[ARVADOS] created: 888e08c4bd321745607ca35cd71fa6c53ece0405

Git user git at public.curoverse.com
Fri Aug 11 15:47:25 EDT 2017


        at  888e08c4bd321745607ca35cd71fa6c53ece0405 (commit)


commit 888e08c4bd321745607ca35cd71fa6c53ece0405
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 15:30:30 2017 -0400

    12033: Add top nav link to multisite search.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/views/layouts/body.html.erb b/apps/workbench/app/views/layouts/body.html.erb
index 3315027..a2256a0 100644
--- a/apps/workbench/app/views/layouts/body.html.erb
+++ b/apps/workbench/app/views/layouts/body.html.erb
@@ -29,6 +29,11 @@ SPDX-License-Identifier: AGPL-3.0 %>
           <% if current_user %>
             <% if current_user.is_active %>
             <li>
+              <%= link_to(controller: 'collections', action: 'multisite') do %>
+                Multisite search (beta)
+              <% end %>
+            </li>
+            <li>
               <form class="navbar-form" role="search"
                          data-search-modal=
                          "<%= url_for(

commit 14edbf1a2327edf9e797928f6095153f22b486be
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 11:43:49 2017 -0400

    12033: Fix double slash in href.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 6167b2c..9e1db75 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -46,7 +46,7 @@ window.components.collection_table = {
             m('tbody', [
                 vnode.attrs.loader.displayable.map(function(item) {
                     return m('tr', [
-                        m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, 'Show')),
+                        m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'collections/'+item.uuid}, 'Show')),
                         m('td.arvados-uuid', item.uuid),
                         m('td', item.name || '(unnamed)'),
                         m('td', m(window.components.datetime, {parse: item.modified_at})),

commit a20ea2d9b6b861829d9daa91990ce064b1000170
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 11:40:08 2017 -0400

    12033: Log out and back in to a site without forgetting it.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index a06e2bb..6167b2c 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -119,7 +119,9 @@ window.components.collection_search = {
                         ? m('span.label.label-xs.label-danger', 'none')
                         : Object.keys(sessions).sort().map(function(key) {
                             return [m('span.label.label-xs', {
-                                className: vnode.state.loader.pagers[key].items() ? 'label-info' : 'label-default',
+                                className: !vnode.state.loader.pagers[key] ? 'label-default' :
+                                    vnode.state.loader.pagers[key].items() ? 'label-success' :
+                                    'label-warning',
                             }, key), ' ']
                         }),
                     ' ',
diff --git a/apps/workbench/app/assets/javascripts/components/sessions.js b/apps/workbench/app/assets/javascripts/components/sessions.js
index 3ff9f4d..19a481d 100644
--- a/apps/workbench/app/assets/javascripts/components/sessions.js
+++ b/apps/workbench/app/assets/javascripts/components/sessions.js
@@ -18,36 +18,46 @@ window.components.sessions = {
         var db = vnode.state.db
         var sessions = db.loadAll()
         return m('container', [
-            m('table.table.table-condensed.table-hover', m('tbody', [
-                Object.keys(sessions).map(function(uuidPrefix) {
-                    var session = sessions[uuidPrefix]
-                    return m('tr', [
-                        session.token && session.user ? [
-                            m('td', session.isFromRails ? null : m('a.btn.btn-xs.btn-default', {
+            m('table.table.table-condensed.table-hover', [
+                m('thead', m('tr', [
+                    m('th', 'status'),
+                    m('th', 'cluster ID'),
+                    m('th', 'username'),
+                    m('th', 'email'),
+                    m('th', 'actions'),
+                    m('th'),
+                ])),
+                m('tbody', [
+                    Object.keys(sessions).map(function(uuidPrefix) {
+                        var session = sessions[uuidPrefix]
+                        return m('tr', [
+                            session.token && session.user ? [
+                                m('td', m('span.label.label-success', 'logged in')),
+                                m('td', {title: session.baseURL}, uuidPrefix),
+                                m('td', session.user.username),
+                                m('td', session.user.email),
+                                m('td', session.isFromRails ? null : m('button.btn.btn-xs.btn-default', {
+                                    uuidPrefix: uuidPrefix,
+                                    onclick: m.withAttr('uuidPrefix', db.logout),
+                                }, 'Log out ', m('span.glyphicon.glyphicon-log-out'))),
+                            ] : [
+                                m('td', m('span.label.label-default', 'logged out')),
+                                m('td', {title: session.baseURL}, uuidPrefix),
+                                m('td'),
+                                m('td'),
+                                m('td', m('a.btn.btn-xs.btn-primary', {
+                                    uuidPrefix: uuidPrefix,
+                                    onclick: db.login.bind(db, session.baseURL),
+                                }, 'Log in ', m('span.glyphicon.glyphicon-log-in'))),
+                            ],
+                            m('td', session.isFromRails ? null : m('button.btn.btn-xs.btn-default', {
                                 uuidPrefix: uuidPrefix,
-                                onclick: m.withAttr('uuidPrefix', db.logout),
-                            }, 'log out')),
-                            m('td', m('span.label.label-info', 'logged in')),
-                            m('td', {title: session.baseURL}, uuidPrefix),
-                            m('td', session.user.username),
-                            m('td', session.user.email),
-                        ] : [
-                            m('td', m('a.btn.btn-xs.btn-info', {
-                                uuidPrefix: uuidPrefix,
-                                onclick: m.withAttr('uuidPrefix', db.login),
-                            }, 'log in')),
-                            m('td', 'span.label.label-default', 'logged out'),
-                            m('td', {title: session.baseURL}, uuidPrefix),
-                            m('td'),
-                            m('td'),
-                        ],
-                        m('td', session.isFromRails ? null : m('a.glyphicon.glyphicon-trash', {
-                            uuidPrefix: uuidPrefix,
-                            onclick: m.withAttr('uuidPrefix', db.trash),
-                        })),
-                    ])
-                }),
-            ])),
+                                onclick: m.withAttr('uuidPrefix', db.trash),
+                            }, 'Remove ', m('span.glyphicon.glyphicon-trash'))),
+                        ])
+                    }),
+                ]),
+            ]),
             m('.row', m('.col-md-6', [
                 m('form', {
                     onsubmit: function() {
diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js
index 0a3181b..17b2ad5 100644
--- a/apps/workbench/app/assets/javascripts/models/loader.js
+++ b/apps/workbench/app/assets/javascripts/models/loader.js
@@ -65,7 +65,7 @@ window.models.MultisiteLoader = function(config) {
         // displayed).
         lowWaterMark: 23,
     })
-    var sessions = loader.sessionDB.loadAll()
+    var sessions = loader.sessionDB.loadActive()
     m.stream.merge(Object.keys(sessions).map(function(key) {
         var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
         loader.pagers[key] = pager
diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
index 13d3eaf..b64481e 100644
--- a/apps/workbench/app/assets/javascripts/models/session_db.js
+++ b/apps/workbench/app/assets/javascripts/models/session_db.js
@@ -20,6 +20,14 @@ window.models.SessionDB = function() {
             }
             return all
         },
+        loadActive: function() {
+            var sessions = db.loadAll()
+            Object.keys(sessions).forEach(function(key) {
+                if (!sessions[key].token)
+                    delete sessions[key]
+            })
+            return sessions
+        },
         save: function(k, v) {
             var sessions = db.loadAll()
             sessions[k] = v
@@ -50,6 +58,14 @@ window.models.SessionDB = function() {
             document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
             return false
         },
+        logout: function(k) {
+            // Forget the token, but leave the other info in the db so
+            // the user can log in again without providing the login
+            // host again.
+            var sessions = db.loadAll()
+            delete sessions[k].token
+            db.save(k, sessions[k])
+        },
         checkForNewToken: function() {
             // If there's a token and baseURL in the location bar (i.e.,
             // we just landed here after a successful login), save it and

commit c97e090a3c26363f750903edb86e422c43b95233
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 10:05:19 2017 -0400

    12033: Fix logging in from location with non-empty query string.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
index fde02f6..13d3eaf 100644
--- a/apps/workbench/app/assets/javascripts/models/session_db.js
+++ b/apps/workbench/app/assets/javascripts/models/session_db.js
@@ -47,7 +47,7 @@ window.models.SessionDB = function() {
                 baseURL = 'https://' + baseURL
             if (!baseURL.endsWith('/'))
                 baseURL = baseURL + '/'
-            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href+'?baseURL='+encodeURIComponent(baseURL))
+            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
             return false
         },
         checkForNewToken: function() {

commit 6e1eda9ff559bb61df8880c8f53aca008aa95c9c
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 10:03:30 2017 -0400

    12033: Ensure current Rails login is always in sessions list.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/sessions.js b/apps/workbench/app/assets/javascripts/components/sessions.js
index be4b7e3..3ff9f4d 100644
--- a/apps/workbench/app/assets/javascripts/components/sessions.js
+++ b/apps/workbench/app/assets/javascripts/components/sessions.js
@@ -23,7 +23,7 @@ window.components.sessions = {
                     var session = sessions[uuidPrefix]
                     return m('tr', [
                         session.token && session.user ? [
-                            m('td', m('a.btn.btn-xs.btn-default', {
+                            m('td', session.isFromRails ? null : m('a.btn.btn-xs.btn-default', {
                                 uuidPrefix: uuidPrefix,
                                 onclick: m.withAttr('uuidPrefix', db.logout),
                             }, 'log out')),
@@ -41,7 +41,7 @@ window.components.sessions = {
                             m('td'),
                             m('td'),
                         ],
-                        m('td', m('a.glyphicon.glyphicon-trash', {
+                        m('td', session.isFromRails ? null : m('a.glyphicon.glyphicon-trash', {
                             uuidPrefix: uuidPrefix,
                             onclick: m.withAttr('uuidPrefix', db.trash),
                         })),
diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
index 75fe6f9..fde02f6 100644
--- a/apps/workbench/app/assets/javascripts/models/session_db.js
+++ b/apps/workbench/app/assets/javascripts/models/session_db.js
@@ -6,15 +6,27 @@ window.models = window.models || {}
 window.models.SessionDB = function() {
     var db = this
     Object.assign(db, {
-        loadAll: function() {
+        loadFromLocalStorage: function() {
             try {
                 return JSON.parse(window.localStorage.getItem('sessions')) || {}
             } catch(e) {}
             return {}
         },
+        loadAll: function() {
+            var all = db.loadFromLocalStorage()
+            if (window.defaultSession) {
+                window.defaultSession.isFromRails = true
+                all[window.defaultSession.user.uuid.slice(0, 5)] = window.defaultSession
+            }
+            return all
+        },
         save: function(k, v) {
             var sessions = db.loadAll()
             sessions[k] = v
+            Object.keys(sessions).forEach(function(key) {
+                if (sessions[key].isFromRails)
+                    delete sessions[key]
+            })
             window.localStorage.setItem('sessions', JSON.stringify(sessions))
         },
         trash: function(k) {
diff --git a/apps/workbench/app/controllers/sessions_controller.rb b/apps/workbench/app/controllers/sessions_controller.rb
index f72b451..48fbc6b 100644
--- a/apps/workbench/app/controllers/sessions_controller.rb
+++ b/apps/workbench/app/controllers/sessions_controller.rb
@@ -3,9 +3,9 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 class SessionsController < ApplicationController
-  skip_around_filter :require_thread_api_token, :only => [:destroy, :index]
-  skip_around_filter :set_thread_api_token, :only => [:destroy, :index]
-  skip_before_filter :find_object_by_uuid, :only => [:destroy, :index]
+  skip_around_filter :require_thread_api_token, :only => [:destroy, :logged_out]
+  skip_around_filter :set_thread_api_token, :only => [:destroy, :logged_out]
+  skip_before_filter :find_object_by_uuid
   skip_before_filter :find_objects_for_index
   skip_before_filter :ensure_arvados_api_exists
 
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb
index 71b1cd1..b59bad4 100644
--- a/apps/workbench/app/views/layouts/application.html.erb
+++ b/apps/workbench/app/views/layouts/application.html.erb
@@ -18,6 +18,11 @@ SPDX-License-Identifier: AGPL-3.0 %>
   <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
   <meta name="description" content="">
   <meta name="author" content="">
+  <% if current_user %>
+    <% content_for :js do %>
+      window.defaultSession = <%=raw({baseURL: Rails.configuration.arvados_v1_base.sub(/\/arvados\/v1$/, '/'), token: Thread.current[:arvados_api_token], user: current_user}.to_json)%>
+    <% end %>
+  <% end %>
   <% if current_user and $arvados_api_client.discovery[:websocketUrl] %>
   <meta name="arv-websocket-url" content="<%=$arvados_api_client.discovery[:websocketUrl]%>?api_token=<%=Thread.current[:arvados_api_token]%>">
   <% end %>

commit 16231ef0f56005b487cde9a7fb7dfb62952205a6
Author: Tom Clegg <tom at curoverse.com>
Date:   Fri Aug 11 01:23:07 2017 -0400

    12033: Fix auto scroll after search; acknowledge end of results.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 19f2fd2..a06e2bb 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -4,30 +4,36 @@
 
 window.components = window.components || {}
 window.components.collection_table = {
-    oncreate: function(vnode) {
-        vnode.state.autoload = function() {
-            if (!vnode.attrs.loader.loadMore)
-                // Can't load more content anyway: no point in
-                // checking anything else.
-                return
-            var contentRect = vnode.dom.getBoundingClientRect()
-            var scroller = window // TODO: use vnode.dom's nearest ancestor with scrollbars
-            if (contentRect.bottom < 2 * scroller.innerHeight) {
-                // We have less than 1 page worth of content available
-                // below the visible area. Load more.
-                vnode.attrs.loader.loadMore()
-                // Indicate loading is in progress.
-                window.requestAnimationFrame(m.redraw)
-            }
+    maybeLoadMore: function(dom) {
+        var loader = this.loader
+        if (loader.done || !loader.loadMore)
+            // Can't load more content anyway: no point in
+            // checking anything else.
+            return
+        var contentRect = dom.getBoundingClientRect()
+        var scroller = window // TODO: use dom's nearest ancestor with scrollbars
+        if (contentRect.bottom < 2 * scroller.innerHeight) {
+            // We have less than 1 page worth of content available
+            // below the visible area. Load more.
+            loader.loadMore()
+            // Indicate loading is in progress.
+            window.requestAnimationFrame(m.redraw)
         }
-        window.addEventListener('scroll', vnode.state.autoload)
-        window.addEventListener('resize', vnode.state.autoload)
-        vnode.state.autoloadTimer = window.setInterval(vnode.state.autoload, 200)
+    },
+    oncreate: function(vnode) {
+        vnode.state.maybeLoadMore = vnode.state.maybeLoadMore.bind(vnode.state, vnode.dom)
+        window.addEventListener('scroll', vnode.state.maybeLoadMore)
+        window.addEventListener('resize', vnode.state.maybeLoadMore)
+        vnode.state.timer = window.setInterval(vnode.state.maybeLoadMore, 200)
+        vnode.state.onupdate(vnode)
+    },
+    onupdate: function(vnode) {
+        vnode.state.loader = vnode.attrs.loader
     },
     onremove: function(vnode) {
-        window.clearInterval(vnode.state.autoloadTimer)
-        window.removeEventListener('scroll', vnode.state.autoload)
-        window.removeEventListener('resize', vnode.state.autoload)
+        window.clearInterval(vnode.state.timer)
+        window.removeEventListener('scroll', vnode.state.maybeLoadMore)
+        window.removeEventListener('resize', vnode.state.maybeLoadMore)
     },
     view: function(vnode) {
         return m('table.table.table-condensed', [
@@ -48,7 +54,7 @@ window.components.collection_table = {
                 }),
             ]),
             m('tfoot', m('tr', [
-                m('th[colspan=4]', m('button.btn.btn-xs', {
+                vnode.attrs.loader.done ? null : m('th[colspan=4]', m('button.btn.btn-xs', {
                     className: vnode.attrs.loader.loadMore ? 'btn-primary' : 'btn-default',
                     style: {
                         display: 'block',
diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js
index 1dc7079..0a3181b 100644
--- a/apps/workbench/app/assets/javascripts/models/loader.js
+++ b/apps/workbench/app/assets/javascripts/models/loader.js
@@ -10,7 +10,7 @@ window.models.Pager = function(loadFunc) {
         done: false,
         items: m.stream(),
         thresholdItem: null,
-        loadNextPage: function() {
+        loadMore: function() {
             // Get the next page, if there are any more items to get.
             if (pager.done)
                 return
@@ -53,6 +53,7 @@ window.models.MultisiteLoader = function(config) {
     Object.assign(loader, config, {
         // Sorted items ready to display, merged from all pagers.
         displayable: [],
+        done: false,
         pagers: {},
         loadMore: false,
         // Number of undisplayed items to keep on hand for each result
@@ -68,7 +69,7 @@ window.models.MultisiteLoader = function(config) {
     m.stream.merge(Object.keys(sessions).map(function(key) {
         var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
         loader.pagers[key] = pager
-        pager.loadNextPage()
+        pager.loadMore()
         // Resolve the stream with the session key when the results
         // arrive.
         return pager.items.map(function() { return key })
@@ -112,14 +113,15 @@ window.models.MultisiteLoader = function(config) {
             if (!loader.pagers[key].done)
                 loadable.push(loader.pagers[key])
         })
-        if (loadable.length == 0)
+        if (loadable.length == 0) {
+            loader.done = true
             loader.loadMore = false
-        else
+        } else
             loader.loadMore = function() {
                 loader.loadMore = false
                 loadable.map(function(pager) {
                     if (pager.items().length - pager.itemsDisplayed < loader.lowWaterMark)
-                        pager.loadNextPage()
+                        pager.loadMore()
                 })
             }
     })

commit ea9b417bc727f859878f17898571ec9f997e4f11
Author: Tom Clegg <tom at curoverse.com>
Date:   Thu Aug 10 23:35:20 2017 -0400

    12033: Load more results automatically on scroll.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 4f995af..19f2fd2 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -4,6 +4,31 @@
 
 window.components = window.components || {}
 window.components.collection_table = {
+    oncreate: function(vnode) {
+        vnode.state.autoload = function() {
+            if (!vnode.attrs.loader.loadMore)
+                // Can't load more content anyway: no point in
+                // checking anything else.
+                return
+            var contentRect = vnode.dom.getBoundingClientRect()
+            var scroller = window // TODO: use vnode.dom's nearest ancestor with scrollbars
+            if (contentRect.bottom < 2 * scroller.innerHeight) {
+                // We have less than 1 page worth of content available
+                // below the visible area. Load more.
+                vnode.attrs.loader.loadMore()
+                // Indicate loading is in progress.
+                window.requestAnimationFrame(m.redraw)
+            }
+        }
+        window.addEventListener('scroll', vnode.state.autoload)
+        window.addEventListener('resize', vnode.state.autoload)
+        vnode.state.autoloadTimer = window.setInterval(vnode.state.autoload, 200)
+    },
+    onremove: function(vnode) {
+        window.clearInterval(vnode.state.autoloadTimer)
+        window.removeEventListener('scroll', vnode.state.autoload)
+        window.removeEventListener('resize', vnode.state.autoload)
+    },
     view: function(vnode) {
         return m('table.table.table-condensed', [
             m('thead', m('tr', [

commit 9ff99b73aa200fa21bfcad1ae8a7098b593864bb
Author: Tom Clegg <tom at curoverse.com>
Date:   Thu Aug 10 15:01:13 2017 -0400

    12033: Style uuid column.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 527ce6c..4f995af 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -16,7 +16,7 @@ window.components.collection_table = {
                 vnode.attrs.loader.displayable.map(function(item) {
                     return m('tr', [
                         m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, 'Show')),
-                        m('td', item.uuid),
+                        m('td.arvados-uuid', item.uuid),
                         m('td', item.name || '(unnamed)'),
                         m('td', m(window.components.datetime, {parse: item.modified_at})),
                     ])

commit 2220f7f54be3445457a82c71e1e60d6492ede3a3
Author: Tom Clegg <tom at curoverse.com>
Date:   Thu Aug 10 14:12:33 2017 -0400

    12033: Extract multisite loader to its own class.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 9334bcc..527ce6c 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 window.components = window.components || {}
-window.components.collection_table_narrow = {
+window.components.collection_table = {
     view: function(vnode) {
         return m('table.table.table-condensed', [
             m('thead', m('tr', [
@@ -13,7 +13,7 @@ window.components.collection_table_narrow = {
                 m('th', 'last modified'),
             ])),
             m('tbody', [
-                vnode.attrs.results.displayable.map(function(item) {
+                vnode.attrs.loader.displayable.map(function(item) {
                     return m('tr', [
                         m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, 'Show')),
                         m('td', item.uuid),
@@ -24,149 +24,42 @@ window.components.collection_table_narrow = {
             ]),
             m('tfoot', m('tr', [
                 m('th[colspan=4]', m('button.btn.btn-xs', {
-                    className: vnode.attrs.results.loadMore ? 'btn-primary' : 'btn-default',
+                    className: vnode.attrs.loader.loadMore ? 'btn-primary' : 'btn-default',
                     style: {
                         display: 'block',
                         width: '12em',
                         marginLeft: 'auto',
                         marginRight: 'auto',
                     },
-                    disabled: !vnode.attrs.results.loadMore,
+                    disabled: !vnode.attrs.loader.loadMore,
                     onclick: function() {
-                        vnode.attrs.results.loadMore()
+                        vnode.attrs.loader.loadMore()
                         return false
                     },
-                }, vnode.attrs.results.loadMore ? 'Load more' : '(loading)')),
+                }, vnode.attrs.loader.loadMore ? 'Load more' : '(loading)')),
             ])),
         ])
     },
 }
 
-function Pager(loadFunc) {
-    // loadFunc(filters) returns a promise for a page of results.
-    var pager = this
-    Object.assign(pager, {
-        done: false,
-        items: m.stream(),
-        thresholdItem: null,
-        loadNextPage: function() {
-            // Get the next page, if there are any more items to get.
-            if (pager.done)
-                return
-            var filters = pager.thresholdItem ? [
-                ["modified_at", "<=", pager.thresholdItem.modified_at],
-                ["uuid", "!=", pager.thresholdItem.uuid],
-            ] : []
-            loadFunc(filters).then(function(resp) {
-                var items = pager.items() || []
-                Array.prototype.push.apply(items, resp.items)
-                if (resp.items.length == 0)
-                    pager.done = true
-                else
-                    pager.thresholdItem = resp.items[resp.items.length-1]
-                pager.items(items)
-            })
-        },
-    })
-}
-
 window.components.collection_search = {
     oninit: function(vnode) {
         vnode.state.sessionDB = new window.models.SessionDB()
         vnode.state.searchEntered = m.stream('')
         vnode.state.searchStart = m.stream('')
         vnode.state.searchStart.map(function(q) {
-            var sessions = vnode.state.sessionDB.loadAll()
-            var cookie = (new Date()).getTime()
-            // Each time searchStart() is called we replace the
-            // vnode.state.results stream with a new one, and use
-            // the local variable to update results in callbacks. This
-            // avoids crosstalk between AJAX calls from consecutive
-            // searches.
-            var results = {
-                // Sorted items ready to display, merged from all
-                // pagers.
-                displayable: [],
-                pagers: {},
-                loadMore: false,
-                // Number of undisplayed items to keep on hand for
-                // each result set. When hitting "load more", if a
-                // result set already has this many additional results
-                // available, we don't bother fetching a new
-                // page. This is the _minimum_ number of rows that
-                // will be added to results.displayable in each "load
-                // more" event (except for the case where all items
-                // are displayed).
-                lowWaterMark: 23,
-            }
-            vnode.state.results = results
-            m.stream.merge(Object.keys(sessions).map(function(key) {
-                var pager = new Pager(function(filters) {
+            vnode.state.loader = new window.models.MultisiteLoader({
+                loadFunc: function(session, filters) {
                     if (q)
                         filters.push(['any', '@@', q+':*'])
-                    return vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
+                    return vnode.state.sessionDB.request(session, 'arvados/v1/collections', {
                         data: {
                             filters: JSON.stringify(filters),
                             count: 'none',
                         },
                     })
-                })
-                results.pagers[key] = pager
-                pager.loadNextPage()
-                // Resolve the stream with the session key when the
-                // results arrive.
-                return pager.items.map(function() { return key })
-            })).map(function(keys) {
-                // Top (most recent) of {bottom (oldest) entry of any
-                // pager that still has more pages left to fetch}
-                var cutoff
-                keys.forEach(function(key) {
-                    var pager = results.pagers[key]
-                    var items = pager.items()
-                    if (items.length == 0 || pager.done)
-                        return
-                    var last = items[items.length-1].modified_at
-                    if (!cutoff || cutoff < last)
-                        cutoff = last
-                })
-                var combined = []
-                keys.forEach(function(key) {
-                    var pager = results.pagers[key]
-                    pager.itemsDisplayed = 0
-                    pager.items().every(function(item) {
-                        if (cutoff && item.modified_at < cutoff)
-                            // Some other pagers haven't caught up to
-                            // this point, so don't display this item
-                            // or anything after it.
-                            return false
-                        item.session = sessions[key]
-                        combined.push(item)
-                        pager.itemsDisplayed++
-                        return true // continue
-                    })
-                })
-                results.displayable = combined.sort(function(a, b) {
-                    return a.modified_at < b.modified_at ? 1 : -1
-                })
-                // Make a new loadMore function that hits the pagers
-                // (if necessary according to lowWaterMark)... or set
-                // results.loadMore to false if there is nothing left
-                // to fetch.
-                var loadable = []
-                Object.keys(results.pagers).map(function(key) {
-                    if (!results.pagers[key].done)
-                        loadable.push(results.pagers[key])
-                })
-                if (loadable.length == 0)
-                    results.loadMore = false
-                else
-                    results.loadMore = function() {
-                        results.loadMore = false
-                        loadable.map(function(pager) {
-                            if (pager.items().length - pager.itemsDisplayed < results.lowWaterMark)
-                                pager.loadNextPage()
-                        })
-                    }
+                },
+                sessionDB: vnode.state.sessionDB,
             })
         })
     },
@@ -195,15 +88,15 @@ window.components.collection_search = {
                         ? m('span.label.label-xs.label-danger', 'none')
                         : Object.keys(sessions).sort().map(function(key) {
                             return [m('span.label.label-xs', {
-                                className: vnode.state.results.pagers[key].items() ? 'label-info' : 'label-default',
+                                className: vnode.state.loader.pagers[key].items() ? 'label-info' : 'label-default',
                             }, key), ' ']
                         }),
                     ' ',
                     m('a[href="/sessions"]', 'Add/remove sites'),
                 ]),
             ]),
-            m(window.components.collection_table_narrow, {
-                results: vnode.state.results,
+            m(window.components.collection_table, {
+                loader: vnode.state.loader,
             }),
         ])
     },
diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js
new file mode 100644
index 0000000..1dc7079
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/models/loader.js
@@ -0,0 +1,126 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.models = window.models || {}
+window.models.Pager = function(loadFunc) {
+    // loadFunc(filters) must return a promise for a page of results.
+    var pager = this
+    Object.assign(pager, {
+        done: false,
+        items: m.stream(),
+        thresholdItem: null,
+        loadNextPage: function() {
+            // Get the next page, if there are any more items to get.
+            if (pager.done)
+                return
+            var filters = pager.thresholdItem ? [
+                ["modified_at", "<=", pager.thresholdItem.modified_at],
+                ["uuid", "!=", pager.thresholdItem.uuid],
+            ] : []
+            loadFunc(filters).then(function(resp) {
+                var items = pager.items() || []
+                Array.prototype.push.apply(items, resp.items)
+                if (resp.items.length == 0)
+                    pager.done = true
+                else
+                    pager.thresholdItem = resp.items[resp.items.length-1]
+                pager.items(items)
+            })
+        },
+    })
+}
+
+// MultisiteLoader loads pages of results from multiple API sessions
+// and merges them into a single result set.
+//
+// The constructor implicitly starts an initial page load for each
+// session.
+//
+// new MultisiteLoader({loadFunc: function(session, filters){...},
+// sessionDB: new window.models.SessionDB()}
+//
+// At any given time, ml.loadMore will be either false (meaning a page
+// load is in progress or there are no more results to fetch) or a
+// function that starts loading more results.
+//
+// loadFunc() must retrieve results in "modified_at desc" order.
+window.models = window.models || {}
+window.models.MultisiteLoader = function(config) {
+    var loader = this
+    if (!(config.loadFunc && config.sessionDB))
+        throw new Error("MultisiteLoader constructor requires loadFunc and sessionDB")
+    Object.assign(loader, config, {
+        // Sorted items ready to display, merged from all pagers.
+        displayable: [],
+        pagers: {},
+        loadMore: false,
+        // Number of undisplayed items to keep on hand for each result
+        // set. When hitting "load more", if a result set already has
+        // this many additional results available, we don't bother
+        // fetching a new page. This is the _minimum_ number of rows
+        // that will be added to loader.displayable in each "load
+        // more" event (except for the case where all items are
+        // displayed).
+        lowWaterMark: 23,
+    })
+    var sessions = loader.sessionDB.loadAll()
+    m.stream.merge(Object.keys(sessions).map(function(key) {
+        var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
+        loader.pagers[key] = pager
+        pager.loadNextPage()
+        // Resolve the stream with the session key when the results
+        // arrive.
+        return pager.items.map(function() { return key })
+    })).map(function(keys) {
+        // Top (most recent) of {bottom (oldest) entry of any pager
+        // that still has more pages left to fetch}
+        var cutoff
+        keys.forEach(function(key) {
+            var pager = loader.pagers[key]
+            var items = pager.items()
+            if (items.length == 0 || pager.done)
+                return
+            var last = items[items.length-1].modified_at
+            if (!cutoff || cutoff < last)
+                cutoff = last
+        })
+        var combined = []
+        keys.forEach(function(key) {
+            var pager = loader.pagers[key]
+            pager.itemsDisplayed = 0
+            pager.items().every(function(item) {
+                if (cutoff && item.modified_at < cutoff)
+                    // Some other pagers haven't caught up to this
+                    // point, so don't display this item or anything
+                    // after it.
+                    return false
+                item.session = sessions[key]
+                combined.push(item)
+                pager.itemsDisplayed++
+                return true // continue
+            })
+        })
+        loader.displayable = combined.sort(function(a, b) {
+            return a.modified_at < b.modified_at ? 1 : -1
+        })
+        // Make a new loadMore function that hits the pagers (if
+        // necessary according to lowWaterMark)... or set
+        // loader.loadMore to false if there is nothing left to fetch.
+        var loadable = []
+        Object.keys(loader.pagers).map(function(key) {
+            if (!loader.pagers[key].done)
+                loadable.push(loader.pagers[key])
+        })
+        if (loadable.length == 0)
+            loader.loadMore = false
+        else
+            loader.loadMore = function() {
+                loader.loadMore = false
+                loadable.map(function(pager) {
+                    if (pager.items().length - pager.itemsDisplayed < loader.lowWaterMark)
+                        pager.loadNextPage()
+                })
+            }
+    })
+}

commit 2014757448a9ce52f2aa4f6af4ce2284c6858bb5
Author: Tom Clegg <tom at curoverse.com>
Date:   Thu Aug 10 13:48:31 2017 -0400

    12033: Add "load more" button.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 0c821e9..9334bcc 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -13,7 +13,7 @@ window.components.collection_table_narrow = {
                 m('th', 'last modified'),
             ])),
             m('tbody', [
-                vnode.attrs.items.map(function(item) {
+                vnode.attrs.results.displayable.map(function(item) {
                     return m('tr', [
                         m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, 'Show')),
                         m('td', item.uuid),
@@ -22,6 +22,22 @@ window.components.collection_table_narrow = {
                     ])
                 }),
             ]),
+            m('tfoot', m('tr', [
+                m('th[colspan=4]', m('button.btn.btn-xs', {
+                    className: vnode.attrs.results.loadMore ? 'btn-primary' : 'btn-default',
+                    style: {
+                        display: 'block',
+                        width: '12em',
+                        marginLeft: 'auto',
+                        marginRight: 'auto',
+                    },
+                    disabled: !vnode.attrs.results.loadMore,
+                    onclick: function() {
+                        vnode.attrs.results.loadMore()
+                        return false
+                    },
+                }, vnode.attrs.results.loadMore ? 'Load more' : '(loading)')),
+            ])),
         ])
     },
 }
@@ -32,19 +48,22 @@ function Pager(loadFunc) {
     Object.assign(pager, {
         done: false,
         items: m.stream(),
-        lastModifiedAt: null,
+        thresholdItem: null,
         loadNextPage: function() {
             // Get the next page, if there are any more items to get.
             if (pager.done)
                 return
-            var filters = pager.lastModifiedAt ? [["modified_at", "<=", pager.lastModifiedAt]] : []
+            var filters = pager.thresholdItem ? [
+                ["modified_at", "<=", pager.thresholdItem.modified_at],
+                ["uuid", "!=", pager.thresholdItem.uuid],
+            ] : []
             loadFunc(filters).then(function(resp) {
                 var items = pager.items() || []
                 Array.prototype.push.apply(items, resp.items)
                 if (resp.items.length == 0)
                     pager.done = true
                 else
-                    pager.lastModifiedAt = resp.items[resp.items.length-1].modified_at
+                    pager.thresholdItem = resp.items[resp.items.length-1]
                 pager.items(items)
             })
         },
@@ -56,15 +75,31 @@ window.components.collection_search = {
         vnode.state.sessionDB = new window.models.SessionDB()
         vnode.state.searchEntered = m.stream('')
         vnode.state.searchStart = m.stream('')
-        // items ready to display
-        vnode.state.displayItems = m.stream([])
-        // {sessionKey -> Pager}
-        vnode.state.pagers = {}
         vnode.state.searchStart.map(function(q) {
             var sessions = vnode.state.sessionDB.loadAll()
             var cookie = (new Date()).getTime()
-            var displayItems = m.stream([])
-            vnode.state.displayItems = displayItems
+            // Each time searchStart() is called we replace the
+            // vnode.state.results stream with a new one, and use
+            // the local variable to update results in callbacks. This
+            // avoids crosstalk between AJAX calls from consecutive
+            // searches.
+            var results = {
+                // Sorted items ready to display, merged from all
+                // pagers.
+                displayable: [],
+                pagers: {},
+                loadMore: false,
+                // Number of undisplayed items to keep on hand for
+                // each result set. When hitting "load more", if a
+                // result set already has this many additional results
+                // available, we don't bother fetching a new
+                // page. This is the _minimum_ number of rows that
+                // will be added to results.displayable in each "load
+                // more" event (except for the case where all items
+                // are displayed).
+                lowWaterMark: 23,
+            }
+            vnode.state.results = results
             m.stream.merge(Object.keys(sessions).map(function(key) {
                 var pager = new Pager(function(filters) {
                     if (q)
@@ -76,20 +111,62 @@ window.components.collection_search = {
                         },
                     })
                 })
-                vnode.state.pagers[key] = pager
+                results.pagers[key] = pager
                 pager.loadNextPage()
+                // Resolve the stream with the session key when the
+                // results arrive.
                 return pager.items.map(function() { return key })
             })).map(function(keys) {
+                // Top (most recent) of {bottom (oldest) entry of any
+                // pager that still has more pages left to fetch}
+                var cutoff
+                keys.forEach(function(key) {
+                    var pager = results.pagers[key]
+                    var items = pager.items()
+                    if (items.length == 0 || pager.done)
+                        return
+                    var last = items[items.length-1].modified_at
+                    if (!cutoff || cutoff < last)
+                        cutoff = last
+                })
                 var combined = []
                 keys.forEach(function(key) {
-                    vnode.state.pagers[key].items().forEach(function(item) {
+                    var pager = results.pagers[key]
+                    pager.itemsDisplayed = 0
+                    pager.items().every(function(item) {
+                        if (cutoff && item.modified_at < cutoff)
+                            // Some other pagers haven't caught up to
+                            // this point, so don't display this item
+                            // or anything after it.
+                            return false
                         item.session = sessions[key]
                         combined.push(item)
+                        pager.itemsDisplayed++
+                        return true // continue
                     })
                 })
-                displayItems(combined.sort(function(a, b) {
+                results.displayable = combined.sort(function(a, b) {
                     return a.modified_at < b.modified_at ? 1 : -1
-                }))
+                })
+                // Make a new loadMore function that hits the pagers
+                // (if necessary according to lowWaterMark)... or set
+                // results.loadMore to false if there is nothing left
+                // to fetch.
+                var loadable = []
+                Object.keys(results.pagers).map(function(key) {
+                    if (!results.pagers[key].done)
+                        loadable.push(results.pagers[key])
+                })
+                if (loadable.length == 0)
+                    results.loadMore = false
+                else
+                    results.loadMore = function() {
+                        results.loadMore = false
+                        loadable.map(function(pager) {
+                            if (pager.items().length - pager.itemsDisplayed < results.lowWaterMark)
+                                pager.loadNextPage()
+                        })
+                    }
             })
         })
     },
@@ -118,7 +195,7 @@ window.components.collection_search = {
                         ? m('span.label.label-xs.label-danger', 'none')
                         : Object.keys(sessions).sort().map(function(key) {
                             return [m('span.label.label-xs', {
-                                className: vnode.state.pagers[key].items() ? 'label-info' : 'label-default',
+                                className: vnode.state.results.pagers[key].items() ? 'label-info' : 'label-default',
                             }, key), ' ']
                         }),
                     ' ',
@@ -126,7 +203,7 @@ window.components.collection_search = {
                 ]),
             ]),
             m(window.components.collection_table_narrow, {
-                items: vnode.state.displayItems(),
+                results: vnode.state.results,
             }),
         ])
     },

commit 473c95bec9a11808bb286528a51a4dd671e1d0bb
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Aug 9 18:35:19 2017 -0400

    12033: Merge results from all sites into one table.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 625539c..0c821e9 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -6,15 +6,19 @@ window.components = window.components || {}
 window.components.collection_table_narrow = {
     view: function(vnode) {
         return m('table.table.table-condensed', [
-            m('thead', m('tr', m('th', vnode.attrs.key))),
+            m('thead', m('tr', [
+                m('th'),
+                m('th', 'uuid'),
+                m('th', 'name'),
+                m('th', 'last modified'),
+            ])),
             m('tbody', [
-                vnode.attrs.items().map(function(item) {
+                vnode.attrs.items.map(function(item) {
                     return m('tr', [
-                        m('td', [
-                            m('a', {href: vnode.attrs.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, item.name || '(unnamed)'),
-                            m('br'),
-                            m(window.components.datetime, {parse: item.modified_at}),
-                        ]),
+                        m('td', m('a.btn.btn-xs.btn-default', {href: item.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, 'Show')),
+                        m('td', item.uuid),
+                        m('td', item.name || '(unnamed)'),
+                        m('td', m(window.components.datetime, {parse: item.modified_at})),
                     ])
                 }),
             ]),
@@ -22,37 +26,74 @@ window.components.collection_table_narrow = {
     },
 }
 
+function Pager(loadFunc) {
+    // loadFunc(filters) returns a promise for a page of results.
+    var pager = this
+    Object.assign(pager, {
+        done: false,
+        items: m.stream(),
+        lastModifiedAt: null,
+        loadNextPage: function() {
+            // Get the next page, if there are any more items to get.
+            if (pager.done)
+                return
+            var filters = pager.lastModifiedAt ? [["modified_at", "<=", pager.lastModifiedAt]] : []
+            loadFunc(filters).then(function(resp) {
+                var items = pager.items() || []
+                Array.prototype.push.apply(items, resp.items)
+                if (resp.items.length == 0)
+                    pager.done = true
+                else
+                    pager.lastModifiedAt = resp.items[resp.items.length-1].modified_at
+                pager.items(items)
+            })
+        },
+    })
+}
+
 window.components.collection_search = {
     oninit: function(vnode) {
         vnode.state.sessionDB = new window.models.SessionDB()
         vnode.state.searchEntered = m.stream('')
         vnode.state.searchStart = m.stream('')
-        vnode.state.items = {}
+        // items ready to display
+        vnode.state.displayItems = m.stream([])
+        // {sessionKey -> Pager}
+        vnode.state.pagers = {}
         vnode.state.searchStart.map(function(q) {
             var sessions = vnode.state.sessionDB.loadAll()
             var cookie = (new Date()).getTime()
-            vnode.state.cookie = cookie
-            Object.keys(sessions).map(function(key) {
-                if (!vnode.state.items[key])
-                    vnode.state.items[key] = m.stream([])
-                vnode.state.items[key].dirty = true
-                vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
-                    data: {
-                        filters: JSON.stringify(!q ? [] : [['any', '@@', q+':*']]),
-                        count: 'none',
-                    },
-                }).then(function(resp) {
-                    if (cookie !== vnode.state.cookie)
-                        // a newer query is in progress; ignore this result.
-                        return
-                    vnode.state.items[key](resp.items)
-                    vnode.state.items[key].dirty = false
+            var displayItems = m.stream([])
+            vnode.state.displayItems = displayItems
+            m.stream.merge(Object.keys(sessions).map(function(key) {
+                var pager = new Pager(function(filters) {
+                    if (q)
+                        filters.push(['any', '@@', q+':*'])
+                    return vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
+                        data: {
+                            filters: JSON.stringify(filters),
+                            count: 'none',
+                        },
+                    })
                 })
+                vnode.state.pagers[key] = pager
+                pager.loadNextPage()
+                return pager.items.map(function() { return key })
+            })).map(function(keys) {
+                var combined = []
+                keys.forEach(function(key) {
+                    vnode.state.pagers[key].items().forEach(function(item) {
+                        item.session = sessions[key]
+                        combined.push(item)
+                    })
+                })
+                displayItems(combined.sort(function(a, b) {
+                    return a.modified_at < b.modified_at ? 1 : -1
+                }))
             })
         })
     },
     view: function(vnode) {
-        var items = vnode.state.items
         var sessions = vnode.state.sessionDB.loadAll()
         return m('form', {
             onsubmit: function() {
@@ -64,7 +105,7 @@ window.components.collection_search = {
                 m('.col-md-6', [
                     m('.input-group', [
                         m('input#search.form-control[placeholder=Search]', {
-                            oninput: m.withAttr('value', debounce(200, vnode.state.searchEntered)),
+                            oninput: m.withAttr('value', vnode.state.searchEntered),
                         }),
                         m('.input-group-btn', [
                             m('input.btn.btn-primary[type=submit][value="Search"]'),
@@ -73,45 +114,20 @@ window.components.collection_search = {
                 ]),
                 m('.col-md-6', [
                     'Searching sites: ',
-                    Object.keys(items).length == 0
+                    Object.keys(sessions).length == 0
                         ? m('span.label.label-xs.label-danger', 'none')
-                        : Object.keys(items).sort().map(function(key) {
-                            return [m('span.label.label-xs.label-info', key), ' ']
+                        : Object.keys(sessions).sort().map(function(key) {
+                            return [m('span.label.label-xs', {
+                                className: vnode.state.pagers[key].items() ? 'label-info' : 'label-default',
+                            }, key), ' ']
                         }),
                     ' ',
                     m('a[href="/sessions"]', 'Add/remove sites'),
                 ]),
             ]),
-            m('.row', Object.keys(items).sort().map(function(key) {
-                return m('.col-md-3', {key: key, style: {
-                    opacity: items[key].dirty ? 0.5 : 1,
-                }}, [
-                    m(window.components.collection_table_narrow, {
-                        key: key,
-                        session: sessions[key],
-                        items: items[key],
-                    }),
-                ])
-            })),
+            m(window.components.collection_table_narrow, {
+                items: vnode.state.displayItems(),
+            }),
         ])
     },
 }
-
-function debounce(t, f) {
-    // Return a new function that waits until t milliseconds have
-    // passed since it was last called, then calls f with its most
-    // recent arguments.
-    var this_was = this
-    var pending
-    return function() {
-        var args = arguments
-        if (pending) {
-            console.log("debounce!")
-            window.clearTimeout(pending)
-        }
-        pending = window.setTimeout(function() {
-            pending = undefined
-            f.apply(this_was, args)
-        }, t)
-    }
-}

commit c3af5da0100777902f4d968c5431630e713a511f
Author: Tom Clegg <tom at curoverse.com>
Date:   Wed Aug 9 13:43:29 2017 -0400

    12033: Use browser locale to render collection timestamps.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 7ab5047..625539c 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -13,7 +13,7 @@ window.components.collection_table_narrow = {
                         m('td', [
                             m('a', {href: vnode.attrs.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, item.name || '(unnamed)'),
                             m('br'),
-                            item.modified_at,
+                            m(window.components.datetime, {parse: item.modified_at}),
                         ]),
                     ])
                 }),
@@ -39,6 +39,7 @@ window.components.collection_search = {
                 vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
                     data: {
                         filters: JSON.stringify(!q ? [] : [['any', '@@', q+':*']]),
+                        count: 'none',
                     },
                 }).then(function(resp) {
                     if (cookie !== vnode.state.cookie)
diff --git a/apps/workbench/app/assets/javascripts/components/date.js b/apps/workbench/app/assets/javascripts/components/date.js
new file mode 100644
index 0000000..c3c905a
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/components/date.js
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.components = window.components || {}
+window.components.datetime = {
+    view: function(vnode) {
+        return m('span', new Date(Date.parse(vnode.attrs.parse)).toLocaleString())
+    },
+}

commit babd40cc9e963fd87952b0b23539189b56be10cf
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Aug 8 21:51:43 2017 -0400

    12033: Link collections to remote workbench sites.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index ddba2e1..7ab5047 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -11,7 +11,7 @@ window.components.collection_table_narrow = {
                 vnode.attrs.items().map(function(item) {
                     return m('tr', [
                         m('td', [
-                            m('a', {href: '/collections/'+item.uuid}, item.name || '(unnamed)'),
+                            m('a', {href: vnode.attrs.session.baseURL.replace('://', '://workbench.')+'/collections/'+item.uuid}, item.name || '(unnamed)'),
                             m('br'),
                             item.modified_at,
                         ]),
@@ -52,6 +52,7 @@ window.components.collection_search = {
     },
     view: function(vnode) {
         var items = vnode.state.items
+        var sessions = vnode.state.sessionDB.loadAll()
         return m('form', {
             onsubmit: function() {
                 vnode.state.searchStart(vnode.state.searchEntered())
@@ -86,6 +87,7 @@ window.components.collection_search = {
                 }}, [
                     m(window.components.collection_table_narrow, {
                         key: key,
+                        session: sessions[key],
                         items: items[key],
                     }),
                 ])

commit 2289f184787fa9db2cf3786a1383dd2ced2b643b
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Aug 8 21:51:05 2017 -0400

    12033: Fade out stale results.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
index 5bd667a..ddba2e1 100644
--- a/apps/workbench/app/assets/javascripts/components/collections.js
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -35,6 +35,7 @@ window.components.collection_search = {
             Object.keys(sessions).map(function(key) {
                 if (!vnode.state.items[key])
                     vnode.state.items[key] = m.stream([])
+                vnode.state.items[key].dirty = true
                 vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
                     data: {
                         filters: JSON.stringify(!q ? [] : [['any', '@@', q+':*']]),
@@ -44,6 +45,7 @@ window.components.collection_search = {
                         // a newer query is in progress; ignore this result.
                         return
                     vnode.state.items[key](resp.items)
+                    vnode.state.items[key].dirty = false
                 })
             })
         })
@@ -79,8 +81,13 @@ window.components.collection_search = {
                 ]),
             ]),
             m('.row', Object.keys(items).sort().map(function(key) {
-                return m('.col-md-3', {key: key}, [
-                    m(window.components.collection_table_narrow, {key: key, items: items[key]}),
+                return m('.col-md-3', {key: key, style: {
+                    opacity: items[key].dirty ? 0.5 : 1,
+                }}, [
+                    m(window.components.collection_table_narrow, {
+                        key: key,
+                        items: items[key],
+                    }),
                 ])
             })),
         ])

commit 6ba147122730b917392b791ed589e35656f9be14
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Aug 8 21:34:39 2017 -0400

    12033: Add /collections/multisite search page.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/app/assets/javascripts/components/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
new file mode 100644
index 0000000..5bd667a
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/components/collections.js
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.components = window.components || {}
+window.components.collection_table_narrow = {
+    view: function(vnode) {
+        return m('table.table.table-condensed', [
+            m('thead', m('tr', m('th', vnode.attrs.key))),
+            m('tbody', [
+                vnode.attrs.items().map(function(item) {
+                    return m('tr', [
+                        m('td', [
+                            m('a', {href: '/collections/'+item.uuid}, item.name || '(unnamed)'),
+                            m('br'),
+                            item.modified_at,
+                        ]),
+                    ])
+                }),
+            ]),
+        ])
+    },
+}
+
+window.components.collection_search = {
+    oninit: function(vnode) {
+        vnode.state.sessionDB = new window.models.SessionDB()
+        vnode.state.searchEntered = m.stream('')
+        vnode.state.searchStart = m.stream('')
+        vnode.state.items = {}
+        vnode.state.searchStart.map(function(q) {
+            var sessions = vnode.state.sessionDB.loadAll()
+            var cookie = (new Date()).getTime()
+            vnode.state.cookie = cookie
+            Object.keys(sessions).map(function(key) {
+                if (!vnode.state.items[key])
+                    vnode.state.items[key] = m.stream([])
+                vnode.state.sessionDB.request(sessions[key], 'arvados/v1/collections', {
+                    data: {
+                        filters: JSON.stringify(!q ? [] : [['any', '@@', q+':*']]),
+                    },
+                }).then(function(resp) {
+                    if (cookie !== vnode.state.cookie)
+                        // a newer query is in progress; ignore this result.
+                        return
+                    vnode.state.items[key](resp.items)
+                })
+            })
+        })
+    },
+    view: function(vnode) {
+        var items = vnode.state.items
+        return m('form', {
+            onsubmit: function() {
+                vnode.state.searchStart(vnode.state.searchEntered())
+                return false
+            },
+        }, [
+            m('.row', [
+                m('.col-md-6', [
+                    m('.input-group', [
+                        m('input#search.form-control[placeholder=Search]', {
+                            oninput: m.withAttr('value', debounce(200, vnode.state.searchEntered)),
+                        }),
+                        m('.input-group-btn', [
+                            m('input.btn.btn-primary[type=submit][value="Search"]'),
+                        ]),
+                    ]),
+                ]),
+                m('.col-md-6', [
+                    'Searching sites: ',
+                    Object.keys(items).length == 0
+                        ? m('span.label.label-xs.label-danger', 'none')
+                        : Object.keys(items).sort().map(function(key) {
+                            return [m('span.label.label-xs.label-info', key), ' ']
+                        }),
+                    ' ',
+                    m('a[href="/sessions"]', 'Add/remove sites'),
+                ]),
+            ]),
+            m('.row', Object.keys(items).sort().map(function(key) {
+                return m('.col-md-3', {key: key}, [
+                    m(window.components.collection_table_narrow, {key: key, items: items[key]}),
+                ])
+            })),
+        ])
+    },
+}
+
+function debounce(t, f) {
+    // Return a new function that waits until t milliseconds have
+    // passed since it was last called, then calls f with its most
+    // recent arguments.
+    var this_was = this
+    var pending
+    return function() {
+        var args = arguments
+        if (pending) {
+            console.log("debounce!")
+            window.clearTimeout(pending)
+        }
+        pending = window.setTimeout(function() {
+            pending = undefined
+            f.apply(this_was, args)
+        }, t)
+    }
+}
diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
index 058d450..75fe6f9 100644
--- a/apps/workbench/app/assets/javascripts/models/session_db.js
+++ b/apps/workbench/app/assets/javascripts/models/session_db.js
@@ -79,5 +79,11 @@ window.models.SessionDB = function() {
             })
             // m.request(session.baseURL + 'discovery/v1/apis/arvados/v1/rest').then(function(dd) {})
         },
+        request: function(session, path, opts) {
+            opts = opts || {}
+            opts.headers = opts.headers || {}
+            opts.headers.authorization = 'OAuth2 '+ session.token
+            return m.request(session.baseURL + path, opts)
+        },
     })
 }
diff --git a/apps/workbench/app/controllers/collections_controller.rb b/apps/workbench/app/controllers/collections_controller.rb
index f8fcf51..da7b466 100644
--- a/apps/workbench/app/controllers/collections_controller.rb
+++ b/apps/workbench/app/controllers/collections_controller.rb
@@ -16,7 +16,7 @@ class CollectionsController < ApplicationController
   skip_around_filter(:require_thread_api_token,
                      only: [:show_file, :show_file_links])
   skip_before_filter(:find_object_by_uuid,
-                     only: [:provenance, :show_file, :show_file_links])
+                     only: [:provenance, :show_file, :show_file_links, :multisite])
   # We depend on show_file to display the user agreement:
   skip_before_filter :check_user_agreements, only: :show_file
   skip_before_filter :check_user_profile, only: :show_file
diff --git a/apps/workbench/app/views/collections/multisite.html b/apps/workbench/app/views/collections/multisite.html
new file mode 100644
index 0000000..7e49ac9
--- /dev/null
+++ b/apps/workbench/app/views/collections/multisite.html
@@ -0,0 +1,5 @@
+<!-- Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: AGPL-3.0 -->
+
+<div data-mount-mithril="collection_search"></div>
diff --git a/apps/workbench/config/routes.rb b/apps/workbench/config/routes.rb
index 8fde2b8..8dcc7fd 100644
--- a/apps/workbench/config/routes.rb
+++ b/apps/workbench/config/routes.rb
@@ -95,6 +95,7 @@ ArvadosWorkbench::Application.routes.draw do
     post 'remove_selected_files', on: :member
     get 'tags', on: :member
     post 'save_tags', on: :member
+    get 'multisite', on: :collection
   end
   get('/collections/download/:uuid/:reader_token/*file' => 'collections#show_file',
       format: false)

commit 69487544de1e7fa7c636473e338fe90dde6c3c06
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Aug 8 09:10:43 2017 -0400

    12033: Add session-manager page.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/.gitignore b/apps/workbench/.gitignore
index 66a7adc..5fb3718 100644
--- a/apps/workbench/.gitignore
+++ b/apps/workbench/.gitignore
@@ -42,3 +42,4 @@
 
 # npm-rails
 /node_modules
+/npm-debug.log
diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js
index d4f928b..aa589ed 100644
--- a/apps/workbench/app/assets/javascripts/application.js
+++ b/apps/workbench/app/assets/javascripts/application.js
@@ -32,8 +32,11 @@
 //= require morris
 //= require jquery.number.min
 //= require npm-dependencies
+//= require mithril/stream/stream
 //= require_tree .
 
+window.m = Object.assign(window.Mithril, {stream: window.m.stream})
+
 jQuery(function($){
     $(document).ajaxStart(function(){
       $('.modal-with-loading-spinner .spinner').show();
@@ -155,7 +158,9 @@ jQuery(function($){
             // Need this to trigger input validation/synchronization callbacks because some browsers
             // auto-fill form fields (e.g., when navigating "back" to a page where some text
             // had been entered in a search box) without triggering a change or input event.
-            $('input').trigger('input');
+            $('input').each(function(el) {
+                $(el).trigger($.Event('input', {currentTarget: el}));
+            });
         });
 
     HeaderRowFixer = function(selector) {
diff --git a/apps/workbench/app/assets/javascripts/components/sessions.js b/apps/workbench/app/assets/javascripts/components/sessions.js
new file mode 100644
index 0000000..be4b7e3
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/components/sessions.js
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+$(document).on('ready', function() {
+    var db = new window.models.SessionDB()
+    db.checkForNewToken()
+    db.fillMissingUUIDs()
+})
+
+window.components = window.components || {}
+window.components.sessions = {
+    oninit: function(vnode) {
+        vnode.state.db = new window.models.SessionDB()
+        vnode.state.hostToAdd = m.stream('')
+    },
+    view: function(vnode) {
+        var db = vnode.state.db
+        var sessions = db.loadAll()
+        return m('container', [
+            m('table.table.table-condensed.table-hover', m('tbody', [
+                Object.keys(sessions).map(function(uuidPrefix) {
+                    var session = sessions[uuidPrefix]
+                    return m('tr', [
+                        session.token && session.user ? [
+                            m('td', m('a.btn.btn-xs.btn-default', {
+                                uuidPrefix: uuidPrefix,
+                                onclick: m.withAttr('uuidPrefix', db.logout),
+                            }, 'log out')),
+                            m('td', m('span.label.label-info', 'logged in')),
+                            m('td', {title: session.baseURL}, uuidPrefix),
+                            m('td', session.user.username),
+                            m('td', session.user.email),
+                        ] : [
+                            m('td', m('a.btn.btn-xs.btn-info', {
+                                uuidPrefix: uuidPrefix,
+                                onclick: m.withAttr('uuidPrefix', db.login),
+                            }, 'log in')),
+                            m('td', 'span.label.label-default', 'logged out'),
+                            m('td', {title: session.baseURL}, uuidPrefix),
+                            m('td'),
+                            m('td'),
+                        ],
+                        m('td', m('a.glyphicon.glyphicon-trash', {
+                            uuidPrefix: uuidPrefix,
+                            onclick: m.withAttr('uuidPrefix', db.trash),
+                        })),
+                    ])
+                }),
+            ])),
+            m('.row', m('.col-md-6', [
+                m('form', {
+                    onsubmit: function() {
+                        db.login(vnode.state.hostToAdd())
+                        return false
+                    },
+                }, [
+                    m('.input-group', [
+                        m('input.form-control[type=text][name=apiHost][placeholder="API host"]', {
+                            oninput: m.withAttr('value', vnode.state.hostToAdd),
+                        }),
+                        m('.input-group-btn', [
+                            m('input.btn.btn-primary[type=submit][value="Log in"]', {
+                                disabled: !vnode.state.hostToAdd(),
+                            }),
+                        ]),
+                    ]),
+                ]),
+            ])),
+        ])
+    },
+}
diff --git a/apps/workbench/app/assets/javascripts/mithril_mount.js b/apps/workbench/app/assets/javascripts/mithril_mount.js
index fe0907e..4a85a09 100644
--- a/apps/workbench/app/assets/javascripts/mithril_mount.js
+++ b/apps/workbench/app/assets/javascripts/mithril_mount.js
@@ -2,9 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-// rails_npm does "window.Mithril = require('mithril')" for us.
-var m = window.Mithril
-
 $(document).on('ready arv:pane:loaded', function() {
     $('[data-mount-mithril]').each(function() {
         m.mount(this, window.components[$(this).data('mount-mithril')])
diff --git a/apps/workbench/app/assets/javascripts/models/session_db.js b/apps/workbench/app/assets/javascripts/models/session_db.js
new file mode 100644
index 0000000..058d450
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/models/session_db.js
@@ -0,0 +1,83 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.models = window.models || {}
+window.models.SessionDB = function() {
+    var db = this
+    Object.assign(db, {
+        loadAll: function() {
+            try {
+                return JSON.parse(window.localStorage.getItem('sessions')) || {}
+            } catch(e) {}
+            return {}
+        },
+        save: function(k, v) {
+            var sessions = db.loadAll()
+            sessions[k] = v
+            window.localStorage.setItem('sessions', JSON.stringify(sessions))
+        },
+        trash: function(k) {
+            var sessions = db.loadAll()
+            delete sessions[k]
+            window.localStorage.setItem('sessions', JSON.stringify(sessions))
+        },
+        login: function(host) {
+            // Initiate login procedure with given API host (which can
+            // optionally include scheme://).
+            //
+            // Any page that has a button that invokes login() must
+            // also call checkForNewToken() on (at least) its first
+            // render. Otherwise, the login procedure can't be
+            // completed.
+            var baseURL = host
+            if (baseURL.indexOf('://') < 0)
+                baseURL = 'https://' + baseURL
+            if (!baseURL.endsWith('/'))
+                baseURL = baseURL + '/'
+            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href+'?baseURL='+encodeURIComponent(baseURL))
+            return false
+        },
+        checkForNewToken: function() {
+            // If there's a token and baseURL in the location bar (i.e.,
+            // we just landed here after a successful login), save it and
+            // scrub the location bar.
+            if (!document.location.search.startsWith('?'))
+                return
+            var params = {}
+            document.location.search.slice(1).split('&').map(function(kv) {
+                var e = kv.indexOf('=')
+                if (e < 0)
+                    return
+                params[decodeURIComponent(kv.slice(0, e))] = decodeURIComponent(kv.slice(e+1))
+            })
+            if (!params.baseURL || !params.api_token)
+                // Have a query string, but it's not a login callback.
+                return
+            params.token = params.api_token
+            delete params.api_token
+            db.save(params.baseURL, params)
+            history.replaceState({}, '', document.location.origin + document.location.pathname)
+        },
+        fillMissingUUIDs: function() {
+            var sessions = db.loadAll()
+            Object.keys(sessions).map(function(key) {
+                if (key.indexOf('://') < 0)
+                    return
+                // key is the baseURL placeholder. We need to get our user
+                // record to find out the cluster's real uuid prefix.
+                var session = sessions[key]
+                m.request(session.baseURL+'arvados/v1/users/current', {
+                    headers: {
+                        authorization: 'OAuth2 '+session.token,
+                    },
+                }).then(function(user) {
+                    session.user = user
+                    db.save(user.uuid.slice(0, 5), session)
+                    db.trash(key)
+                })
+            })
+            // m.request(session.baseURL + 'discovery/v1/apis/arvados/v1/rest').then(function(dd) {})
+        },
+    })
+}
diff --git a/apps/workbench/app/controllers/sessions_controller.rb b/apps/workbench/app/controllers/sessions_controller.rb
index d498653..f72b451 100644
--- a/apps/workbench/app/controllers/sessions_controller.rb
+++ b/apps/workbench/app/controllers/sessions_controller.rb
@@ -6,14 +6,19 @@ class SessionsController < ApplicationController
   skip_around_filter :require_thread_api_token, :only => [:destroy, :index]
   skip_around_filter :set_thread_api_token, :only => [:destroy, :index]
   skip_before_filter :find_object_by_uuid, :only => [:destroy, :index]
+  skip_before_filter :find_objects_for_index
+  skip_before_filter :ensure_arvados_api_exists
 
   def destroy
     session.clear
     redirect_to arvados_api_client.arvados_logout_url(return_to: root_url)
   end
 
-  def index
+  def logged_out
     redirect_to root_url if session[:arvados_api_token]
     render_index
   end
+
+  def index
+  end
 end
diff --git a/apps/workbench/app/views/sessions/index.html b/apps/workbench/app/views/sessions/index.html
new file mode 100644
index 0000000..ddfa8dd
--- /dev/null
+++ b/apps/workbench/app/views/sessions/index.html
@@ -0,0 +1,5 @@
+<!-- Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: AGPL-3.0 -->
+
+<div data-mount-mithril="sessions"></div>
diff --git a/apps/workbench/app/views/sessions/index.html.erb b/apps/workbench/app/views/sessions/logged_out.html.erb
similarity index 100%
rename from apps/workbench/app/views/sessions/index.html.erb
rename to apps/workbench/app/views/sessions/logged_out.html.erb
diff --git a/apps/workbench/app/views/tests/mithril.html b/apps/workbench/app/views/tests/mithril.html
new file mode 100644
index 0000000..4936f12
--- /dev/null
+++ b/apps/workbench/app/views/tests/mithril.html
@@ -0,0 +1 @@
+<div data-mount-mithril="test"></div>
diff --git a/apps/workbench/config/application.rb b/apps/workbench/config/application.rb
index a1f35c4..891dd43 100644
--- a/apps/workbench/config/application.rb
+++ b/apps/workbench/config/application.rb
@@ -52,6 +52,11 @@ module ArvadosWorkbench
 
     # Version of your assets, change this if you want to expire all your assets
     config.assets.version = '1.0'
+
+    # npm-rails loads top-level modules like window.Mithril, but we
+    # also pull in some code from node_modules in application.js, like
+    # mithril/stream/stream.
+    config.assets.paths << Rails.root.join('node_modules')
   end
 end
 
diff --git a/apps/workbench/config/routes.rb b/apps/workbench/config/routes.rb
index 8aec64e..8fde2b8 100644
--- a/apps/workbench/config/routes.rb
+++ b/apps/workbench/config/routes.rb
@@ -47,8 +47,9 @@ ArvadosWorkbench::Application.routes.draw do
   get '/repositories/:id/tree/:commit/*path' => 'repositories#show_tree', as: :show_repository_tree, format: false
   get '/repositories/:id/blob/:commit/*path' => 'repositories#show_blob', as: :show_repository_blob, format: false
   get '/repositories/:id/commit/:commit' => 'repositories#show_commit', as: :show_repository_commit
+  resources :sessions
   match '/logout' => 'sessions#destroy', via: [:get, :post]
-  get '/logged_out' => 'sessions#index'
+  get '/logged_out' => 'sessions#logged_out'
   resources :users do
     get 'choose', :on => :collection
     get 'home', :on => :member
diff --git a/apps/workbench/npm_packages b/apps/workbench/npm_packages
new file mode 100644
index 0000000..56acf9f
--- /dev/null
+++ b/apps/workbench/npm_packages
@@ -0,0 +1,10 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+# Run "rake npm:install"
+
+# Browserify is required.
+npm 'browserify', require: false, development: true
+
+npm 'mithril'

commit 50c0afeb2cad89f6248a36ed6b44a66ae974dbac
Author: Tom Clegg <tom at curoverse.com>
Date:   Thu Aug 3 15:54:38 2017 -0400

    12033: Add mithril via npm.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at curoverse.com>

diff --git a/apps/workbench/.gitignore b/apps/workbench/.gitignore
index a27ac31..66a7adc 100644
--- a/apps/workbench/.gitignore
+++ b/apps/workbench/.gitignore
@@ -39,3 +39,6 @@
 
 # Generated git-commit.version file
 /git-commit.version
+
+# npm-rails
+/node_modules
diff --git a/apps/workbench/Gemfile b/apps/workbench/Gemfile
index 8e9fcbf..788dcf6 100644
--- a/apps/workbench/Gemfile
+++ b/apps/workbench/Gemfile
@@ -102,3 +102,5 @@ gem 'lograge'
 gem 'logstash-event'
 
 gem 'safe_yaml'
+
+gem 'npm-rails'
diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock
index 0abe868..34db9cd 100644
--- a/apps/workbench/Gemfile.lock
+++ b/apps/workbench/Gemfile.lock
@@ -169,6 +169,8 @@ GEM
       net-ssh (>= 2.6.5)
     nokogiri (1.6.6.4)
       mini_portile (~> 0.6.0)
+    npm-rails (0.2.1)
+      rails (>= 3.2)
     oj (2.11.2)
     os (0.9.6)
     passenger (4.0.57)
@@ -296,6 +298,7 @@ DEPENDENCIES
   mocha
   morrisjs-rails
   multi_json
+  npm-rails
   oj
   passenger
   piwik_analytics
@@ -320,4 +323,4 @@ DEPENDENCIES
   wiselinks
 
 BUNDLED WITH
-   1.13.2
+   1.15.1
diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js
index c55bda0..d4f928b 100644
--- a/apps/workbench/app/assets/javascripts/application.js
+++ b/apps/workbench/app/assets/javascripts/application.js
@@ -31,6 +31,7 @@
 //= require raphael
 //= require morris
 //= require jquery.number.min
+//= require npm-dependencies
 //= require_tree .
 
 jQuery(function($){
diff --git a/apps/workbench/app/assets/javascripts/components/test.js b/apps/workbench/app/assets/javascripts/components/test.js
new file mode 100644
index 0000000..809c6d7
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/components/test.js
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.components = window.components || {}
+window.components.test = {
+    view: function(vnode) {
+        return m('div.mithril-test-component', [
+            m('p', {
+                onclick: m.withAttr('zzz', function(){}),
+            }, [
+                'mithril is working; rendered at t=',
+                (new Date()).getTime(),
+                'ms (click to re-render)',
+            ]),
+        ])
+    },
+}
diff --git a/apps/workbench/app/assets/javascripts/mithril_mount.js b/apps/workbench/app/assets/javascripts/mithril_mount.js
new file mode 100644
index 0000000..fe0907e
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/mithril_mount.js
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// rails_npm does "window.Mithril = require('mithril')" for us.
+var m = window.Mithril
+
+$(document).on('ready arv:pane:loaded', function() {
+    $('[data-mount-mithril]').each(function() {
+        m.mount(this, window.components[$(this).data('mount-mithril')])
+    })
+})
diff --git a/apps/workbench/app/controllers/tests_controller.rb b/apps/workbench/app/controllers/tests_controller.rb
new file mode 100644
index 0000000..5d2de4e
--- /dev/null
+++ b/apps/workbench/app/controllers/tests_controller.rb
@@ -0,0 +1,9 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class TestsController < ApplicationController
+  skip_before_filter :find_object_by_uuid
+  def mithril
+  end
+end
diff --git a/apps/workbench/config/routes.rb b/apps/workbench/config/routes.rb
index a3644e5..8aec64e 100644
--- a/apps/workbench/config/routes.rb
+++ b/apps/workbench/config/routes.rb
@@ -128,6 +128,8 @@ ArvadosWorkbench::Application.routes.draw do
 
   match '/_health/ping', to: 'healthcheck#ping', via: [:get]
 
+  get '/tests/mithril', to: 'tests#mithril'
+
   # Send unroutable requests to an arbitrary controller
   # (ends up at ApplicationController#render_not_found)
   match '*a', to: 'links#render_not_found', via: [:get, :post]
diff --git a/apps/workbench/test/integration/smoke_test.rb b/apps/workbench/test/integration/smoke_test.rb
index 9f2ade9..18973db 100644
--- a/apps/workbench/test/integration/smoke_test.rb
+++ b/apps/workbench/test/integration/smoke_test.rb
@@ -47,4 +47,10 @@ class SmokeTest < ActionDispatch::IntegrationTest
       # urls += all_links_in('body')
     end
   end
+
+  test "mithril test page" do
+    visit page_with_token('active_trustedclient', '/tests/mithril')
+    assert_visit_success
+    assert_selector 'p', text: 'mithril is working'
+  end
 end
diff --git a/build/package-build-dockerfiles/Makefile b/build/package-build-dockerfiles/Makefile
index e35056b..396370d 100644
--- a/build/package-build-dockerfiles/Makefile
+++ b/build/package-build-dockerfiles/Makefile
@@ -29,11 +29,15 @@ ubuntu1604/generated: common-generated-all
 	cp -rlt ubuntu1604/generated common-generated/*
 
 GOTARBALL=go1.8.3.linux-amd64.tar.gz
+NODETARBALL=node-v6.11.2-linux-x64.tar.xz
 
-common-generated-all: common-generated/$(GOTARBALL)
+common-generated-all: common-generated/$(GOTARBALL) common-generated/$(NODETARBALL)
 
 common-generated/$(GOTARBALL): common-generated
 	wget -cqO common-generated/$(GOTARBALL) http://storage.googleapis.com/golang/$(GOTARBALL)
 
+common-generated/$(NODETARBALL): common-generated
+	wget -cqO common-generated/$(NODETARBALL) https://nodejs.org/dist/v6.11.2/$(NODETARBALL)
+
 common-generated:
 	mkdir common-generated
diff --git a/build/package-build-dockerfiles/centos7/Dockerfile b/build/package-build-dockerfiles/centos7/Dockerfile
index 0f084b3..cf120c9 100644
--- a/build/package-build-dockerfiles/centos7/Dockerfile
+++ b/build/package-build-dockerfiles/centos7/Dockerfile
@@ -20,6 +20,10 @@ RUN gpg --keyserver pool.sks-keyservers.net --recv-keys D39DC0E3 && \
 ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Need to "touch" RPM database to workaround bug in interaction between
 # overlayfs and yum (https://bugzilla.redhat.com/show_bug.cgi?id=1213602)
 RUN touch /var/lib/rpm/* && yum -q -y install python33
diff --git a/build/package-build-dockerfiles/debian8/Dockerfile b/build/package-build-dockerfiles/debian8/Dockerfile
index f5aced7..b9998c6 100644
--- a/build/package-build-dockerfiles/debian8/Dockerfile
+++ b/build/package-build-dockerfiles/debian8/Dockerfile
@@ -22,6 +22,10 @@ RUN gpg --keyserver pool.sks-keyservers.net --recv-keys D39DC0E3 && \
 ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Old versions of setuptools cannot build a schema-salad package.
 RUN pip install --upgrade setuptools
 
diff --git a/build/package-build-dockerfiles/debian9/Dockerfile b/build/package-build-dockerfiles/debian9/Dockerfile
index 0003384..28ba9a3 100644
--- a/build/package-build-dockerfiles/debian9/Dockerfile
+++ b/build/package-build-dockerfiles/debian9/Dockerfile
@@ -21,10 +21,13 @@ RUN gpg --import /tmp/D39DC0E3.asc && \
     /usr/local/rvm/bin/rvm-exec default gem install cure-fpm --version 1.6.0b
 
 # Install golang binary
-COPY generated/go1.8.3.linux-amd64.tar.gz /usr/local/
-RUN cd /usr/local && ls && tar xzvf go1.8.3.linux-amd64.tar.gz
+ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Old versions of setuptools cannot build a schema-salad package.
 RUN pip install --upgrade setuptools
 
diff --git a/build/package-build-dockerfiles/ubuntu1204/Dockerfile b/build/package-build-dockerfiles/ubuntu1204/Dockerfile
index bf09089..1d07db7 100644
--- a/build/package-build-dockerfiles/ubuntu1204/Dockerfile
+++ b/build/package-build-dockerfiles/ubuntu1204/Dockerfile
@@ -22,6 +22,10 @@ RUN gpg --keyserver pool.sks-keyservers.net --recv-keys D39DC0E3 && \
 ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Old versions of setuptools cannot build a schema-salad package.
 RUN pip install --upgrade setuptools
 
diff --git a/build/package-build-dockerfiles/ubuntu1404/Dockerfile b/build/package-build-dockerfiles/ubuntu1404/Dockerfile
index ecfcefc..9e77ad3 100644
--- a/build/package-build-dockerfiles/ubuntu1404/Dockerfile
+++ b/build/package-build-dockerfiles/ubuntu1404/Dockerfile
@@ -22,6 +22,10 @@ RUN gpg --keyserver pool.sks-keyservers.net --recv-keys D39DC0E3 && \
 ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Old versions of setuptools cannot build a schema-salad package.
 RUN pip install --upgrade setuptools
 
diff --git a/build/package-build-dockerfiles/ubuntu1604/Dockerfile b/build/package-build-dockerfiles/ubuntu1604/Dockerfile
index b7c02d7..e4673c8 100644
--- a/build/package-build-dockerfiles/ubuntu1604/Dockerfile
+++ b/build/package-build-dockerfiles/ubuntu1604/Dockerfile
@@ -22,6 +22,10 @@ RUN gpg --keyserver pool.sks-keyservers.net --recv-keys D39DC0E3 && \
 ADD generated/go1.8.3.linux-amd64.tar.gz /usr/local/
 RUN ln -s /usr/local/go/bin/go /usr/local/bin/
 
+# Install nodejs and npm
+ADD generated/node-v6.11.2-linux-x64.tar.xz /usr/local/
+RUN ln -s /usr/local/node-v6.11.2-linux-x64/bin/* /usr/local/bin/
+
 # Old versions of setuptools cannot build a schema-salad package.
 RUN pip install --upgrade setuptools
 
diff --git a/build/run-build-packages.sh b/build/run-build-packages.sh
index a0a6c5f..af90eb6 100755
--- a/build/run-build-packages.sh
+++ b/build/run-build-packages.sh
@@ -621,6 +621,7 @@ if [[ "$?" == "0" ]] ; then
       \cp config/environments/production.rb.example config/environments/production.rb -f
       sed -i 's/secret_token: ~/secret_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/' config/application.yml
 
+      RAILS_ENV=production RAILS_GROUPS=assets bundle exec rake npm:install >/dev/null
       RAILS_ENV=production RAILS_GROUPS=assets bundle exec rake assets:precompile >/dev/null
 
       # Remove generated configuration files so they don't go in the package.
diff --git a/build/run-tests.sh b/build/run-tests.sh
index 3952b36..15e89fa 100755
--- a/build/run-tests.sh
+++ b/build/run-tests.sh
@@ -204,6 +204,8 @@ sanity_checks() {
     echo -n 'gitolite: '
     which gitolite \
         || fatal "No gitolite. Try: apt-get install gitolite3"
+    which npm \
+          || fatal "No npm. Try: wget -O- https://nodejs.org/dist/v6.11.2/node-v6.11.2-linux-x64.tar.xz | sudo tar -C /usr/local xJf - && sudo ln -s ../node-v6.11.2-linux-x64/bin/{node,npm} /usr/local/bin/"
 }
 
 rotate_logfile() {
@@ -805,7 +807,8 @@ done
 install_workbench() {
     cd "$WORKSPACE/apps/workbench" \
         && mkdir -p tmp/cache \
-        && RAILS_ENV=test bundle_install_trylocal
+        && RAILS_ENV=test bundle_install_trylocal \
+        && RAILS_ENV=test RAILS_GROUPS=assets bundle exec rake npm:install
 }
 do_install apps/workbench workbench
 

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list