[ARVADOS] created: 7252307e0e529e31dea36c5f94efd58c55fee7fd

git at public.curoverse.com git at public.curoverse.com
Tue Nov 3 19:26:59 EST 2015


        at  7252307e0e529e31dea36c5f94efd58c55fee7fd (commit)


commit 7252307e0e529e31dea36c5f94efd58c55fee7fd
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 3 19:26:48 2015 -0500

    4831: Add a885m c97qk wx7k5

diff --git a/apps/backstage/app/backstage-routes.js b/apps/backstage/app/backstage-routes.js
index 1d4961f..11ecfb4 100644
--- a/apps/backstage/app/backstage-routes.js
+++ b/apps/backstage/app/backstage-routes.js
@@ -12,7 +12,7 @@ var m = require('mithril')
 window.jQuery = require('jquery');
 require('bootstrap');
 
-var connections = m.prop('4xphq qr1hi 9tee4 su92l tb05z'.split(' ').map(
+var connections = m.prop('4xphq a885m c97qk qr1hi 9tee4 su92l tb05z wx7k5'.split(' ').map(
     function(site) {
         return ArvadosConnection.make(site);
     }));

commit ef3c5eadfcccf03cf498e1c6e58c0445f281905e
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 22:02:24 2015 -0500

    4831: Rearrange classes more.

diff --git a/apps/backstage/app/backstage-layout.js b/apps/backstage/app/backstage-layout.js
index 746daf8..818daa8 100644
--- a/apps/backstage/app/backstage-layout.js
+++ b/apps/backstage/app/backstage-layout.js
@@ -4,15 +4,15 @@ var m = require('mithril');
 var _ = require('lodash');
 var Layout = require('./base-layout');
 
-function BackstageLayout(innerModules) {
-    return _.extend(this, {
-        controller: BackstageLayout.controller.bind(this, innerModules),
-        view: BackstageLayout.view,
+function BackstageLayout(opts) {
+    _.extend(this, {
+        controller: this.controller.bind(this, opts),
     });
 }
-BackstageLayout.prototype = new Layout();
-BackstageLayout.controller = Layout.controller;
-BackstageLayout.view = function view(ctrl) {
+_.extend(BackstageLayout.prototype, Layout.prototype, {
+    view: view
+});
+function view(ctrl) {
     return [
         m('.navbar.navbar-default', {role: 'navigation'}, [
             m('.container-fluid', [
diff --git a/apps/backstage/app/backstage-routes.js b/apps/backstage/app/backstage-routes.js
index 4f66765..1d4961f 100644
--- a/apps/backstage/app/backstage-routes.js
+++ b/apps/backstage/app/backstage-routes.js
@@ -20,12 +20,18 @@ var connections = m.prop('4xphq qr1hi 9tee4 su92l tb05z'.split(' ').map(
 m.route(document.body, '/', {
     '/login-callback': new BackstageLoginComponent(),
     '/': new BackstageLayout({
-        content: new ArvApiDirectoryComponent(connections)
+        modules: {
+            content: new ArvApiDirectoryComponent({connections: connections}),
+        },
     }),
     '/list/:connection/:modelName': new BackstageLayout({
-        content: ArvIndexComponent
+        modules: {
+            content: ArvIndexComponent
+        },
     }),
     '/show/:uuid': new BackstageLayout({
-        content: ArvShowComponent
+        modules: {
+            content: ArvShowComponent
+        },
     }),
 });
diff --git a/apps/backstage/app/base-layout.js b/apps/backstage/app/base-layout.js
index cba62c9..acab495 100644
--- a/apps/backstage/app/base-layout.js
+++ b/apps/backstage/app/base-layout.js
@@ -1,37 +1,50 @@
-// Layout class. Instances are suitable for passing to m.route().
+// Layout class. Instances of subclasses are suitable for passing to
+// m.route().
 //
 // Usage:
-// new Layout(viewFunction, {main: FooModuleClass, nav: NavComponent})
+// new LayoutSubclass({modules: {main: FooModuleClass, nav: NavComponent}})
 //
-// viewFunction is expected to include this.views.main() and
-// this.views.nav() somewhere in its return value.
-//
-// Content can be given as a class (in which case instances are made
-// with new FooModuleClass()) or as a component instance (i.e., an
-// object with a function named 'controller').
+// Content can be given as:
+// * a class -- instances are made with `new class()`
+// * an array of [cls, arg] -- instances are made with `new cls.controller(arg)`
+// * a component instance (i.e., an object with a function named 'controller')
 //
 // The layout is responsible for creating and unloading controllers.
 
 module.exports = Layout;
 
-var BaseController = require('app/base-ctrl');
+var BaseController = require('./base-ctrl');
 var _ = require('lodash');
 
-function Layout(innerModules) {
+function Layout(opts) {
     return _.extend(this, {
-        controller: Layout.controller.bind(this, innerModules),
+        controller: this.controller.bind(this, opts),
     });
 }
-Layout.controller = function controller(innerModules) {
+_.extend(Layout.prototype, {
+    controller: controller,
+});
+controller.prototype = new BaseController();
+function controller(opts) {
+    _.extend(this, {modules: {}}, opts);
     this.views = {};
     this.controllers = [];
-    Object.keys(innerModules).map(function(key) {
-        var module = innerModules[key];
-        var component = (module.controller instanceof Function) ? module : new module();
-        var ctrl = new component.controller();
+    Object.keys(this.modules).map(function(key) {
+        var module = this.modules[key];
+        var component;
+        var ctrl;
+        if (module instanceof Array) {
+            component = module[0];
+            ctrl = new component.controller(module[1]);
+        } else if (module.controller instanceof Function) {
+            component = module;
+            ctrl = new component.controller();
+        } else {
+            component = new module();
+            ctrl = new component.controller();
+        }
         var view = component.view.bind(component.view, ctrl);
         this.controllers.push(ctrl);
         this.views[key] = view;
     }, this);
-};
-Layout.controller.prototype = new BaseController();
+}
diff --git a/apps/backstage/app/component.arv-api-directory.js b/apps/backstage/app/component.arv-api-directory.js
index 9d1360c..b79765b 100644
--- a/apps/backstage/app/component.arv-api-directory.js
+++ b/apps/backstage/app/component.arv-api-directory.js
@@ -1,43 +1,50 @@
 module.exports = ArvApiDirectoryComponent;
 
-var m = require('mithril')
-, BaseController = require('app/base-ctrl')
-, ArvApiStatusComponent = require('app/component.arv-api-status');
+var m = require('mithril');
+var _ = require('lodash');
+var BaseController = require('./base-ctrl');
+var ArvApiStatusComponent = require('./component.arv-api-status');
 
-function ArvApiDirectoryComponent(connections) {
-    this.controller = Controller;
-    Controller.prototype = new BaseController();
-    function Controller(vm) {
-        this.vm = vm || {};
-        this.vm.widgets = connections().map(function(conn) {
-            var component = new ArvApiStatusComponent(conn);
-            return {
-                view: component.view,
-                controller: new component.controller(),
-            };
-        });
-        // Give BaseController a list of components to unload.
-        this.controllers = function() {
-            return this.vm.widgets.map(function(widget) {
-                return widget.controller;
-            });
-        }.bind(this);
-
-        this.redrawTimer = setInterval(function() {
-            // If redraw is really really cheap, we can do this to make
-            // "#seconds old" timers count in real time.
-            m.redraw();
-        }, 1000);
-        this.onunload = function() {
-            clearTimeout(this.redrawTimer);
+function ArvApiDirectoryComponent(opts) {
+    _.extend(this, {
+        controller: this.controller.bind(this, opts),
+    });
+}
+_.extend(ArvApiDirectoryComponent.prototype, {
+    controller: controller,
+    view: view,
+});
+function controller(opts) {
+    _.extend(this, {connections: m.prop([])}, opts);
+    this.redrawTimer = setInterval(function() {
+        // If redraw is really really cheap, we can do this to make
+        // "#seconds old" timers count in real time.
+        m.redraw();
+    }, 1000);
+    this.widgets = this.connections().map(function(conn) {
+        var component = new ArvApiStatusComponent(conn);
+        return {
+            view: component.view,
+            controller: new component.controller(),
         };
-    };
-    this.view = View;
-    function View(ctrl) {
-        return m('div', [
-            ctrl.vm.widgets.map(function(widget) {
-                return widget.view(widget.controller);
-            })
-        ]);
-    };
+    });
+}
+_.extend(controller.prototype, BaseController.prototype, {
+    // Give BaseController a list of components to unload.
+    controllers: function controllers() {
+        return this.widgets.map(function(widget) {
+            return widget.controller;
+        });
+    },
+    onunload: function onunload() {
+        clearTimeout(this.redrawTimer);
+        BaseController.prototype.onunload.call(this);
+    },
+});
+function view(ctrl) {
+    return m('div', [
+        ctrl.widgets.map(function(widget) {
+            return widget.view(widget.controller);
+        })
+    ]);
 }
diff --git a/apps/backstage/app/component.arv-index.js b/apps/backstage/app/component.arv-index.js
index 8b79f2e..1eded1a 100644
--- a/apps/backstage/app/component.arv-index.js
+++ b/apps/backstage/app/component.arv-index.js
@@ -1,13 +1,18 @@
 module.exports = ArvIndexComponent;
 
-var m = require('mithril')
-, BaseController = require('app/base-ctrl')
-, ArvListComponent = require('app/component.arv-list')
-, ArvObjectRowComponent = require('app/component.arv-object-row')
-, InfiniteScroll = require('app/infinitescroll');
+var m = require('mithril');
+var _ = require('lodash');
+var BaseController = require('./base-ctrl');
+var ArvListComponent = require('./component.arv-list')
+var ArvObjectRowComponent = require('./component.arv-object-row')
+var InfiniteScroll = require('./infinitescroll');
 
 function ArvIndexComponent() {}
-ArvIndexComponent.controller = function controller() {
+_.extend(ArvIndexComponent.prototype, {
+    controller: controller,
+    view: view,
+});
+function controller() {
     this.list =
         new ArvListComponent(null, null, ArvObjectRowComponent);
     this.listCtrl =
@@ -16,10 +21,12 @@ ArvIndexComponent.controller = function controller() {
         new InfiniteScroll(this.listCtrl, this.list.view, {pxThreshold: 200});
     this.scrollerCtrl =
         new this.scroller.controller();
-};
-ArvIndexComponent.controller.prototype.controllers = function controllers() {
-    return [this.listCtrl, this.scrollerCtrl];
-};
-ArvIndexComponent.view = function view(ctrl) {
+}
+_.extend(controller.prototype, BaseController.prototype, {
+    controllers: function controllers() {
+        return [this.listCtrl, this.scrollerCtrl];
+    },
+});
+function view(ctrl) {
     return ctrl.scroller.view(ctrl.scrollerCtrl);
-};
+}

commit 3b62145615a5b40be68b44ffbfda1e3ddb5a6699
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 18:54:38 2015 -0500

    4831: Remove old reference to util namespace.

diff --git a/apps/backstage/app/util.js b/apps/backstage/app/util.js
index 277124f..a856da2 100644
--- a/apps/backstage/app/util.js
+++ b/apps/backstage/app/util.js
@@ -3,8 +3,8 @@ module.exports = {
     debounce: debounce,
 };
 
-// util.choose('a', {a: 'A', b: 'B'}) --> return 'A'
-// util.choose('a', {a: [console.log, 'foo']}) --> return console.log('foo')
+// choose('a', {a: 'A', b: 'B'}) --> return 'A'
+// choose('a', {a: [console.log, 'foo']}) --> return console.log('foo')
 function choose(key, options) {
     var option = options[key];
     if (option instanceof Array && option[0] instanceof Function)
@@ -13,13 +13,13 @@ function choose(key, options) {
         return option;
 }
 
-// util.debounce(250, key) --> Return a promise. If someone else
+// debounce(250, key) --> Return a promise. If someone else
 // calls debounce with the same key, reject the promise. If nobody
 // else has done so after 250ms, resolve the promise.
 function debounce(ms, key) {
     var newpending;
-    util.debounce.pending = util.debounce.pending || [];
-    util.debounce.pending.map(function(found) {
+    debounce.pending = debounce.pending || [];
+    debounce.pending.map(function(found) {
         if (!newpending && found.key === key) {
             // Promise already pending with this key. Reject the old
             // one, reuse its slot for the new one.
@@ -32,15 +32,15 @@ function debounce(ms, key) {
     if (!newpending) {
         // No pending promise with this key.
         newpending = {key: key}
-        util.debounce.pending.push(newpending);
+        debounce.pending.push(newpending);
     }
     newpending.deferred = m.deferred();
     m.startComputation();
     newpending.timer = window.setTimeout(function() {
         // Success, no more bouncing. Remove from pending list.
-        util.debounce.pending.map(function(found, i) {
+        debounce.pending.map(function(found, i) {
             if (found === newpending) {
-                util.debounce.pending.splice(i, 1);
+                debounce.pending.splice(i, 1);
                 found.deferred.resolve();
                 m.endComputation();
             }

commit e7c80567f45a6983f599cf01e5bcc8e32d8853a8
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 18:17:08 2015 -0500

    4831: Unclassize some modules.

diff --git a/apps/backstage/app/backstage-layout.js b/apps/backstage/app/backstage-layout.js
index 5229669..746daf8 100644
--- a/apps/backstage/app/backstage-layout.js
+++ b/apps/backstage/app/backstage-layout.js
@@ -1,8 +1,18 @@
-module.exports = BackstageLayoutView;
+module.exports = BackstageLayout;
 
 var m = require('mithril');
+var _ = require('lodash');
+var Layout = require('./base-layout');
 
-function BackstageLayoutView() {
+function BackstageLayout(innerModules) {
+    return _.extend(this, {
+        controller: BackstageLayout.controller.bind(this, innerModules),
+        view: BackstageLayout.view,
+    });
+}
+BackstageLayout.prototype = new Layout();
+BackstageLayout.controller = Layout.controller;
+BackstageLayout.view = function view(ctrl) {
     return [
         m('.navbar.navbar-default', {role: 'navigation'}, [
             m('.container-fluid', [
@@ -26,7 +36,7 @@ function BackstageLayoutView() {
                 ]),
             ]),
         ]),
-        m('.container-fluid', this.views.content()),
+        m('.container-fluid', ctrl.views.content()),
     ];
     function siteBreadcrumb() {
         var txt;
diff --git a/apps/backstage/app/backstage-routes.js b/apps/backstage/app/backstage-routes.js
index defe480..4f66765 100644
--- a/apps/backstage/app/backstage-routes.js
+++ b/apps/backstage/app/backstage-routes.js
@@ -2,12 +2,12 @@ module.exports = true;
 
 var m = require('mithril')
 , ArvadosConnection = require('arvados/client')
-, Layout = require('app/base-layout')
-, BackstageLayoutView = require('app/backstage-layout')
-, BackstageLoginComponent = require('app/backstage-login')
-, ArvApiDirectoryComponent = require('app/component.arv-api-directory')
-, ArvIndexComponent = require('app/component.arv-index')
-, ArvShowComponent = require('app/component.arv-show');
+, Layout = require('./base-layout')
+, BackstageLayout = require('./backstage-layout')
+, BackstageLoginComponent = require('./backstage-login')
+, ArvApiDirectoryComponent = require('./component.arv-api-directory')
+, ArvIndexComponent = require('./component.arv-index')
+, ArvShowComponent = require('./component.arv-show');
 
 window.jQuery = require('jquery');
 require('bootstrap');
@@ -19,13 +19,13 @@ var connections = m.prop('4xphq qr1hi 9tee4 su92l tb05z'.split(' ').map(
 
 m.route(document.body, '/', {
     '/login-callback': new BackstageLoginComponent(),
-    '/': new Layout(BackstageLayoutView, {
+    '/': new BackstageLayout({
         content: new ArvApiDirectoryComponent(connections)
     }),
-    '/list/:connection/:modelName': new Layout(BackstageLayoutView, {
+    '/list/:connection/:modelName': new BackstageLayout({
         content: ArvIndexComponent
     }),
-    '/show/:uuid': new Layout(BackstageLayoutView, {
+    '/show/:uuid': new BackstageLayout({
         content: ArvShowComponent
     }),
 });
diff --git a/apps/backstage/app/base-component.js b/apps/backstage/app/base-component.js
deleted file mode 100644
index 9aab6f4..0000000
--- a/apps/backstage/app/base-component.js
+++ /dev/null
@@ -1,6 +0,0 @@
-module.exports = BaseComponent;
-
-var BaseController = require('app/base-ctrl');
-
-BaseComponent.prototype.controller = BaseController;
-function BaseComponent() {}
diff --git a/apps/backstage/app/base-ctrl.js b/apps/backstage/app/base-ctrl.js
index 68f9b8f..feb33a2 100644
--- a/apps/backstage/app/base-ctrl.js
+++ b/apps/backstage/app/base-ctrl.js
@@ -2,6 +2,7 @@ module.exports = BaseController;
 
 var m = require('mithril');
 
+function BaseController() {}
 BaseController.prototype.selectUuid =
     function selectUuid(uuid) {
         m.route('/show/' + uuid);
@@ -19,6 +20,3 @@ BaseController.prototype.onunload =
                 ctrl.onunload();
         });
     }
-function BaseController(vm) {
-    this.vm = vm;
-}
diff --git a/apps/backstage/app/base-layout.js b/apps/backstage/app/base-layout.js
index 29adbc0..cba62c9 100644
--- a/apps/backstage/app/base-layout.js
+++ b/apps/backstage/app/base-layout.js
@@ -14,24 +14,24 @@
 
 module.exports = Layout;
 
-var BaseComponent = require('app/base-component')
-, BaseController = require('app/base-ctrl');
+var BaseController = require('app/base-ctrl');
+var _ = require('lodash');
 
-Layout.prototype = BaseComponent;
-function Layout(layoutView, innerModules) {
-    var layout = this;
-    this.views = {};
-    this.controller = function controller() {
-        this.controllers = [];
-        Object.keys(innerModules).map(function(key) {
-            var module = innerModules[key];
-            var component = (module.controller instanceof Function) ? module : new module();
-            var ctrl = new component.controller();
-            var view = component.view.bind(component.view, ctrl);
-            this.controllers.push(ctrl);
-            layout.views[key] = view;
-        }, this);
-    };
-    this.controller.prototype = new BaseController();
-    this.view = layoutView.bind(this, this.controller);
+function Layout(innerModules) {
+    return _.extend(this, {
+        controller: Layout.controller.bind(this, innerModules),
+    });
 }
+Layout.controller = function controller(innerModules) {
+    this.views = {};
+    this.controllers = [];
+    Object.keys(innerModules).map(function(key) {
+        var module = innerModules[key];
+        var component = (module.controller instanceof Function) ? module : new module();
+        var ctrl = new component.controller();
+        var view = component.view.bind(component.view, ctrl);
+        this.controllers.push(ctrl);
+        this.views[key] = view;
+    }, this);
+};
+Layout.controller.prototype = new BaseController();
diff --git a/apps/backstage/app/component.arv-api-directory.js b/apps/backstage/app/component.arv-api-directory.js
index 1370ede..9d1360c 100644
--- a/apps/backstage/app/component.arv-api-directory.js
+++ b/apps/backstage/app/component.arv-api-directory.js
@@ -1,11 +1,9 @@
 module.exports = ArvApiDirectoryComponent;
 
 var m = require('mithril')
-, BaseComponent = require('app/base-component')
 , BaseController = require('app/base-ctrl')
 , ArvApiStatusComponent = require('app/component.arv-api-status');
 
-ArvApiDirectoryComponent.prototype = new BaseComponent();
 function ArvApiDirectoryComponent(connections) {
     this.controller = Controller;
     Controller.prototype = new BaseController();
@@ -18,7 +16,7 @@ function ArvApiDirectoryComponent(connections) {
                 controller: new component.controller(),
             };
         });
-        // Give BaseComponent a list of components to unload.
+        // Give BaseController a list of components to unload.
         this.controllers = function() {
             return this.vm.widgets.map(function(widget) {
                 return widget.controller;
diff --git a/apps/backstage/app/component.arv-index.js b/apps/backstage/app/component.arv-index.js
index 10a0c93..8b79f2e 100644
--- a/apps/backstage/app/component.arv-index.js
+++ b/apps/backstage/app/component.arv-index.js
@@ -6,28 +6,20 @@ var m = require('mithril')
 , ArvObjectRowComponent = require('app/component.arv-object-row')
 , InfiniteScroll = require('app/infinitescroll');
 
-function ArvIndexComponent() {
-    this.controller = Controller;
-    this.view = function view(ctrl) {
-        return ctrl.vm.scroller.view(ctrl.vm.scrollerCtrl);
-    };
-    function ViewModel() {
-        this.list =
-            new ArvListComponent(null, null, new ArvObjectRowComponent());
-        this.listCtrl =
-            new this.list.controller();
-        this.scroller =
-            new InfiniteScroll(this.listCtrl, this.list.view,
-                               {pxThreshold: 200});
-        this.scrollerCtrl =
-            new this.scroller.controller();
-    }
-    function Controller() {
-        this.vm = new ViewModel();
-    }
-    Controller.prototype = new BaseController();
-    Controller.prototype.controllers =
-        function controllers() {
-            return [this.vm.listCtrl, this.vm.scrollerCtrl];
-        }
-}
+function ArvIndexComponent() {}
+ArvIndexComponent.controller = function controller() {
+    this.list =
+        new ArvListComponent(null, null, ArvObjectRowComponent);
+    this.listCtrl =
+        new this.list.controller();
+    this.scroller =
+        new InfiniteScroll(this.listCtrl, this.list.view, {pxThreshold: 200});
+    this.scrollerCtrl =
+        new this.scroller.controller();
+};
+ArvIndexComponent.controller.prototype.controllers = function controllers() {
+    return [this.listCtrl, this.scrollerCtrl];
+};
+ArvIndexComponent.view = function view(ctrl) {
+    return ctrl.scroller.view(ctrl.scrollerCtrl);
+};
diff --git a/apps/backstage/app/component.arv-object-row.js b/apps/backstage/app/component.arv-object-row.js
index 73504d6..df45024 100644
--- a/apps/backstage/app/component.arv-object-row.js
+++ b/apps/backstage/app/component.arv-object-row.js
@@ -7,29 +7,29 @@
 // mod.view(ctrl)
 module.exports = ArvObjectRowComponent;
 
-var m = require('mithril')
-, BaseComponent = require('app/base-component');
+var m = require('mithril');
+var BaseController = require('./base-ctrl');
 
-ArvObjectRowComponent.prototype = new BaseComponent();
-function ArvObjectRowComponent() {
-    this.view = function(ctrl) {
-        var _item = ctrl.vm.item;
-        var _owner = _item.owner_uuid ? _item._conn.find(_item.owner_uuid)() : '';
-        return m('.row', [
-            m('.col-sm-3', [
-                m('.btn.btn-xs',
-                  {onclick: ctrl.selectUuid.bind(ctrl, _item.uuid)}, [
-                      m('span.glyphicon.glyphicon-link'),
-                  ]),
-                _item.uuid,
+function ArvObjectRowComponent() {}
+ArvObjectRowComponent.controller = function(props) { this.props = props }
+ArvObjectRowComponent.controller.prototype = new BaseController();
+ArvObjectRowComponent.view = function(ctrl) {
+    var _item = ctrl.props.item;
+    var _owner = _item.owner_uuid ? _item._conn.find(_item.owner_uuid)() : '';
+    return m('.row', [
+        m('.col-sm-3', [
+            m('.btn.btn-xs',
+              {onclick: ctrl.selectUuid.bind(ctrl, _item.uuid)}, [
+                  m('span.glyphicon.glyphicon-link'),
+              ]),
+            _item.uuid,
+        ]),
+        m('.col-sm-4', _item.name),
+        m('.col-sm-2', [
+            m('a[href="/show/'+_item.owner_uuid+'"]', {config:m.route}, [
+                _owner && (_owner.full_name || _owner.name)
             ]),
-            m('.col-sm-4', _item.name),
-            m('.col-sm-2', [
-                m('a[href="/show/'+_item.owner_uuid+'"]', {config:m.route}, [
-                    _owner && (_owner.full_name || _owner.name)
-                ]),
-            ]),
-            m('.col-sm-2', new Date(_item.created_at).toLocaleString()),
-        ]);
-    };
-}
+        ]),
+        m('.col-sm-2', new Date(_item.created_at).toLocaleString()),
+    ]);
+};

commit d3be209f62d66580878ea1f49b0ae9ce2d01d4a2
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 16:19:43 2015 -0500

    4831: Show logged-in email address on dashboard summary. Remove a level of prop() redirection.

diff --git a/apps/backstage/app/component.arv-api-status.js b/apps/backstage/app/component.arv-api-status.js
index 230700e..22d23b2 100644
--- a/apps/backstage/app/component.arv-api-status.js
+++ b/apps/backstage/app/component.arv-api-status.js
@@ -9,6 +9,7 @@ function ArvApiStatusComponent(connection) {
     apistatus.vm = (function() {
         var vm = {};
         vm.connection = connection;
+        vm.currentUser = m.prop({});
         vm.dd = connection.discoveryDoc;
         vm.dirty = true;
         vm.init = function() {
@@ -16,13 +17,15 @@ function ArvApiStatusComponent(connection) {
                 vm.refresh();
             vm.dirty = false;
         };
-        vm.keepServices = m.prop();
-        vm.nodes = m.prop();
+        vm.keepServices = m.prop([]);
+        vm.nodes = m.prop([]);
         vm.refresh = function() {
             vm.connection.api(
-                'KeepService', 'list', {}).then(vm.keepServices);
+                'KeepService', 'list', {}).then(vm.keepServices).then(m.redraw);
             vm.connection.api(
-                'Node', 'list', {}).then(vm.nodes);
+                'Node', 'list', {}).then(vm.nodes).then(m.redraw);
+            vm.connection.api(
+                'User', 'current', {}).then(vm.currentUser).then(m.redraw);
         };
         vm.logout = function() {
             vm.connection.token(undefined);
@@ -61,8 +64,10 @@ function ArvApiStatusComponent(connection) {
                 !vm.dd() ? '' : m('.pull-right', [
                     util.choose(!!vm.connection.token(), {
                         true: [function() {
-                            return m('a.btn.btn-xs.btn-default',
-                                     {onclick: vm.logout}, 'Log out');
+                            return [vm.currentUser().email,
+                                    " ",
+                                    m('a.btn.btn-xs.btn-default',
+                                      {onclick: vm.logout}, 'Log out')];
                         }],
                         false: [function() {
                             return m('a.btn.btn-xs.btn-primary',
@@ -81,41 +86,41 @@ function ArvApiStatusComponent(connection) {
                           ]);
                       })),
                     m('.col-md-4', [
-                        !vm.keepServices() ? '' : m('ul', [
+                        m('ul', [
                             '' + vm.keepServices().length + ' Keep services',
                             vm.keepServices().map(function(keepService) {
                                 return m('li', [
                                     m('span.label.label-default',
-                                      keepService().service_type),
+                                      keepService.service_type),
                                     ' ',
                                     m('a',
-                                      {href: '/show/'+keepService().uuid,
+                                      {href: '/show/'+keepService.uuid,
                                        config: m.route}, [
-                                           keepService().service_host,
+                                           keepService.service_host,
                                            ':',
-                                           keepService().service_port,
+                                           keepService.service_port,
                                        ]),
                                 ]);
                             }),
                         ]),
                     ]),
                     m('.col-md-4', [
-                        !vm.nodes() ? '' : m('ul', [
+                        m('ul', [
                             '' + vm.nodes().length + ' worker nodes',
                             vm.nodes().filter(function(node) {
-                                return node().crunch_worker_state != 'down';
+                                return node.crunch_worker_state != 'down';
                             }).map(function(node) {
                                 return m('li', [
                                     m('span.label.label-default', [
-                                        node().crunch_worker_state,
+                                        node.crunch_worker_state,
                                     ]),
                                     ' ',
-                                    m('a', {href: '/show/'+node().uuid,
+                                    m('a', {href: '/show/'+node.uuid,
                                             config: m.route},
-                                      node().hostname),
+                                      node.hostname),
                                     ' ',
                                     m('span.label.label-info', {title: 'time since last ping'}, [
-                                        ((new Date() - Date.parse(node().last_ping_at))/1000).toFixed(),
+                                        ((new Date() - Date.parse(node.last_ping_at))/1000).toFixed(),
                                         's'
                                     ]),
                                 ]);
diff --git a/apps/backstage/app/component.arv-object-row.js b/apps/backstage/app/component.arv-object-row.js
index 9222408..73504d6 100644
--- a/apps/backstage/app/component.arv-object-row.js
+++ b/apps/backstage/app/component.arv-object-row.js
@@ -13,7 +13,7 @@ var m = require('mithril')
 ArvObjectRowComponent.prototype = new BaseComponent();
 function ArvObjectRowComponent() {
     this.view = function(ctrl) {
-        var _item = ctrl.vm.item();
+        var _item = ctrl.vm.item;
         var _owner = _item.owner_uuid ? _item._conn.find(_item.owner_uuid)() : '';
         return m('.row', [
             m('.col-sm-3', [
diff --git a/apps/backstage/app/component.dmgraph.js b/apps/backstage/app/component.dmgraph.js
index dcf9894..67ca595 100644
--- a/apps/backstage/app/component.dmgraph.js
+++ b/apps/backstage/app/component.dmgraph.js
@@ -14,12 +14,12 @@ DataManagerGraph.controller = function(opts) {
         var seriesLabel = {};
         var data = _.compact(this.logs().map(function(item){
             try {
-                var p = item().properties;
+                var p = item.properties;
                 var runId = '' + p.run_info.pid + '/' + p.run_info.start_time;
                 var pt = {
                     seriesLabel: '' + p.run_info.pid,
                     collectionsRead: p.collection_info.collections_read,
-                    logCreatedAt: toDate(item().created_at),
+                    logCreatedAt: toDate(item.created_at),
                     runStartTime: toDate(p.run_info.start_time),
                 };
                 pt[runId] = pt.collectionsRead;
diff --git a/apps/backstage/arvados/client.js b/apps/backstage/arvados/client.js
index 935cd3e..6a3a977 100644
--- a/apps/backstage/arvados/client.js
+++ b/apps/backstage/arvados/client.js
@@ -181,7 +181,7 @@ function ArvadosConnection(apiPrefix) {
             store[response.uuid](response);
             store[response.uuid]()._cacheTime = new Date();
             store[response.uuid]()._conn = connection;
-            return store[response.uuid];
+            return store[response.uuid]();
         } else {
             return response;
         }

commit a3a78746fc25d0e77b825c448d61662223197f38
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 12:20:19 2015 -0500

    4831: Add more error checking to mithril deferred.onerror.

diff --git a/apps/backstage/app/util.js b/apps/backstage/app/util.js
index f8b4d8e..277124f 100644
--- a/apps/backstage/app/util.js
+++ b/apps/backstage/app/util.js
@@ -48,3 +48,13 @@ function debounce(ms, key) {
     }, ms);
     return newpending.deferred.promise;
 }
+
+// Override mithril's default deferred.onerror, with more error checking
+var m = require('mithril');
+m.deferred.onerror = function(e) {
+    if ({}.toString.call(e) === "[object Error]" &&
+        !(e.constructor &&
+          e.constructor.toString() &&
+          e.constructor.toString().match(/ Error/)))
+        throw e;
+};

commit c4a18f7a6084e559fdb0182da2d52ee04a5dacef
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 04:39:57 2015 -0500

    4831: Add morris graph of data manager log entries.

diff --git a/apps/backstage/Makefile b/apps/backstage/Makefile
index 035cffd..9273ce8 100644
--- a/apps/backstage/Makefile
+++ b/apps/backstage/Makefile
@@ -6,7 +6,7 @@ clean:
 	rm -rvf dist
 
 npmdeps:
-	ulimit -n 4000; for x in bootstrap bower browserify chai chai-jquery jquery jsdom mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
+	ulimit -n 4000; for x in bootstrap bower browserify chai chai-jquery jquery jsdom lodash mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
 
 # This uses --debug to enable source maps.
 build: build-app build-test build-assets
diff --git a/apps/backstage/app/component.arv-api-status.js b/apps/backstage/app/component.arv-api-status.js
index 0dff644..230700e 100644
--- a/apps/backstage/app/component.arv-api-status.js
+++ b/apps/backstage/app/component.arv-api-status.js
@@ -1,7 +1,8 @@
 module.exports = ArvApiStatusComponent;
 
 var m = require('mithril')
-, util = require('app/util');
+, util = require('app/util')
+, DataManagerGraph = require('./component.dmgraph');
 
 function ArvApiStatusComponent(connection) {
     var apistatus = {};
@@ -43,6 +44,9 @@ function ArvApiStatusComponent(connection) {
                                  vm.dd().websocketUrl)}, ['none'])
             }
         };
+        vm.dmGraphCtrl = new DataManagerGraph.controller({
+            connection: connection,
+        });
         return vm;
     })();
     apistatus.controller = function() {
@@ -128,6 +132,11 @@ function ArvApiStatusComponent(connection) {
                         }, arvModelName+'s'),
                     ]);
                 })),
+                m('.row', [
+                  m('.col-sm-12', [
+                      DataManagerGraph.view(vm.dmGraphCtrl)
+                  ]),
+                ]),
             ]),
         ]);
     };
diff --git a/apps/backstage/app/component.dmgraph.js b/apps/backstage/app/component.dmgraph.js
new file mode 100644
index 0000000..dcf9894
--- /dev/null
+++ b/apps/backstage/app/component.dmgraph.js
@@ -0,0 +1,63 @@
+module.exports = DataManagerGraph;
+
+var _ = require('lodash');
+var m = require('mithril');
+// var Morris = require('morrisjs'); /* It's currently a global. */
+
+function DataManagerGraph() {};
+DataManagerGraph.controller = function(opts) {
+    opts.connection.api('Log', 'list', {
+        filters: [['event_type', '=', 'experimental-data-manager-report']],
+        order: ['created_at DESC'],
+    }).then(this.logs = m.prop([])).then(m.redraw);
+    this.configGraph = function configGraph(element, isInitialized, context) {
+        var seriesLabel = {};
+        var data = _.compact(this.logs().map(function(item){
+            try {
+                var p = item().properties;
+                var runId = '' + p.run_info.pid + '/' + p.run_info.start_time;
+                var pt = {
+                    seriesLabel: '' + p.run_info.pid,
+                    collectionsRead: p.collection_info.collections_read,
+                    logCreatedAt: toDate(item().created_at),
+                    runStartTime: toDate(p.run_info.start_time),
+                };
+                pt[runId] = pt.collectionsRead;
+                seriesLabel[runId] = p.run_info.pid;
+                return pt;
+            } catch(e) {
+                return null;
+            }
+        }));
+        var chartopts = {
+            element: element,
+            hoverCallback: morrisHoverCallback,
+            xkey: 'logCreatedAt',
+            ykeys: _.keys(seriesLabel),
+            labels: _.values(seriesLabel),
+            resize: true,
+        };
+        if (!isInitialized || !_.isEqual(chartopts, context.chartopts)) {
+            context.chartopts = chartopts;
+            context.chart = new Morris.Line(_.merge({data:data}, chartopts));
+        } else if (!context.chart) {
+            // Initialization crashed, no chart?
+        } else if (!_.isEqual(data, context.data)) {
+            context.chart.setData(data);
+        }
+        context.data = data;
+    }.bind(this);
+    function toDate(timestamp) {
+        // 2015-01-13T01:13:52.281556508Z -> 2015-01-13 01:13:52.281
+        return timestamp.match(/([-\d]{10})T([:\.\d]{8,12})/).slice(1).join(' ');
+    }
+    function morrisHoverCallback(index, options, content, row) {
+        return ''+row.seriesLabel+': '+row.collectionsRead+' collections read @ '+row.logCreatedAt;
+    }
+};
+DataManagerGraph.view = function(ctrl) {
+    return ctrl.logs().length==0 ? [] : m('div', {
+        config: ctrl.configGraph,
+        style: { width: '100%', height: 200 },
+    });
+};
diff --git a/apps/backstage/index.html b/apps/backstage/index.html
index 691d347..a4b6312 100644
--- a/apps/backstage/index.html
+++ b/apps/backstage/index.html
@@ -7,9 +7,13 @@
     <title>arvados backstage</title>
     <link href="dist/bootstrap/css/bootstrap.min.css" rel="stylesheet">
     <link href="dist/bootstrap/css/bootstrap-theme.min.css" rel="stylesheet">
+    <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css">
     <link href="app/backstage.css" rel="stylesheet">
   </head>
   <body>
+    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.min.js"></script>
     <script src="dist/app.js"></script>
   </body>
 </html>

commit 3b8e03d5fcd6726c68b5cbaf1cc4df69428c4468
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 03:54:23 2015 -0500

    4831: Rearrange some files.

diff --git a/apps/backstage/.gitignore b/apps/backstage/.gitignore
index 7c8727f..e1497d4 100644
--- a/apps/backstage/.gitignore
+++ b/apps/backstage/.gitignore
@@ -1,6 +1,7 @@
 dist
-node_modules
+node_modules/*
 !node_modules/app
 !node_modules/arvados
 !node_modules/mithril-jquery
 !node_modules/test
+npm-debug.log
diff --git a/apps/backstage/node_modules/arvados/client.js b/apps/backstage/arvados/client.js
similarity index 100%
rename from apps/backstage/node_modules/arvados/client.js
rename to apps/backstage/arvados/client.js
diff --git a/apps/backstage/node_modules/mithril-jquery/index.js b/apps/backstage/mithril-jquery/index.js
similarity index 100%
rename from apps/backstage/node_modules/mithril-jquery/index.js
rename to apps/backstage/mithril-jquery/index.js
diff --git a/apps/backstage/node_modules/arvados b/apps/backstage/node_modules/arvados
new file mode 120000
index 0000000..4f53985
--- /dev/null
+++ b/apps/backstage/node_modules/arvados
@@ -0,0 +1 @@
+../arvados
\ No newline at end of file
diff --git a/apps/backstage/node_modules/mithril-jquery b/apps/backstage/node_modules/mithril-jquery
new file mode 120000
index 0000000..d57928e
--- /dev/null
+++ b/apps/backstage/node_modules/mithril-jquery
@@ -0,0 +1 @@
+../mithril-jquery
\ No newline at end of file

commit 46b7efb1c6013fe695dd9c65ba892adb92e3c7d0
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Jan 19 03:50:56 2015 -0500

    4831: Simplify promise use.

diff --git a/apps/backstage/app/component.arv-api-status.js b/apps/backstage/app/component.arv-api-status.js
index 64e8d64..0dff644 100644
--- a/apps/backstage/app/component.arv-api-status.js
+++ b/apps/backstage/app/component.arv-api-status.js
@@ -15,11 +15,13 @@ function ArvApiStatusComponent(connection) {
                 vm.refresh();
             vm.dirty = false;
         };
+        vm.keepServices = m.prop();
+        vm.nodes = m.prop();
         vm.refresh = function() {
-            vm.nodes = vm.connection.api(
-                'Node', 'list', {});
-            vm.keepServices = vm.connection.api(
-                'KeepService', 'list', {});
+            vm.connection.api(
+                'KeepService', 'list', {}).then(vm.keepServices);
+            vm.connection.api(
+                'Node', 'list', {}).then(vm.nodes);
         };
         vm.logout = function() {
             vm.connection.token(undefined);
diff --git a/apps/backstage/node_modules/arvados/client.js b/apps/backstage/node_modules/arvados/client.js
index 380384d..935cd3e 100644
--- a/apps/backstage/node_modules/arvados/client.js
+++ b/apps/backstage/node_modules/arvados/client.js
@@ -93,17 +93,10 @@ function ArvadosConnection(apiPrefix) {
     // modelClass: 'Collection', 'Node', etc.
     // action: 'get', 'list', 'update', etc.
     // params: {uuid:'foo',filters:[],...}
-    // deferred (optional): deferred object for response. If not
-    // supplied, a new one is created.
-    function api(modelClass, action, params, deferred) {
-        deferred = deferred || m.deferred();
-        connection.ready.then(function() {
-            connection[modelClass][action](params).
-                then(updateStore).
-                then(deferred.resolve, deferred.reject).
-                then(m.redraw);
-        }, deferred.reject);
-        return deferred.promise;
+    function api(modelClass, action, params) {
+        return connection.ready.then(function() {
+            return connection[modelClass][action](params);
+        }).then(updateStore);
     }
 
     // Private instance variables

commit e3829a29b1d8b5a7bdfce82c784812996fec7c47
Author: Tom Clegg <tom at curoverse.com>
Date:   Sun Jan 4 03:59:12 2015 -0500

    4831: Add tb05z

diff --git a/apps/backstage/app/backstage-routes.js b/apps/backstage/app/backstage-routes.js
index 0575620..defe480 100644
--- a/apps/backstage/app/backstage-routes.js
+++ b/apps/backstage/app/backstage-routes.js
@@ -12,7 +12,7 @@ var m = require('mithril')
 window.jQuery = require('jquery');
 require('bootstrap');
 
-var connections = m.prop('4xphq qr1hi 9tee4 su92l bogus'.split(' ').map(
+var connections = m.prop('4xphq qr1hi 9tee4 su92l tb05z'.split(' ').map(
     function(site) {
         return ArvadosConnection.make(site);
     }));

commit 0732b731453ca16312cb6a59aa4af2a6bdbab7f7
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 16:21:02 2014 -0500

    4831: Add eof() controller method.

diff --git a/apps/backstage/app/component.arv-list.js b/apps/backstage/app/component.arv-list.js
index d2dbf13..1414db0 100644
--- a/apps/backstage/app/component.arv-list.js
+++ b/apps/backstage/app/component.arv-list.js
@@ -21,6 +21,10 @@ function ArvListComponent(connection, arvModelName, contentModule) {
         function getMoreItems() {
             return this.vm.getMoreItems.apply(this.vm, arguments);
         }
+    Controller.prototype.eof =
+        function eof() {
+            return this.vm.eof();
+        }
     Controller.prototype.currentFilter =
         function currentFilter(key, attr, operator, operand) {
             if (arguments.length > 1) {

commit a27e3f6ad0077c8a84c06ffb2862aa716167cbb8
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 16:20:33 2014 -0500

    4831: Change ObjectType dropdown label.

diff --git a/apps/backstage/app/filter.js b/apps/backstage/app/filter.js
index 19c95bc..7d2fdff 100644
--- a/apps/backstage/app/filter.js
+++ b/apps/backstage/app/filter.js
@@ -27,7 +27,7 @@ function FilterObjectType(opts) {
             m('.input-group.input-group-sm', [
                 m('.input-group-btn', [
                     m('button.btn.btn-default.dropdown-toggle[type="button"][data-toggle="dropdown"]', [
-                        ctrl.currentFilter() ? ctrl.currentFilter()[2].replace(/^.*#/,'') : 'Type',
+                        ctrl.currentFilter() ? ctrl.currentFilter()[2].replace(/^.*#/,'') : 'Any type',
                         ' ',
                         m('span.caret'),
                     ]),
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
index 462162c..a99e64d 100644
--- a/apps/backstage/test/unit/filter.js
+++ b/apps/backstage/test/unit/filter.js
@@ -34,12 +34,12 @@ suite('Filter', function() {
     suite('ObjectType', function() {
         test("uses existing filter value as initial label", function() {
             f = prep(Filter.ObjectType, ['fakeAttr','is_a','arvados#collection']);
-            c.assert.lengthOf(m$('.dropdown-toggle:contains(Type)', f.vdom), 0);
+            c.assert.lengthOf(m$('.dropdown-toggle:contains(Any type)', f.vdom), 0);
             c.assert.lengthOf(m$('.dropdown-toggle:contains(collection)', f.vdom), 1);
         });
-        test("uses 'Type' as initial label if no current filter", function() {
+        test("uses 'Any type' as initial label if no current filter", function() {
             f = prep(Filter.ObjectType, undefined);
-            c.assert.lengthOf(m$('.dropdown-toggle:contains(Type)', f.vdom), 1);
+            c.assert.lengthOf(m$('.dropdown-toggle:contains(Any type)', f.vdom), 1);
         });
         test("calls currentFilter when selection clicked", function() {
             f = prep(Filter.ObjectType);

commit d3bcddaca100415eed3f5aa37994d2dd5a7e5d2a
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 16:20:00 2014 -0500

    4831: Detect EOF using items_available/offset in API response

diff --git a/apps/backstage/app/component.arv-list.js b/apps/backstage/app/component.arv-list.js
index c859c12..d2dbf13 100644
--- a/apps/backstage/app/component.arv-list.js
+++ b/apps/backstage/app/component.arv-list.js
@@ -96,7 +96,9 @@ function ArvListComponent(connection, arvModelName, contentModule) {
                     // new one. Ignore.
                     return;
                 }
-                vm.eof(newItems.length === 0);
+                vm.eof(newItems.length === 0 ||
+                       (typeof newItems.offset === 'number' &&
+                        newItems.items_available === newItems.offset + newItems.length));
                 vm.items(vm.items().concat(newItems));
             }, vm.eof).then(vm.makeItemViews).then(function() {
                 // Give the new items a chance to render before

commit a8970774fdea988769845fa60901bf7d58369552
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 15:56:59 2014 -0500

    4831: Add test methods.

diff --git a/apps/backstage/Makefile b/apps/backstage/Makefile
index 209a8ae..035cffd 100644
--- a/apps/backstage/Makefile
+++ b/apps/backstage/Makefile
@@ -25,5 +25,10 @@ server: build
 	[ -e $(NPMBIN)/harp ] || npm install harp
 	$(NPMBIN)/harp server --port 9000
 
-test: build-test build-assets
+test-phantomjs: build-test build-assets
 	$(NPMBIN)/mocha-phantomjs test.html
+test-watch:
+	$(NPMBIN)/mocha --watch
+.PHONY: test
+test:
+	$(NPMBIN)/mocha
diff --git a/apps/backstage/README.md b/apps/backstage/README.md
index d16a038..dd92262 100644
--- a/apps/backstage/README.md
+++ b/apps/backstage/README.md
@@ -1,20 +1,57 @@
-Prerequisites
-=============
+# Install
 
-    sudo apt-get install nodejs
+## Prerequisites
 
-Install dependencies and build dist files
-=========================================
+```
+sudo apt-get install nodejs
+```
 
-    make
+## Install dependencies and build dist files
 
-Update dist files and run a dev server
-======================================
+```
+make
+```
 
-    make server
+# Develop
 
-Update dependencies
-===================
+## Update dist files and run a dev server
 
-    npm update
-    make
+```
+make server
+```
+
+## Update dependencies
+
+```
+npm update && make
+```
+
+# Test
+
+## Run test suite
+
+This uses mocha, node.js, and jsdom.
+
+```
+make test
+```
+
+Run mocha in "watch" mode to re-run tests whenever you change a source file.
+
+```
+make test-watch
+```
+
+## Run test suite using phantomjs
+
+```
+make test-phantomjs
+```
+
+## Run test suite using a real browser
+
+```
+make server
+```
+
+Point your browser at http://localhost:9000/test.html

commit b659792bfad5a62adeb9b959dc7d1b99c6f889a5
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 10:45:07 2014 -0500

    4831: Rearrange mithril-dom as mithril-jquery

diff --git a/apps/backstage/.gitignore b/apps/backstage/.gitignore
index a2fa9ac..7c8727f 100644
--- a/apps/backstage/.gitignore
+++ b/apps/backstage/.gitignore
@@ -2,4 +2,5 @@ dist
 node_modules
 !node_modules/app
 !node_modules/arvados
+!node_modules/mithril-jquery
 !node_modules/test
diff --git a/apps/backstage/node_modules/mithril-jquery/index.js b/apps/backstage/node_modules/mithril-jquery/index.js
new file mode 100644
index 0000000..bd6e3bc
--- /dev/null
+++ b/apps/backstage/node_modules/mithril-jquery/index.js
@@ -0,0 +1,40 @@
+module.exports = mJquery;
+
+var m = require('mithril');
+var global = (function() { return this })();
+var usingWin = global;
+var ready;
+
+mJquery.ready = function() {
+    if (ready) {
+        // Already done, or underway.
+    } else if (typeof window !== 'undefined' && global === window) {
+        ready = m.deferred();
+        ready.resolve(require('jquery'));
+    } else {
+        ready = m.deferred();
+        require('jsdom').env({
+            html: '<!doctype html><html></html>',
+            scripts: [
+                '../jquery/dist/jquery.js',
+                '../mithril/mithril.js',
+            ],
+            done: function(err, win) {
+                if (err) {
+                    console.log("jsdom setup failed: "+JSON.stringify(err));
+                    ready.reject(err);
+                } else {
+                    usingWin = win;
+                    ready.resolve(win.jQuery);
+                }
+            }
+        });
+    }
+    return ready.promise;
+}
+
+function mJquery(selector, cell) {
+    var $div = ready.promise()('<div></div>');
+    (usingWin.m || m).render($div[0], cell);
+    return ready.promise()(selector, $div[0]);
+}
diff --git a/apps/backstage/test/mithril-dom.js b/apps/backstage/test/mithril-dom.js
deleted file mode 100644
index e1fd8d0..0000000
--- a/apps/backstage/test/mithril-dom.js
+++ /dev/null
@@ -1,33 +0,0 @@
-var $ = require('jquery')
-, jsdom = require('jsdom')
-, m = require('mithril');
-
-module.exports = md;
-
-var global = (function() { return this })();
-var jsdomWin = global;
-
-md.ready = function(cb) {
-    if (typeof window !== 'undefined' && global === window) {
-        cb($);
-        return;
-    }
-    jsdom.env({
-        html: '<html></html>',
-        scripts: [
-            '../node_modules/jquery/dist/jquery.js',
-            '../node_modules/mithril/mithril.js',
-        ],
-        done: function(err, win) {
-            jsdomWin = win;
-            $ = win.jQuery;
-            cb($);
-        }
-    });
-}
-
-function md(cell) {
-    var div = $('<div></div>')[0];
-    (jsdomWin.m || m).render(div, cell);
-    return div.children;
-}
diff --git a/apps/backstage/test/mocha.opts b/apps/backstage/test/mocha.opts
new file mode 100644
index 0000000..5efaf24
--- /dev/null
+++ b/apps/backstage/test/mocha.opts
@@ -0,0 +1 @@
+--ui tdd
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
index 427866f..462162c 100644
--- a/apps/backstage/test/unit/filter.js
+++ b/apps/backstage/test/unit/filter.js
@@ -1,7 +1,7 @@
 var Filter = require('app/filter')
 , chai = require('chai')
 , m = require('mithril')
-, md = require('test/mithril-dom')
+, m$ = require('mithril-jquery')
 , mq = require('mithril-query')
 , sinon = require('sinon')
 , $ = require('jquery')
@@ -9,53 +9,41 @@ var Filter = require('app/filter')
 , s = sinon;
 
 suite('Filter', function() {
-    setup(function(done) {
-        md.ready(function(jQuery) {
-            $ = jQuery;
-            done();
-        });
-    });
+    setup(m$.ready);
     function prep(filterClass, initialFilter) {
         var f = {};
         f.tested = new filterClass({attr: 'fakeAttr'});
         f.cfSpy = sinon.stub();
         f.cfSpy.withArgs().returns(initialFilter);
         f.ctrl = {currentFilter: f.cfSpy};
-        f.rendered = f.tested.view(f.ctrl);
-        f.domfrag = mq(f.rendered);
+        f.vdom = f.tested.view(f.ctrl);
         return f;
     }
     suite('AnyText', function() {
-        test("default is existing filter value", function() {
+        test("uses existing filter as initial input value", function() {
             f = prep(Filter.AnyText, ['any','ilike','%quux%']);
-            f.rendered = f.tested.view(f.ctrl);
-            f.domfrag = mq(f.rendered);
-            c.assert.equal(f.domfrag.first('input').attrs.value, "quux");
+            c.assert.equal(mq(f.vdom).first('input').attrs.value, "quux");
         });
-        test("fires currentFilter on input change", function() {
+        test("calls currentFilter when input changes", function() {
             f = prep(Filter.AnyText);
-            f.domfrag.setValue('input', 'qux');
+            mq(f.vdom).setValue('input', 'qux');
             // Should call again to set new filter value
             s.assert.calledWith(f.cfSpy, 'any', 'ilike', '%qux%');
         });
     });
     suite('ObjectType', function() {
-        test("default is existing filter value", function() {
+        test("uses existing filter value as initial label", function() {
             f = prep(Filter.ObjectType, ['fakeAttr','is_a','arvados#collection']);
-            f.rendered = f.tested.view(f.ctrl);
-            f.md = md(f.rendered);
-            c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 0);
-            c.assert.lengthOf($('.dropdown-toggle:contains(collection)', f.md), 1);
+            c.assert.lengthOf(m$('.dropdown-toggle:contains(Type)', f.vdom), 0);
+            c.assert.lengthOf(m$('.dropdown-toggle:contains(collection)', f.vdom), 1);
         });
-        test("show generic label if no existing filter value", function() {
+        test("uses 'Type' as initial label if no current filter", function() {
             f = prep(Filter.ObjectType, undefined);
-            f.rendered = f.tested.view(f.ctrl);
-            f.md = md(f.rendered);
-            c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 1);
+            c.assert.lengthOf(m$('.dropdown-toggle:contains(Type)', f.vdom), 1);
         });
-        test("fires currentFilter on selection", function() {
+        test("calls currentFilter when selection clicked", function() {
             f = prep(Filter.ObjectType);
-            f.domfrag.click('li a[data-value="arvados#pipelineInstance"]');
+            mq(f.vdom).click('li a[data-value="arvados#pipelineInstance"]');
             s.assert.calledOn(f.cfSpy, f.ctrl);
             s.assert.calledWith(f.cfSpy, 'fakeAttr', 'is_a', 'arvados#pipelineInstance');
         });

commit 791546592e753f2b59f563d2d0ddb82fb48b7b1a
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 03:40:14 2014 -0500

    4831: Tests pass with "make test", "mocha --watch", and browser at /test.html

diff --git a/apps/backstage/Makefile b/apps/backstage/Makefile
index 305f19b..209a8ae 100644
--- a/apps/backstage/Makefile
+++ b/apps/backstage/Makefile
@@ -6,17 +6,18 @@ clean:
 	rm -rvf dist
 
 npmdeps:
-	for x in bootstrap bower browserify chai chai-jquery jquery mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
+	ulimit -n 4000; for x in bootstrap bower browserify chai chai-jquery jquery jsdom mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
 
 # This uses --debug to enable source maps.
-build: build-app build-test build-css
+build: build-app build-test build-assets
 build-app:
 	mkdir -p dist
-	$(NPMBIN)/browserify --debug -o dist/app.js app/app.js
+	ulimit -n 4000; $(NPMBIN)/browserify --debug -o dist/app.js app/app.js
 build-test:
 	mkdir -p dist
-	$(NPMBIN)/browserify --debug -o dist/test.js test/runner.js
-build-css:
+	ulimit -n 4000; $(NPMBIN)/browserify --debug -o dist/test.js test/runner.js
+build-assets: dist/bootstrap
+dist/bootstrap:
 	mkdir -p dist
 	rsync -a $(NPMBIN)/../bootstrap/dist/ dist/bootstrap/
 
@@ -24,5 +25,5 @@ server: build
 	[ -e $(NPMBIN)/harp ] || npm install harp
 	$(NPMBIN)/harp server --port 9000
 
-test: build-test build-css
+test: build-test build-assets
 	$(NPMBIN)/mocha-phantomjs test.html
diff --git a/apps/backstage/test/mithril-dom.js b/apps/backstage/test/mithril-dom.js
index 6b92b49..e1fd8d0 100644
--- a/apps/backstage/test/mithril-dom.js
+++ b/apps/backstage/test/mithril-dom.js
@@ -1,10 +1,33 @@
 var $ = require('jquery')
+, jsdom = require('jsdom')
 , m = require('mithril');
 
 module.exports = md;
 
+var global = (function() { return this })();
+var jsdomWin = global;
+
+md.ready = function(cb) {
+    if (typeof window !== 'undefined' && global === window) {
+        cb($);
+        return;
+    }
+    jsdom.env({
+        html: '<html></html>',
+        scripts: [
+            '../node_modules/jquery/dist/jquery.js',
+            '../node_modules/mithril/mithril.js',
+        ],
+        done: function(err, win) {
+            jsdomWin = win;
+            $ = win.jQuery;
+            cb($);
+        }
+    });
+}
+
 function md(cell) {
     var div = $('<div></div>')[0];
-    m.render(div, cell);
+    (jsdomWin.m || m).render(div, cell);
     return div.children;
 }
diff --git a/apps/backstage/test/runner.js b/apps/backstage/test/runner.js
index 18270c4..ff4a670 100644
--- a/apps/backstage/test/runner.js
+++ b/apps/backstage/test/runner.js
@@ -1,9 +1,13 @@
 var global = (function() { return this })();
 
-global.$ = global.jQuery = require('jquery');
-chaiJquery = require('chai-jquery');
-require('chai').use(chaiJquery);
-mocha.setup({ui: 'tdd'});
+if (global.mocha) {
+    // Running in browser.
+    global.$ = global.jQuery = require('jquery');
+    var cj = require('chai-jquery');
+    var c = require('chai');
+    c.use(cj);
+    global.mocha.setup({ui: 'tdd'});
+}
 
 require('test/unit/filter.js');
 require('test/unit/filterset.js');
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
index d8ffd5e..427866f 100644
--- a/apps/backstage/test/unit/filter.js
+++ b/apps/backstage/test/unit/filter.js
@@ -9,7 +9,13 @@ var Filter = require('app/filter')
 , s = sinon;
 
 suite('Filter', function() {
-    function setup(filterClass, initialFilter) {
+    setup(function(done) {
+        md.ready(function(jQuery) {
+            $ = jQuery;
+            done();
+        });
+    });
+    function prep(filterClass, initialFilter) {
         var f = {};
         f.tested = new filterClass({attr: 'fakeAttr'});
         f.cfSpy = sinon.stub();
@@ -21,13 +27,13 @@ suite('Filter', function() {
     }
     suite('AnyText', function() {
         test("default is existing filter value", function() {
-            f = setup(Filter.AnyText, ['any','ilike','%quux%']);
+            f = prep(Filter.AnyText, ['any','ilike','%quux%']);
             f.rendered = f.tested.view(f.ctrl);
             f.domfrag = mq(f.rendered);
             c.assert.equal(f.domfrag.first('input').attrs.value, "quux");
         });
         test("fires currentFilter on input change", function() {
-            f = setup(Filter.AnyText);
+            f = prep(Filter.AnyText);
             f.domfrag.setValue('input', 'qux');
             // Should call again to set new filter value
             s.assert.calledWith(f.cfSpy, 'any', 'ilike', '%qux%');
@@ -35,20 +41,20 @@ suite('Filter', function() {
     });
     suite('ObjectType', function() {
         test("default is existing filter value", function() {
-            f = setup(Filter.ObjectType, ['fakeAttr','is_a','arvados#collection']);
+            f = prep(Filter.ObjectType, ['fakeAttr','is_a','arvados#collection']);
             f.rendered = f.tested.view(f.ctrl);
             f.md = md(f.rendered);
             c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 0);
             c.assert.lengthOf($('.dropdown-toggle:contains(collection)', f.md), 1);
         });
         test("show generic label if no existing filter value", function() {
-            f = setup(Filter.ObjectType, undefined);
+            f = prep(Filter.ObjectType, undefined);
             f.rendered = f.tested.view(f.ctrl);
             f.md = md(f.rendered);
             c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 1);
         });
         test("fires currentFilter on selection", function() {
-            f = setup(Filter.ObjectType);
+            f = prep(Filter.ObjectType);
             f.domfrag.click('li a[data-value="arvados#pipelineInstance"]');
             s.assert.calledOn(f.cfSpy, f.ctrl);
             s.assert.calledWith(f.cfSpy, 'fakeAttr', 'is_a', 'arvados#pipelineInstance');
diff --git a/apps/backstage/test/webdriver-client.js b/apps/backstage/test/webdriver-client.js
index 752a797..54996dc 100644
--- a/apps/backstage/test/webdriver-client.js
+++ b/apps/backstage/test/webdriver-client.js
@@ -9,5 +9,6 @@ var client = webdriverjs.remote({
     // However, if anything goes wrong, remove this to see more details
     // logLevel: 'silent'
 });
-client.init();
+// client.init();
+
 module.exports = client;

commit 67422ce6e67a59d6c7d100b26f1e377e6eeaa870
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Dec 31 01:56:10 2014 -0500

    4831: Set initial values for filters.

diff --git a/apps/backstage/app/filter.js b/apps/backstage/app/filter.js
index 1da6159..19c95bc 100644
--- a/apps/backstage/app/filter.js
+++ b/apps/backstage/app/filter.js
@@ -9,8 +9,12 @@ function FilterAnyText() {
     this.view = function(ctrl) {
         return m('.input-group.input-group-sm', [
             m('input.form-control[type="text"][placeholder="Search"]',
-              {oninput: m.withAttr('value', setFilter)}),
+              {value: getFilter(),
+               oninput: m.withAttr('value', setFilter)}),
         ]);
+        function getFilter() {
+            return ctrl.currentFilter() ? ctrl.currentFilter()[2].replace(/^%(.*)%$/, '$1') : ''
+        }
         function setFilter(value) {
             ctrl.currentFilter('any', 'ilike', '%'+value+'%')
         }
@@ -29,10 +33,10 @@ function FilterObjectType(opts) {
                     ]),
                     m('ul.dropdown-menu[role="menu"]', [
                         m('li', [
-                            m('a[href="#"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#collection')}, 'Collection'),
+                            m('a[href="#"][data-value="arvados#collection"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#collection')}, 'Collection'),
                         ]),
                         m('li', [
-                            m('a[href="#"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#pipelineInstance')}, 'Pipeline instance'),
+                            m('a[href="#"][data-value="arvados#pipelineInstance"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#pipelineInstance')}, 'Pipeline instance'),
                         ]),
                     ]),
                 ]),
diff --git a/apps/backstage/test/mithril-dom.js b/apps/backstage/test/mithril-dom.js
new file mode 100644
index 0000000..6b92b49
--- /dev/null
+++ b/apps/backstage/test/mithril-dom.js
@@ -0,0 +1,10 @@
+var $ = require('jquery')
+, m = require('mithril');
+
+module.exports = md;
+
+function md(cell) {
+    var div = $('<div></div>')[0];
+    m.render(div, cell);
+    return div.children;
+}
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
index 3756471..d8ffd5e 100644
--- a/apps/backstage/test/unit/filter.js
+++ b/apps/backstage/test/unit/filter.js
@@ -1,40 +1,57 @@
-var mq = require('mithril-query')
-, Filter = require('app/filter')
+var Filter = require('app/filter')
+, chai = require('chai')
+, m = require('mithril')
+, md = require('test/mithril-dom')
+, mq = require('mithril-query')
 , sinon = require('sinon')
+, $ = require('jquery')
+, c = chai
 , s = sinon;
 
 suite('Filter', function() {
-    function setup(filterClass) {
+    function setup(filterClass, initialFilter) {
         var f = {};
         f.tested = new filterClass({attr: 'fakeAttr'});
-        f.cfSpy = sinon.spy();
+        f.cfSpy = sinon.stub();
+        f.cfSpy.withArgs().returns(initialFilter);
         f.ctrl = {currentFilter: f.cfSpy};
         f.rendered = f.tested.view(f.ctrl);
         f.domfrag = mq(f.rendered);
         return f;
     }
     suite('AnyText', function() {
-        test.skip("default is existing filter value", function() {
-            // TODO
+        test("default is existing filter value", function() {
+            f = setup(Filter.AnyText, ['any','ilike','%quux%']);
+            f.rendered = f.tested.view(f.ctrl);
+            f.domfrag = mq(f.rendered);
+            c.assert.equal(f.domfrag.first('input').attrs.value, "quux");
         });
         test("fires currentFilter on input change", function() {
             f = setup(Filter.AnyText);
-            // XXX: should call once to retrieve current filter value
-            // s.assert.calledWith(f.cfSpy);
             f.domfrag.setValue('input', 'qux');
             // Should call again to set new filter value
             s.assert.calledWith(f.cfSpy, 'any', 'ilike', '%qux%');
         });
     });
     suite('ObjectType', function() {
-        test.skip("default is existing filter value", function() {
-            // TODO
+        test("default is existing filter value", function() {
+            f = setup(Filter.ObjectType, ['fakeAttr','is_a','arvados#collection']);
+            f.rendered = f.tested.view(f.ctrl);
+            f.md = md(f.rendered);
+            c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 0);
+            c.assert.lengthOf($('.dropdown-toggle:contains(collection)', f.md), 1);
+        });
+        test("show generic label if no existing filter value", function() {
+            f = setup(Filter.ObjectType, undefined);
+            f.rendered = f.tested.view(f.ctrl);
+            f.md = md(f.rendered);
+            c.assert.lengthOf($('.dropdown-toggle:contains(Type)', f.md), 1);
         });
         test("fires currentFilter on selection", function() {
             f = setup(Filter.ObjectType);
-            f.domfrag.click('li a');
+            f.domfrag.click('li a[data-value="arvados#pipelineInstance"]');
             s.assert.calledOn(f.cfSpy, f.ctrl);
-            s.assert.calledWith(f.cfSpy, 'fakeAttr', 'is_a', 'arvados#collection');
+            s.assert.calledWith(f.cfSpy, 'fakeAttr', 'is_a', 'arvados#pipelineInstance');
         });
     });
 });

commit 3c48f7bf7a14ff938c523a560a0ebc4cd629a090
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Dec 30 23:18:08 2014 -0500

    4831: More testing stuff.

diff --git a/apps/backstage/Makefile b/apps/backstage/Makefile
index 65f375d..305f19b 100644
--- a/apps/backstage/Makefile
+++ b/apps/backstage/Makefile
@@ -1,6 +1,6 @@
 NPMBIN:=$(shell npm bin)
 
-all: npmdeps build-dist
+all: npmdeps build
 
 clean:
 	rm -rvf dist
@@ -9,12 +9,20 @@ npmdeps:
 	for x in bootstrap bower browserify chai chai-jquery jquery mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
 
 # This uses --debug to enable source maps.
-build-dist:
+build: build-app build-test build-css
+build-app:
 	mkdir -p dist
 	$(NPMBIN)/browserify --debug -o dist/app.js app/app.js
+build-test:
+	mkdir -p dist
 	$(NPMBIN)/browserify --debug -o dist/test.js test/runner.js
+build-css:
+	mkdir -p dist
 	rsync -a $(NPMBIN)/../bootstrap/dist/ dist/bootstrap/
 
-server: build-dist
+server: build
 	[ -e $(NPMBIN)/harp ] || npm install harp
 	$(NPMBIN)/harp server --port 9000
+
+test: build-test build-css
+	$(NPMBIN)/mocha-phantomjs test.html
diff --git a/apps/backstage/test/functional/dashboard.js b/apps/backstage/test/functional/dashboard.js
index 95418c5..df95f64 100644
--- a/apps/backstage/test/functional/dashboard.js
+++ b/apps/backstage/test/functional/dashboard.js
@@ -1,11 +1,12 @@
-require(['chai', 'test/webdriver-client'], function(chai, c) {
-    var assert = chai.assert;
-    describe('Dashboard page', function() {
-        before(function() {
-            c.init().url('http://localhost:5555');
-        });
-        it('has a nav', function() {
-            assert(c.isVisible('nav'));
-        });
+var chai = require('chai')
+, wd = require('webdriver-client')
+, c = chai;
+
+suite('Dashboard page', function() {
+    setup(function() {
+        wd.url('http://localhost:5555');
+    });
+    test('has a nav', function() {
+        c.assert(wd.isVisible('nav'));
     });
 });
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
index dc1b6be..3756471 100644
--- a/apps/backstage/test/unit/filter.js
+++ b/apps/backstage/test/unit/filter.js
@@ -4,15 +4,37 @@ var mq = require('mithril-query')
 , s = sinon;
 
 suite('Filter', function() {
-    test("changing input fires currentFilter", function() {
-        var tested = new Filter.AnyText();
-        var cfSpy = sinon.spy();
-        var ctrl = {currentFilter: cfSpy};
-        var v = tested.view(ctrl);
-        s.assert.notCalled(cfSpy);
-        mq(v).setValue('input', 'qux');
-        s.assert.calledOnce(cfSpy);
-        s.assert.calledOn(cfSpy, ctrl);
-        s.assert.calledWith(cfSpy, 'any', 'ilike', '%qux%');
+    function setup(filterClass) {
+        var f = {};
+        f.tested = new filterClass({attr: 'fakeAttr'});
+        f.cfSpy = sinon.spy();
+        f.ctrl = {currentFilter: f.cfSpy};
+        f.rendered = f.tested.view(f.ctrl);
+        f.domfrag = mq(f.rendered);
+        return f;
+    }
+    suite('AnyText', function() {
+        test.skip("default is existing filter value", function() {
+            // TODO
+        });
+        test("fires currentFilter on input change", function() {
+            f = setup(Filter.AnyText);
+            // XXX: should call once to retrieve current filter value
+            // s.assert.calledWith(f.cfSpy);
+            f.domfrag.setValue('input', 'qux');
+            // Should call again to set new filter value
+            s.assert.calledWith(f.cfSpy, 'any', 'ilike', '%qux%');
+        });
+    });
+    suite('ObjectType', function() {
+        test.skip("default is existing filter value", function() {
+            // TODO
+        });
+        test("fires currentFilter on selection", function() {
+            f = setup(Filter.ObjectType);
+            f.domfrag.click('li a');
+            s.assert.calledOn(f.cfSpy, f.ctrl);
+            s.assert.calledWith(f.cfSpy, 'fakeAttr', 'is_a', 'arvados#collection');
+        });
     });
 });
diff --git a/apps/backstage/test/webdriver-client.js b/apps/backstage/test/webdriver-client.js
index dc40522..752a797 100644
--- a/apps/backstage/test/webdriver-client.js
+++ b/apps/backstage/test/webdriver-client.js
@@ -1,13 +1,13 @@
-define(['webdriverjs'], function(webdriverjs) {
-    var client = webdriverjs.remote({
-        desiredCapabilities: {
-            // http://code.google.com/p/selenium/wiki/DesiredCapabilities
-            browserName: 'phantomjs'
-        },
-        // webdriverjs has a lot of output which is generally useless
-        // However, if anything goes wrong, remove this to see more details
-        // logLevel: 'silent'
-    });
-    client.init();
-    return client;
+var webdriverjs = require('webdriverjs');
+
+var client = webdriverjs.remote({
+    desiredCapabilities: {
+        // http://code.google.com/p/selenium/wiki/DesiredCapabilities
+        browserName: 'phantomjs'
+    },
+    // webdriverjs has a lot of output which is generally useless
+    // However, if anything goes wrong, remove this to see more details
+    // logLevel: 'silent'
 });
+client.init();
+module.exports = client;

commit 17114546f7bd6b2d442d0ac384111bb73f1accd8
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Dec 30 18:56:52 2014 -0500

    4831: "Backstage" browser client.

diff --git a/apps/backstage/.gitignore b/apps/backstage/.gitignore
new file mode 100644
index 0000000..a2fa9ac
--- /dev/null
+++ b/apps/backstage/.gitignore
@@ -0,0 +1,5 @@
+dist
+node_modules
+!node_modules/app
+!node_modules/arvados
+!node_modules/test
diff --git a/apps/backstage/Makefile b/apps/backstage/Makefile
new file mode 100644
index 0000000..65f375d
--- /dev/null
+++ b/apps/backstage/Makefile
@@ -0,0 +1,20 @@
+NPMBIN:=$(shell npm bin)
+
+all: npmdeps build-dist
+
+clean:
+	rm -rvf dist
+
+npmdeps:
+	for x in bootstrap bower browserify chai chai-jquery jquery mithril mithril-query mocha mocha-phantomjs sinon; do [ -d "$(NPMBIN)/../$$x" ] || npm install $$x --save-dev; done
+
+# This uses --debug to enable source maps.
+build-dist:
+	mkdir -p dist
+	$(NPMBIN)/browserify --debug -o dist/app.js app/app.js
+	$(NPMBIN)/browserify --debug -o dist/test.js test/runner.js
+	rsync -a $(NPMBIN)/../bootstrap/dist/ dist/bootstrap/
+
+server: build-dist
+	[ -e $(NPMBIN)/harp ] || npm install harp
+	$(NPMBIN)/harp server --port 9000
diff --git a/apps/backstage/README.md b/apps/backstage/README.md
new file mode 100644
index 0000000..d16a038
--- /dev/null
+++ b/apps/backstage/README.md
@@ -0,0 +1,20 @@
+Prerequisites
+=============
+
+    sudo apt-get install nodejs
+
+Install dependencies and build dist files
+=========================================
+
+    make
+
+Update dist files and run a dev server
+======================================
+
+    make server
+
+Update dependencies
+===================
+
+    npm update
+    make
diff --git a/apps/backstage/app/app.js b/apps/backstage/app/app.js
new file mode 100644
index 0000000..fc60fb1
--- /dev/null
+++ b/apps/backstage/app/app.js
@@ -0,0 +1 @@
+require('app/backstage-routes');
diff --git a/apps/backstage/app/backstage-layout.js b/apps/backstage/app/backstage-layout.js
new file mode 100644
index 0000000..5229669
--- /dev/null
+++ b/apps/backstage/app/backstage-layout.js
@@ -0,0 +1,39 @@
+module.exports = BackstageLayoutView;
+
+var m = require('mithril');
+
+function BackstageLayoutView() {
+    return [
+        m('.navbar.navbar-default', {role: 'navigation'}, [
+            m('.container-fluid', [
+                m('.navbar-header', [
+                    m('button.navbar-toggle.collapsed',
+                      {'data-toggle': 'collapse', 'data-target': '#navbar'},
+                      [0,0,0].map(function() {
+                          return m('span.icon-bar');
+                      })),
+                    m("a.navbar-brand[href='/']", {config:m.route},
+                      'Arvados::Backstage'),
+                ]),
+                m('#navbar.navbar-collapse.collapse', [
+                    m('ul.nav.navbar-nav', [
+                        m('li', [
+                            m("a[href='/']", {config:m.route},
+                              'Dashboard'),
+                        ]),
+                    ]),
+                    m('p.navbar-text', [siteBreadcrumb()]),
+                ]),
+            ]),
+        ]),
+        m('.container-fluid', this.views.content()),
+    ];
+    function siteBreadcrumb() {
+        var txt;
+        if (txt = m.route.param('connection'))
+            return txt;
+        if ((txt = m.route.param('uuid')) && txt.substr(5,1)=='-')
+            return txt.substr(0,5);
+        return '';
+    }
+}
diff --git a/apps/backstage/app/backstage-login.js b/apps/backstage/app/backstage-login.js
new file mode 100644
index 0000000..13551d7
--- /dev/null
+++ b/apps/backstage/app/backstage-login.js
@@ -0,0 +1,18 @@
+module.exports = BackstageLoginComponent;
+
+var m = require('mithril');
+
+function BackstageLoginComponent() {
+    var callback = {};
+    callback.controller = function() {
+        var tokens = {};
+        try {
+            tokens = JSON.parse(window.localStorage.tokens);
+        } catch(e) {}
+        tokens[m.route.param('apiPrefix')] = m.route.param('api_token');
+        window.localStorage.tokens = JSON.stringify(tokens);
+        m.route(m.route.param('return_to') || '/');
+    }
+    callback.view = function(ctrl) {}
+    return callback;
+}
diff --git a/apps/backstage/app/backstage-routes.js b/apps/backstage/app/backstage-routes.js
new file mode 100644
index 0000000..0575620
--- /dev/null
+++ b/apps/backstage/app/backstage-routes.js
@@ -0,0 +1,31 @@
+module.exports = true;
+
+var m = require('mithril')
+, ArvadosConnection = require('arvados/client')
+, Layout = require('app/base-layout')
+, BackstageLayoutView = require('app/backstage-layout')
+, BackstageLoginComponent = require('app/backstage-login')
+, ArvApiDirectoryComponent = require('app/component.arv-api-directory')
+, ArvIndexComponent = require('app/component.arv-index')
+, ArvShowComponent = require('app/component.arv-show');
+
+window.jQuery = require('jquery');
+require('bootstrap');
+
+var connections = m.prop('4xphq qr1hi 9tee4 su92l bogus'.split(' ').map(
+    function(site) {
+        return ArvadosConnection.make(site);
+    }));
+
+m.route(document.body, '/', {
+    '/login-callback': new BackstageLoginComponent(),
+    '/': new Layout(BackstageLayoutView, {
+        content: new ArvApiDirectoryComponent(connections)
+    }),
+    '/list/:connection/:modelName': new Layout(BackstageLayoutView, {
+        content: ArvIndexComponent
+    }),
+    '/show/:uuid': new Layout(BackstageLayoutView, {
+        content: ArvShowComponent
+    }),
+});
diff --git a/apps/backstage/app/backstage.css b/apps/backstage/app/backstage.css
new file mode 100644
index 0000000..55a9c0f
--- /dev/null
+++ b/apps/backstage/app/backstage.css
@@ -0,0 +1,3 @@
+.lighten {
+    opacity: 0.5;
+}
diff --git a/apps/backstage/app/base-component.js b/apps/backstage/app/base-component.js
new file mode 100644
index 0000000..9aab6f4
--- /dev/null
+++ b/apps/backstage/app/base-component.js
@@ -0,0 +1,6 @@
+module.exports = BaseComponent;
+
+var BaseController = require('app/base-ctrl');
+
+BaseComponent.prototype.controller = BaseController;
+function BaseComponent() {}
diff --git a/apps/backstage/app/base-ctrl.js b/apps/backstage/app/base-ctrl.js
new file mode 100644
index 0000000..68f9b8f
--- /dev/null
+++ b/apps/backstage/app/base-ctrl.js
@@ -0,0 +1,24 @@
+module.exports = BaseController;
+
+var m = require('mithril');
+
+BaseController.prototype.selectUuid =
+    function selectUuid(uuid) {
+        m.route('/show/' + uuid);
+    }
+BaseController.prototype.onunload =
+    function onunload() {
+        var todo = [];
+        if (this.controllers instanceof Function) {
+            todo = this.controllers();
+        } else if (this.controllers instanceof Array) {
+            todo = this.controllers;
+        }
+        todo.map(function(ctrl) {
+            if (ctrl.onunload instanceof Function)
+                ctrl.onunload();
+        });
+    }
+function BaseController(vm) {
+    this.vm = vm;
+}
diff --git a/apps/backstage/app/base-layout.js b/apps/backstage/app/base-layout.js
new file mode 100644
index 0000000..29adbc0
--- /dev/null
+++ b/apps/backstage/app/base-layout.js
@@ -0,0 +1,37 @@
+// Layout class. Instances are suitable for passing to m.route().
+//
+// Usage:
+// new Layout(viewFunction, {main: FooModuleClass, nav: NavComponent})
+//
+// viewFunction is expected to include this.views.main() and
+// this.views.nav() somewhere in its return value.
+//
+// Content can be given as a class (in which case instances are made
+// with new FooModuleClass()) or as a component instance (i.e., an
+// object with a function named 'controller').
+//
+// The layout is responsible for creating and unloading controllers.
+
+module.exports = Layout;
+
+var BaseComponent = require('app/base-component')
+, BaseController = require('app/base-ctrl');
+
+Layout.prototype = BaseComponent;
+function Layout(layoutView, innerModules) {
+    var layout = this;
+    this.views = {};
+    this.controller = function controller() {
+        this.controllers = [];
+        Object.keys(innerModules).map(function(key) {
+            var module = innerModules[key];
+            var component = (module.controller instanceof Function) ? module : new module();
+            var ctrl = new component.controller();
+            var view = component.view.bind(component.view, ctrl);
+            this.controllers.push(ctrl);
+            layout.views[key] = view;
+        }, this);
+    };
+    this.controller.prototype = new BaseController();
+    this.view = layoutView.bind(this, this.controller);
+}
diff --git a/apps/backstage/app/component.arv-api-directory.js b/apps/backstage/app/component.arv-api-directory.js
new file mode 100644
index 0000000..1370ede
--- /dev/null
+++ b/apps/backstage/app/component.arv-api-directory.js
@@ -0,0 +1,45 @@
+module.exports = ArvApiDirectoryComponent;
+
+var m = require('mithril')
+, BaseComponent = require('app/base-component')
+, BaseController = require('app/base-ctrl')
+, ArvApiStatusComponent = require('app/component.arv-api-status');
+
+ArvApiDirectoryComponent.prototype = new BaseComponent();
+function ArvApiDirectoryComponent(connections) {
+    this.controller = Controller;
+    Controller.prototype = new BaseController();
+    function Controller(vm) {
+        this.vm = vm || {};
+        this.vm.widgets = connections().map(function(conn) {
+            var component = new ArvApiStatusComponent(conn);
+            return {
+                view: component.view,
+                controller: new component.controller(),
+            };
+        });
+        // Give BaseComponent a list of components to unload.
+        this.controllers = function() {
+            return this.vm.widgets.map(function(widget) {
+                return widget.controller;
+            });
+        }.bind(this);
+
+        this.redrawTimer = setInterval(function() {
+            // If redraw is really really cheap, we can do this to make
+            // "#seconds old" timers count in real time.
+            m.redraw();
+        }, 1000);
+        this.onunload = function() {
+            clearTimeout(this.redrawTimer);
+        };
+    };
+    this.view = View;
+    function View(ctrl) {
+        return m('div', [
+            ctrl.vm.widgets.map(function(widget) {
+                return widget.view(widget.controller);
+            })
+        ]);
+    };
+}
diff --git a/apps/backstage/app/component.arv-api-status.js b/apps/backstage/app/component.arv-api-status.js
new file mode 100644
index 0000000..64e8d64
--- /dev/null
+++ b/apps/backstage/app/component.arv-api-status.js
@@ -0,0 +1,133 @@
+module.exports = ArvApiStatusComponent;
+
+var m = require('mithril')
+, util = require('app/util');
+
+function ArvApiStatusComponent(connection) {
+    var apistatus = {};
+    apistatus.vm = (function() {
+        var vm = {};
+        vm.connection = connection;
+        vm.dd = connection.discoveryDoc;
+        vm.dirty = true;
+        vm.init = function() {
+            if (vm.dirty)
+                vm.refresh();
+            vm.dirty = false;
+        };
+        vm.refresh = function() {
+            vm.nodes = vm.connection.api(
+                'Node', 'list', {});
+            vm.keepServices = vm.connection.api(
+                'KeepService', 'list', {});
+        };
+        vm.logout = function() {
+            vm.connection.token(undefined);
+        };
+        vm.ddSummary = function() {
+            return !vm.dd() ? {} : {
+                apiVersion: vm.dd().version + ' (' + vm.dd().revision + ')',
+                sourceVersion: m('a', {
+                    href: 'https://arvados.org/projects/arvados/repository/changes?rev=' + vm.dd().source_version
+                }, vm.dd().source_version),
+                generatedAt: vm.dd().generatedAt,
+                websocket: util.choose(vm.connection.webSocket().readyState, {
+                    0: m('span.label.label-warning', ['connecting']),
+                    1: m('span.label.label-success', ['OK']),
+                    2: m('span.label.label-danger', ['closing']),
+                    3: m('span.label.label-danger', ['closed']),
+                }) || m('span.label.label-danger',
+                        {title: ('advertised websocketUrl: ' +
+                                 vm.dd().websocketUrl)}, ['none'])
+            }
+        };
+        return vm;
+    })();
+    apistatus.controller = function() {
+        apistatus.vm.init();
+    };
+    apistatus.view = function() {
+        var vm = apistatus.vm;
+        var ddSummary = vm.ddSummary();
+        return m('.panel.panel-info.arv-bs-api-status', [
+            m('.panel-heading', [
+                vm.connection.apiPrefix(),
+                !vm.dd() ? '' : m('.pull-right', [
+                    util.choose(!!vm.connection.token(), {
+                        true: [function() {
+                            return m('a.btn.btn-xs.btn-default',
+                                     {onclick: vm.logout}, 'Log out');
+                        }],
+                        false: [function() {
+                            return m('a.btn.btn-xs.btn-primary',
+                                     {href: vm.connection.loginLink()}, 'Log in');
+                        }]
+                    }),
+                ]),
+            ]),
+            m('.panel-body', !vm.dd() ? [vm.connection.state()] : [
+                m('.row', [
+                    m('.col-md-4',
+                      Object.keys(ddSummary).map(function(key) {
+                          return m('.row', [
+                              m('.col-sm-4.lighten', key),
+                              m('.col-sm-8', ddSummary[key]),
+                          ]);
+                      })),
+                    m('.col-md-4', [
+                        !vm.keepServices() ? '' : m('ul', [
+                            '' + vm.keepServices().length + ' Keep services',
+                            vm.keepServices().map(function(keepService) {
+                                return m('li', [
+                                    m('span.label.label-default',
+                                      keepService().service_type),
+                                    ' ',
+                                    m('a',
+                                      {href: '/show/'+keepService().uuid,
+                                       config: m.route}, [
+                                           keepService().service_host,
+                                           ':',
+                                           keepService().service_port,
+                                       ]),
+                                ]);
+                            }),
+                        ]),
+                    ]),
+                    m('.col-md-4', [
+                        !vm.nodes() ? '' : m('ul', [
+                            '' + vm.nodes().length + ' worker nodes',
+                            vm.nodes().filter(function(node) {
+                                return node().crunch_worker_state != 'down';
+                            }).map(function(node) {
+                                return m('li', [
+                                    m('span.label.label-default', [
+                                        node().crunch_worker_state,
+                                    ]),
+                                    ' ',
+                                    m('a', {href: '/show/'+node().uuid,
+                                            config: m.route},
+                                      node().hostname),
+                                    ' ',
+                                    m('span.label.label-info', {title: 'time since last ping'}, [
+                                        ((new Date() - Date.parse(node().last_ping_at))/1000).toFixed(),
+                                        's'
+                                    ]),
+                                ]);
+                            }),
+                        ]),
+                    ]),
+                ]),
+                m('.row', 'Collection Job PipelineInstance'.split(' ').map(function(arvModelName) {
+                    return m('.col-sm-2', [
+                        m('a.btn.btn-xs.btn-default', {
+                            style: 'width: 100%',
+                            href: '/list/'+vm.connection.apiPrefix()+'/'+arvModelName,
+                            config: m.route
+                        }, arvModelName+'s'),
+                    ]);
+                })),
+            ]),
+        ]);
+    };
+    return apistatus;
+}
diff --git a/apps/backstage/app/component.arv-index.js b/apps/backstage/app/component.arv-index.js
new file mode 100644
index 0000000..10a0c93
--- /dev/null
+++ b/apps/backstage/app/component.arv-index.js
@@ -0,0 +1,33 @@
+module.exports = ArvIndexComponent;
+
+var m = require('mithril')
+, BaseController = require('app/base-ctrl')
+, ArvListComponent = require('app/component.arv-list')
+, ArvObjectRowComponent = require('app/component.arv-object-row')
+, InfiniteScroll = require('app/infinitescroll');
+
+function ArvIndexComponent() {
+    this.controller = Controller;
+    this.view = function view(ctrl) {
+        return ctrl.vm.scroller.view(ctrl.vm.scrollerCtrl);
+    };
+    function ViewModel() {
+        this.list =
+            new ArvListComponent(null, null, new ArvObjectRowComponent());
+        this.listCtrl =
+            new this.list.controller();
+        this.scroller =
+            new InfiniteScroll(this.listCtrl, this.list.view,
+                               {pxThreshold: 200});
+        this.scrollerCtrl =
+            new this.scroller.controller();
+    }
+    function Controller() {
+        this.vm = new ViewModel();
+    }
+    Controller.prototype = new BaseController();
+    Controller.prototype.controllers =
+        function controllers() {
+            return [this.vm.listCtrl, this.vm.scrollerCtrl];
+        }
+}
diff --git a/apps/backstage/app/component.arv-list.js b/apps/backstage/app/component.arv-list.js
new file mode 100644
index 0000000..c859c12
--- /dev/null
+++ b/apps/backstage/app/component.arv-list.js
@@ -0,0 +1,128 @@
+module.exports = ArvListComponent;
+
+var m = require('mithril')
+, ArvadosClient = require('arvados/client')
+, BaseController = require('app/base-ctrl')
+, Filter = require('app/filter')
+, FilterSet = require('app/filterset')
+, util = require('app/util');
+
+function ArvListComponent(connection, arvModelName, contentModule) {
+    this.controller = Controller;
+    this.view = View;
+
+    Controller.prototype = new BaseController();
+    function Controller() {
+        this.vm = new ViewModel();
+        this.vm.init();
+        this.vm.filterSetCtrl = new this.vm.filterSet.controller(this);
+    }
+    Controller.prototype.getMoreItems =
+        function getMoreItems() {
+            return this.vm.getMoreItems.apply(this.vm, arguments);
+        }
+    Controller.prototype.currentFilter =
+        function currentFilter(key, attr, operator, operand) {
+            if (arguments.length > 1) {
+                this.vm.filters[key] = [attr, operator, operand];
+                util.debounce(500, this.vm.resetContent).
+                    then(this.vm.resetContent);
+            }
+            return this.vm.filters[key];
+        }
+
+    function ViewModel() {
+        var vm = this;
+        vm.init = function() {
+            vm.arvModelName = arvModelName || m.route.param('modelName');
+            vm.connection = connection || ArvadosClient.make(m.route.param('connection'));
+            vm.filters = {};
+            vm.filterSet = new FilterSet(
+                [['any', Filter.AnyText],
+                 ['type', Filter.ObjectType, {attr:'uuid'}]]);
+            vm.inflight = null;
+            vm.listLimit = 30;
+            vm.listOrders = ['created_at desc'];
+            vm.resetContent();
+        };
+        vm.resetContent = function() {
+            if (vm.inflight) {
+                // Forget about current/stale request. TODO: abort the xhr.
+                vm.inflight.reject();
+                vm.inflight = null;
+            }
+            vm.eof = m.prop(false);
+            vm.items = m.prop([]);
+            vm.itemViews = m.prop([]);
+            vm.beforeRender = function() {
+                // On first render, trigger a scroll event to make the
+                // first page of content appear. The scroll handler can
+                // ignore this if (for example) the content view is
+                // invisible now.
+                vm.beforeRender = function() {};
+                window.setTimeout(function() {
+                    window.dispatchEvent(new Event('scroll'));
+                }, 1);
+            };
+        };
+        vm.apiFilters = function() {
+            var filters = [];
+            Object.keys(vm.filters).map(function(key) {
+                if (vm.filters[key])
+                    filters.push(vm.filters[key]);
+            });
+            return filters;
+        };
+        vm.makeItemViews = function() {
+            vm.itemViews(vm.items().map(function(item) {
+                return contentModule.view.bind(
+                    contentModule.view,
+                    new contentModule.controller({item:item}));
+            }));
+        };
+        vm.getMoreItems = function() {
+            var inflight;
+            if (vm.inflight || vm.eof())
+                return false;
+            inflight = m.deferred();
+            vm.connection.api(vm.arvModelName, 'list', {
+                filters: vm.apiFilters(),
+                limit: vm.listLimit,
+                offset: vm.items().length,
+                order: vm.listOrders
+            }).then(function(newItems) {
+                if (inflight !== vm.inflight) {
+                    // This request has already been superseded by a
+                    // new one. Ignore.
+                    return;
+                }
+                vm.eof(newItems.length === 0);
+                vm.items(vm.items().concat(newItems));
+            }, vm.eof).then(vm.makeItemViews).then(function() {
+                // Give the new items a chance to render before
+                // resolving the promise. This makes it possible for
+                // the resolve callback to measure the DOM after the
+                // new elements have been added (notably, in order to
+                // keep fetching pages until the scroll threshold is
+                // satisfied).
+                window.setTimeout(inflight.resolve, 50);
+                vm.inflight = null;
+            });
+            return (vm.inflight = inflight).promise;
+        };
+        return vm;
+    }
+
+    function View(ctrl) {
+        ctrl.vm.beforeRender();
+        return [
+            ctrl.vm.filterSet ? ctrl.vm.filterSet.view(ctrl.vm.filterSetCtrl) : '',
+            ctrl.vm.itemViews().map(function(v) {
+                return v();
+            }),
+            ctrl.vm.eof() ? '' : m('.row', {style: 'background: #ffffdd'}, [
+                m('.col-sm-12', {style: 'text-align: center'}, ['...loading...'])
+            ]),
+        ];
+    }
+}
diff --git a/apps/backstage/app/component.arv-object-row.js b/apps/backstage/app/component.arv-object-row.js
new file mode 100644
index 0000000..9222408
--- /dev/null
+++ b/apps/backstage/app/component.arv-object-row.js
@@ -0,0 +1,35 @@
+// Render an arvados object as a <div class="row">.
+//
+// Usage:
+// x = m.prop({}); // fill in [later] using ArvConnection.find, etc.
+// mod = new ArvObjectRowComponent();
+// ctrl = new mod.controller({item: x});
+// mod.view(ctrl)
+module.exports = ArvObjectRowComponent;
+
+var m = require('mithril')
+, BaseComponent = require('app/base-component');
+
+ArvObjectRowComponent.prototype = new BaseComponent();
+function ArvObjectRowComponent() {
+    this.view = function(ctrl) {
+        var _item = ctrl.vm.item();
+        var _owner = _item.owner_uuid ? _item._conn.find(_item.owner_uuid)() : '';
+        return m('.row', [
+            m('.col-sm-3', [
+                m('.btn.btn-xs',
+                  {onclick: ctrl.selectUuid.bind(ctrl, _item.uuid)}, [
+                      m('span.glyphicon.glyphicon-link'),
+                  ]),
+                _item.uuid,
+            ]),
+            m('.col-sm-4', _item.name),
+            m('.col-sm-2', [
+                m('a[href="/show/'+_item.owner_uuid+'"]', {config:m.route}, [
+                    _owner && (_owner.full_name || _owner.name)
+                ]),
+            ]),
+            m('.col-sm-2', new Date(_item.created_at).toLocaleString()),
+        ]);
+    };
+}
diff --git a/apps/backstage/app/component.arv-show.js b/apps/backstage/app/component.arv-show.js
new file mode 100644
index 0000000..c7ca0ed
--- /dev/null
+++ b/apps/backstage/app/component.arv-show.js
@@ -0,0 +1,27 @@
+module.exports = ArvShowComponent;
+
+var ArvadosConnection = require('arvados/client')
+, m = require('mithril');
+
+function ArvShowComponent() {
+    this.controller = function() {
+        this.vm = (function() {
+            var vm = {};
+            vm.uuid = m.route.param('uuid');
+            vm.connection = ArvadosConnection.make(vm.uuid.slice(0,5));
+            vm.model = vm.connection.find(vm.uuid);
+            return vm;
+        })();
+    };
+    this.view = function(ctrl) {
+        return [
+            m('.row', [m('.col-sm-12', ctrl.vm.uuid)]),
+            Object.keys(ctrl.vm.model() || {}).map(function(key) {
+                return m('.row', [
+                    m('.col-sm-2.lighten', key),
+                    m('.col-sm-10', ctrl.vm.model()[key]),
+                ]);
+            }),
+        ];
+    };
+}
diff --git a/apps/backstage/app/filter.js b/apps/backstage/app/filter.js
new file mode 100644
index 0000000..1da6159
--- /dev/null
+++ b/apps/backstage/app/filter.js
@@ -0,0 +1,42 @@
+module.exports = {
+    AnyText: FilterAnyText,
+    ObjectType: FilterObjectType,
+};
+
+var m = require('mithril');
+
+function FilterAnyText() {
+    this.view = function(ctrl) {
+        return m('.input-group.input-group-sm', [
+            m('input.form-control[type="text"][placeholder="Search"]',
+              {oninput: m.withAttr('value', setFilter)}),
+        ]);
+        function setFilter(value) {
+            ctrl.currentFilter('any', 'ilike', '%'+value+'%')
+        }
+    };
+}
+
+function FilterObjectType(opts) {
+    this.view = function(ctrl) {
+        return [
+            m('.input-group.input-group-sm', [
+                m('.input-group-btn', [
+                    m('button.btn.btn-default.dropdown-toggle[type="button"][data-toggle="dropdown"]', [
+                        ctrl.currentFilter() ? ctrl.currentFilter()[2].replace(/^.*#/,'') : 'Type',
+                        ' ',
+                        m('span.caret'),
+                    ]),
+                    m('ul.dropdown-menu[role="menu"]', [
+                        m('li', [
+                            m('a[href="#"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#collection')}, 'Collection'),
+                        ]),
+                        m('li', [
+                            m('a[href="#"]', {onclick: ctrl.currentFilter.bind(ctrl, opts.attr, 'is_a', 'arvados#pipelineInstance')}, 'Pipeline instance'),
+                        ]),
+                    ]),
+                ]),
+            ]),
+        ];
+    };
+}
diff --git a/apps/backstage/app/filterset.js b/apps/backstage/app/filterset.js
new file mode 100644
index 0000000..c1bc5e6
--- /dev/null
+++ b/apps/backstage/app/filterset.js
@@ -0,0 +1,26 @@
+module.exports = FilterSet;
+
+var m = require('mithril');
+
+function FilterSet(viewModules) {
+    var filterSet = {};
+    filterSet.vm = {};
+    filterSet.controller = function(callerCtrl) {
+        var ctrl = this;
+        ctrl.vm = filterSet.vm;
+        ctrl.vm.views = viewModules.map(function(modInfo) {
+            var view = (new modInfo[1](modInfo[2])).view;
+            var boundGettersetter = callerCtrl.currentFilter.bind(
+                callerCtrl, modInfo[0]);
+            return view.bind(view, {currentFilter: boundGettersetter});
+        });
+    };
+    filterSet.view = function(ctrl) {
+        return m('form.form-inline', [
+            ctrl.vm.views.map(function(view) {
+                return [view(), ' '];
+            }),
+        ]);
+    };
+    return filterSet;
+}
diff --git a/apps/backstage/app/infinitescroll.js b/apps/backstage/app/infinitescroll.js
new file mode 100644
index 0000000..2a7dc6a
--- /dev/null
+++ b/apps/backstage/app/infinitescroll.js
@@ -0,0 +1,71 @@
+// Call the content module's getMoreItems() action whenever the bottom
+// edge of the content view is [within pxThreshold of being] visible.
+//
+// If getMoreItems() returns a promise, bottom edge visibility will be
+// tested again when that promise is resolved. This should be used
+// whenever getMoreItems() adds any new items, to cover the case where
+// the bottom of the content view is still visible after a new page of
+// items is rendered.
+//
+// It is the responsibility of getMoreItems() to ignore subsequent
+// calls while it's busy retrieving or preparing additional content.
+module.exports = InfiniteScroll;
+
+var m = require('mithril')
+, jQuery = require('jquery');
+
+function InfiniteScroll(contentCtrl, contentView, opts) {
+    var scroller = {};
+    opts = opts || {};
+    scroller.controller = function() {
+        this.contentCtrl = contentCtrl;
+        this.getMoreItems = this.contentCtrl.getMoreItems.bind(this.contentCtrl);
+        this.pxThreshold = opts.pxThreshold || 0;
+        this.onunload = onunload.bind(this);
+        function onunload () {
+            var i=0;
+            InfiniteScroll.controllers().map(function(ctrl) {
+                if (ctrl === this) {
+                    InfiniteScroll.elements().splice(i, 1);
+                    InfiniteScroll.controllers().splice(i, 1);
+                } else {
+                    i++;
+                }
+            }.bind(this));
+        };
+    };
+    scroller.view = function(ctrl) {
+        return m('.container', {config: function(el, isInit, ctx) {
+            return scroller.configEl(el, isInit, ctx, ctrl);
+        }}, [
+            contentView(ctrl.contentCtrl)
+        ]);
+    };
+    scroller.configEl = function(el, isInit, ctx, ctrl) {
+        if (isInit) return;
+        if (InfiniteScroll.elements().indexOf(el) < 0) {
+            InfiniteScroll.elements().push(el);
+            InfiniteScroll.controllers().push(ctrl);
+        }
+    };
+    return scroller;
+}
+InfiniteScroll.elements = m.prop([]);
+InfiniteScroll.controllers = m.prop([]);
+
+(function() {
+    function scrollHandler(event) {
+        InfiniteScroll.elements().map(function(el, i) {
+            var ctrl = InfiniteScroll.controllers()[i];
+            var pxBeforeEnd =
+                el.getBoundingClientRect().bottom -
+                document.documentElement.clientHeight;
+            var promised;
+            if (pxBeforeEnd > ctrl.pxThreshold)
+                return;
+            if ((promised = ctrl.getMoreItems()) && promised.then)
+                promised.then(scrollHandler);
+        });
+    }
+    jQuery(window).on('DOMContentLoaded load resize scroll', scrollHandler);
+})();
diff --git a/apps/backstage/app/util.js b/apps/backstage/app/util.js
new file mode 100644
index 0000000..f8b4d8e
--- /dev/null
+++ b/apps/backstage/app/util.js
@@ -0,0 +1,50 @@
+module.exports = {
+    choose: choose,
+    debounce: debounce,
+};
+
+// util.choose('a', {a: 'A', b: 'B'}) --> return 'A'
+// util.choose('a', {a: [console.log, 'foo']}) --> return console.log('foo')
+function choose(key, options) {
+    var option = options[key];
+    if (option instanceof Array && option[0] instanceof Function)
+        return option[0].apply(this, option.slice(1));
+    else
+        return option;
+}
+
+// util.debounce(250, key) --> Return a promise. If someone else
+// calls debounce with the same key, reject the promise. If nobody
+// else has done so after 250ms, resolve the promise.
+function debounce(ms, key) {
+    var newpending;
+    util.debounce.pending = util.debounce.pending || [];
+    util.debounce.pending.map(function(found) {
+        if (!newpending && found.key === key) {
+            // Promise already pending with this key. Reject the old
+            // one, reuse its slot for the new one.
+            window.clearTimeout(found.timer);
+            found.deferred.reject();
+            m.endComputation();
+            newpending = found;
+        }
+    });
+    if (!newpending) {
+        // No pending promise with this key.
+        newpending = {key: key}
+        util.debounce.pending.push(newpending);
+    }
+    newpending.deferred = m.deferred();
+    m.startComputation();
+    newpending.timer = window.setTimeout(function() {
+        // Success, no more bouncing. Remove from pending list.
+        util.debounce.pending.map(function(found, i) {
+            if (found === newpending) {
+                util.debounce.pending.splice(i, 1);
+                found.deferred.resolve();
+                m.endComputation();
+            }
+        });
+    }, ms);
+    return newpending.deferred.promise;
+}
diff --git a/apps/backstage/bower.json b/apps/backstage/bower.json
new file mode 100644
index 0000000..9e5913c
--- /dev/null
+++ b/apps/backstage/bower.json
@@ -0,0 +1,24 @@
+{
+  "name": "backstage",
+  "version": "0.0.0",
+  "license": "AGPLv3",
+  "private": true,
+  "ignore": [
+    "**/.*",
+    "node_modules",
+    "bower_components",
+    "test",
+    "tests"
+  ],
+  "dependencies": {
+    "bootstrap": "~3.3.1",
+    "mithril": "~0.1.27"
+  },
+  "devDependencies": {
+    "requirejs": "~2.1.15",
+    "chai": "~1.10.0",
+    "mocha": "~2.1.0",
+    "chai-jquery": "~2.0.0",
+    "sinon": "~1.12.2"
+  }
+}
diff --git a/apps/backstage/index.html b/apps/backstage/index.html
new file mode 100644
index 0000000..691d347
--- /dev/null
+++ b/apps/backstage/index.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>arvados backstage</title>
+    <link href="dist/bootstrap/css/bootstrap.min.css" rel="stylesheet">
+    <link href="dist/bootstrap/css/bootstrap-theme.min.css" rel="stylesheet">
+    <link href="app/backstage.css" rel="stylesheet">
+  </head>
+  <body>
+    <script src="dist/app.js"></script>
+  </body>
+</html>
diff --git a/apps/backstage/node_modules/app b/apps/backstage/node_modules/app
new file mode 120000
index 0000000..5df94d9
--- /dev/null
+++ b/apps/backstage/node_modules/app
@@ -0,0 +1 @@
+../app
\ No newline at end of file
diff --git a/apps/backstage/node_modules/arvados/client.js b/apps/backstage/node_modules/arvados/client.js
new file mode 100644
index 0000000..380384d
--- /dev/null
+++ b/apps/backstage/node_modules/arvados/client.js
@@ -0,0 +1,298 @@
+// Data service backed by Arvados.
+//
+// c = new ArvadosConnection('xyzzy'); // connect to xyzzy.arvadosapi.com
+// c.token('asdfasdf'); // set token
+// c.state(); // 'loading'
+// c.ready.then(function() { c.state() }); // 'ready'
+//
+// // This part needs a better API:
+// nodelist = m.prop();
+// c.ready.then(function() { c.Node.list().then(nodelist) });
+//
+// // Better?
+// nodelist = m.deferred();
+// nodelist = c.api('nodes.list', {filters: []}, nodelist);
+// nodelist(); // undefined
+// nodelist.then(function() { nodelist(); }); // [{uuid:...},...]
+
+module.exports = ArvadosConnection;
+
+var m = require('mithril');
+
+ArvadosConnection.connections = {};
+ArvadosConnection.make = Make;
+
+function Make(connectionId, apiPrefix) {
+    var conns = ArvadosConnection.connections;
+    apiPrefix = apiPrefix || connectionId;
+    if (!conns[connectionId]) {
+        conns[connectionId] = new ArvadosConnection(apiPrefix);
+    }
+    return conns[connectionId];
+}
+
+function ArvadosConnection(apiPrefix) {
+    var connection = this;
+    var dd = m.prop();
+    connection.apiPrefix = m.prop(apiPrefix);
+    connection.discoveryDoc = dd;
+    connection.state = m.prop('loading');
+    connection.api = api;
+    connection.find = find;
+    connection.loginLink = loginLink;
+    connection.token = token;
+    connection.webSocket = m.prop({});
+
+    // Initialize
+
+    connection.ready = m.request({
+        background: true,
+        method: 'GET',
+        url: 'https://' + apiPrefix + '.arvadosapi.com/discovery/v1/apis/arvados/v1/rest'
+    });
+    connection.ready.
+        then(connection.discoveryDoc).
+        then(setupModelClasses).
+        then(setupWebSocket).
+        then(m.redraw,
+             function(err){connection.state('error: '+err); m.redraw();});
+
+    // Public methods
+
+    // URL that will initiate the login process, pass the new token to
+    // localStorage via login-callback, then return to the current
+    // route.
+    function loginLink() {
+        if (!dd()) return null;
+        return dd().rootUrl + 'login?return_to=' + encodeURIComponent(
+            (location.href.replace(/\?.*/,'') + '?/login-callback?apiPrefix=' + apiPrefix +
+             '&return_to=' + encodeURIComponent(m.route())));
+    }
+
+    // getter-setter, backed by localStorage. Currently supports only
+    // one connection per apiPrefix.
+    function token(newToken) {
+        var tokens;
+        try {
+            tokens = JSON.parse(window.localStorage.tokens);
+        } catch(e) {
+            tokens = {};
+        }
+        if (arguments.length === 0) {
+            return tokens[apiPrefix];
+        } else {
+            tokens[apiPrefix] = newToken;
+            window.localStorage.tokens = JSON.stringify(tokens);
+            return newToken;
+        }
+    }
+
+    // Wait for discovery doc if necessary, then perform API call and
+    // resolve the returned promise.
+    //
+    // modelClass: 'Collection', 'Node', etc.
+    // action: 'get', 'list', 'update', etc.
+    // params: {uuid:'foo',filters:[],...}
+    // deferred (optional): deferred object for response. If not
+    // supplied, a new one is created.
+    function api(modelClass, action, params, deferred) {
+        deferred = deferred || m.deferred();
+        connection.ready.then(function() {
+            connection[modelClass][action](params).
+                then(updateStore).
+                then(deferred.resolve, deferred.reject).
+                then(m.redraw);
+        }, deferred.reject);
+        return deferred.promise;
+    }
+
+    // Private instance variables
+
+    var store = {};
+    var uuidInfixClassName = {};
+
+    // Private methods
+
+    function ModelClass(resourceName) {
+        var resourceClass = function() {
+            var model = this;
+        };
+        resourceClass.resourceName = resourceName;
+        resourceClass.addAction = function(action, method) {
+            resourceClass[action] = function(params) {
+                var path, postdata = {};
+                params = params || {};
+                Object.keys(params).map(function(key) {
+                    if (params[key] instanceof Object)
+                        postdata[key] = JSON.stringify(params[key]);
+                    else
+                        postdata[key] = params[key];
+                });
+                path = method.path.replace(/{(.*?)}/, function(_, key) {
+                    var val = postdata[key];
+                    delete postdata[key];
+                    return encodeURIComponent(val);
+                });
+                path = dd().rootUrl + dd().servicePath + path;
+                return request({
+                    method: method.httpMethod,
+                    url: path,
+                    data: postdata,
+                });
+            };
+        };
+        resourceClass.find = function(uuid, refreshFlag) {
+            if (!refreshFlag && store[uuid]) return store[uuid];
+            else return api(resourceName, 'get', {uuid:uuid});
+        };
+        return resourceClass;
+    }
+
+    function find(uuid, refreshFlag) {
+        refreshFlag = refreshFlag || !store[uuid];
+        connection.ready.then(function() {
+            var infix = uuid.slice(6,11);
+            var className = uuidInfixClassName[infix];
+            var theClass = connection[className];
+            if (!theClass) {
+                throw new Error("No class for "+className+" for infix "+infix);
+            }
+            theClass.find(uuid, refreshFlag);
+        });
+        store[uuid] = store[uuid] || m.prop();
+        return store[uuid];
+    }
+
+    function request(args) {
+        args.config = function(xhr) {
+            xhr.setRequestHeader('Authorization', 'OAuth2 '+connection.token());
+        };
+        return m.request(args);
+    }
+
+    // Update local cache with data just received in API response.
+    function updateStore(response) {
+        var items;
+        if (response.items) {
+            // Return an array of getters, with extra properties
+            // (items_available, etc.) tacked on to the array.
+            items = response.items.map(updateStore);
+            Object.keys(response).map(function(key) {
+                if (key !== 'items') {
+                    items[key] = response[key];
+                }
+            });
+            return items;
+        } else if (response.uuid) {
+            store[response.uuid] = store[response.uuid] || m.prop();
+            store[response.uuid](response);
+            store[response.uuid]()._cacheTime = new Date();
+            store[response.uuid]()._conn = connection;
+            return store[response.uuid];
+        } else {
+            return response;
+        }
+    }
+
+    function setupModelClasses(x) {
+        var schemas = connection.discoveryDoc().schemas;
+        Object.keys(schemas).map(function(modelClassName) {
+            if (modelClassName.search(/List$/) > -1) return;
+            var modelClass = new ModelClass(modelClassName);
+            modelClass.schema = schemas[modelClassName];
+            connection[modelClassName] = modelClass;
+            uuidInfixClassName[schemas[modelClassName].uuidPrefix] = modelClassName;
+        });
+        var resources = connection.discoveryDoc().resources;
+        Object.keys(resources).map(function(ctrl) {
+            var modelClassName;
+            var methods = resources[ctrl].methods;
+            try {
+                modelClassName = resources[ctrl].methods.get.response.$ref;
+            } catch(e) {
+                console.log("Hm, could not handle resource '"+ctrl+"'");
+                return;
+            }
+            if (!connection[modelClassName]) {
+                console.log("Hm, no schema for response type '"+ctrl+"'");
+                return;
+            }
+            Object.keys(methods).map(function(action) {
+                connection[modelClassName].addAction(action, methods[action]);
+            });
+        });
+        connection.state('ready');
+    }
+
+    function setupWebSocket() {
+        var ws;
+        if (!connection.token()) {
+            // No sense trying to connect without a valid token.
+            return connection.webSocket({});
+        }
+        ws = new WebSocket(
+            dd().websocketUrl + '?api_token=' + connection.token());
+        ws.startedAt = new Date();
+        ws.sendJson = function(object) {
+            ws.send(JSON.stringify(object));
+        };
+        ws.onopen = function(event) {
+            // TODO: subscribe to logs about uuids in
+            // connection.store, not everything.
+            ws.sendJson({method:'subscribe'});
+        };
+        ws.onreadystatechange = m.redraw.bind(m, false);
+        ws.onmessage = function(event) {
+            var message = JSON.parse(event.data);
+            var newAttrs;
+            var objectProp;
+            if (typeof message.object_uuid === 'string' &&
+                message.event_type === 'update' &&
+                message.object_uuid.slice(0,5) === apiPrefix &&
+                (objectProp = store[message.object_uuid])) {
+                newAttrs = message.properties.new_attributes;
+                if (objectProp()) {
+                    // A local copy exists. Update whatever attributes
+                    // we see in the message.
+                    Object.keys(newAttrs).map(function(key) {
+                        objectProp()[key] = newAttrs[key];
+                    });
+                } else {
+                    // We have a getter-setter ready for this object,
+                    // but it has no content yet. TODO: make the
+                    // server send a full API response, not just the
+                    // database columns, with these update messages.
+                    objectProp(newAttrs);
+                }
+                objectProp()._cacheTime = new Date();
+                m.redraw();
+            }
+        };
+        ws.onclose = function(event) {
+            if (new Date() - ws.startedAt < 60000) {
+                // If the last connection lasted less than 60 seconds,
+                // there's probably something wrong -- it's not just
+                // the expected occasional server reset or network
+                // interruption -- so we should make sure to use a
+                // pessimistic retry delay of at least 60 seconds, and
+                // use ever-increasing delays until the connection
+                // starts staying alive for more than a minute at a
+                // time.
+                setupWebSocket.backoff = Math.min(
+                    (setupWebSocket.backoff || 30), 30) * 2 + 1;
+            }
+            else {
+                // The last connection lasted more than a
+                // minute. Let's assume this is just a brief
+                // interruption and things are going well most of the
+                // time: delay 5 seconds, then try again.
+                setupWebSocket.backoff = 5;
+            }
+            console.log("Websocket closed at " + new Date() +
+                        " with code=" + event.code +
+                        ", retry in "+setupWebSocket.backoff+"s");
+            window.setTimeout(setupWebSocket, setupWebSocket.backoff*1000);
+        };
+        return connection.webSocket(ws);
+    }
+}
diff --git a/apps/backstage/node_modules/test b/apps/backstage/node_modules/test
new file mode 120000
index 0000000..419df4f
--- /dev/null
+++ b/apps/backstage/node_modules/test
@@ -0,0 +1 @@
+../test
\ No newline at end of file
diff --git a/apps/backstage/test.html b/apps/backstage/test.html
new file mode 100644
index 0000000..253d8a4
--- /dev/null
+++ b/apps/backstage/test.html
@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>arvados backstage tests</title>
+    <link href="node_modules/mocha/mocha.css" rel="stylesheet">
+  </head>
+  <body>
+    <div id="mocha"></div>
+    <script src="node_modules/mocha/mocha.js"></script>
+    <script src="dist/test.js"></script>
+    <script>
+        var global = (function() { return this })();
+        if (global.mochaPhantomJS) {
+            global.mochaPhantomJS.run();
+        }
+        else {
+            mocha.run();
+        }
+    </script>
+  </body>
+</html>
diff --git a/apps/backstage/test/functional/dashboard.js b/apps/backstage/test/functional/dashboard.js
new file mode 100644
index 0000000..95418c5
--- /dev/null
+++ b/apps/backstage/test/functional/dashboard.js
@@ -0,0 +1,11 @@
+require(['chai', 'test/webdriver-client'], function(chai, c) {
+    var assert = chai.assert;
+    describe('Dashboard page', function() {
+        before(function() {
+            c.init().url('http://localhost:5555');
+        });
+        it('has a nav', function() {
+            assert(c.isVisible('nav'));
+        });
+    });
+});
diff --git a/apps/backstage/test/runner.js b/apps/backstage/test/runner.js
new file mode 100644
index 0000000..18270c4
--- /dev/null
+++ b/apps/backstage/test/runner.js
@@ -0,0 +1,9 @@
+var global = (function() { return this })();
+
+global.$ = global.jQuery = require('jquery');
+chaiJquery = require('chai-jquery');
+require('chai').use(chaiJquery);
+mocha.setup({ui: 'tdd'});
+
+require('test/unit/filter.js');
+require('test/unit/filterset.js');
diff --git a/apps/backstage/test/unit/filter.js b/apps/backstage/test/unit/filter.js
new file mode 100644
index 0000000..dc1b6be
--- /dev/null
+++ b/apps/backstage/test/unit/filter.js
@@ -0,0 +1,18 @@
+var mq = require('mithril-query')
+, Filter = require('app/filter')
+, sinon = require('sinon')
+, s = sinon;
+
+suite('Filter', function() {
+    test("changing input fires currentFilter", function() {
+        var tested = new Filter.AnyText();
+        var cfSpy = sinon.spy();
+        var ctrl = {currentFilter: cfSpy};
+        var v = tested.view(ctrl);
+        s.assert.notCalled(cfSpy);
+        mq(v).setValue('input', 'qux');
+        s.assert.calledOnce(cfSpy);
+        s.assert.calledOn(cfSpy, ctrl);
+        s.assert.calledWith(cfSpy, 'any', 'ilike', '%qux%');
+    });
+});
diff --git a/apps/backstage/test/unit/filterset.js b/apps/backstage/test/unit/filterset.js
new file mode 100644
index 0000000..c90a082
--- /dev/null
+++ b/apps/backstage/test/unit/filterset.js
@@ -0,0 +1,39 @@
+var FilterSet = require('app/filterset')
+, chai = require('chai')
+, mq = require('mithril-query')
+, sinon = require('sinon')
+, s = sinon
+, c = chai;
+
+suite('FilterSet', function() {
+    test("viewModules' views bind parentCtrl's currentFilter", function() {
+        FilterStub = function() {};
+        FilterStub.prototype.view = function() {};
+        var stubFilterName = 'foo';
+        var fakeFilterValue = 'bar';
+        var childViewSpy = sinon.spy(FilterStub.prototype, 'view');
+        var stubParentCtrl = {currentFilter: sinon.spy()};
+
+        // Instantiate a component, and its controller.
+        var tested = new FilterSet([[stubFilterName, FilterStub]]);
+        var testedCtrl = new tested.controller(stubParentCtrl);
+
+        // The FilterSet's view should invoke the child's view,
+        // and pass a controller to it.
+        s.assert.notCalled(childViewSpy);
+        var viewOut = tested.view(testedCtrl);
+        s.assert.calledOnce(childViewSpy);
+        c.assert.lengthOf(childViewSpy.getCall(0).args, 1);
+
+        // The controller passed to the child view should have a
+        // currentFilter method which invokes
+        // stubParentCtrl.currentFilter() with the filter name
+        // prepended to its argument list.
+        var childViewCtrl = childViewSpy.getCall(0).args[0];
+        s.assert.notCalled(stubParentCtrl.currentFilter);
+        childViewCtrl.currentFilter(fakeFilterValue);
+        s.assert.calledOnce(stubParentCtrl.currentFilter);
+        s.assert.calledOn(stubParentCtrl.currentFilter, stubParentCtrl);
+        s.assert.calledWith(stubParentCtrl.currentFilter, stubFilterName, fakeFilterValue);
+    });
+});
diff --git a/apps/backstage/test/webdriver-client.js b/apps/backstage/test/webdriver-client.js
new file mode 100644
index 0000000..dc40522
--- /dev/null
+++ b/apps/backstage/test/webdriver-client.js
@@ -0,0 +1,13 @@
+define(['webdriverjs'], function(webdriverjs) {
+    var client = webdriverjs.remote({
+        desiredCapabilities: {
+            // http://code.google.com/p/selenium/wiki/DesiredCapabilities
+            browserName: 'phantomjs'
+        },
+        // webdriverjs has a lot of output which is generally useless
+        // However, if anything goes wrong, remove this to see more details
+        // logLevel: 'silent'
+    });
+    client.init();
+    return client;
+});

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list