Merge branch 'master' into 12033-multisite-search
authorTom Clegg <tclegg@veritasgenetics.com>
Thu, 17 Aug 2017 17:18:41 +0000 (13:18 -0400)
committerTom Clegg <tclegg@veritasgenetics.com>
Thu, 17 Aug 2017 17:18:41 +0000 (13:18 -0400)
refs #12033

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tclegg@veritasgenetics.com>

34 files changed:
apps/workbench/.gitignore
apps/workbench/Gemfile
apps/workbench/Gemfile.lock
apps/workbench/app/assets/javascripts/application.js
apps/workbench/app/assets/javascripts/components/collections.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/components/date.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/components/save_ui_state.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/components/sessions.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/components/test.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/mithril_mount.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/models/loader.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/models/session_db.js [new file with mode: 0644]
apps/workbench/app/controllers/collections_controller.rb
apps/workbench/app/controllers/sessions_controller.rb
apps/workbench/app/controllers/tests_controller.rb [new file with mode: 0644]
apps/workbench/app/views/collections/multisite.html [new file with mode: 0644]
apps/workbench/app/views/layouts/application.html.erb
apps/workbench/app/views/layouts/body.html.erb
apps/workbench/app/views/sessions/index.html [new file with mode: 0644]
apps/workbench/app/views/sessions/logged_out.html.erb [moved from apps/workbench/app/views/sessions/index.html.erb with 100% similarity]
apps/workbench/app/views/tests/mithril.html [new file with mode: 0644]
apps/workbench/config/application.rb
apps/workbench/config/routes.rb
apps/workbench/npm_packages [new file with mode: 0644]
apps/workbench/test/integration/smoke_test.rb
build/package-build-dockerfiles/Makefile
build/package-build-dockerfiles/centos7/Dockerfile
build/package-build-dockerfiles/debian8/Dockerfile
build/package-build-dockerfiles/debian9/Dockerfile
build/package-build-dockerfiles/ubuntu1204/Dockerfile
build/package-build-dockerfiles/ubuntu1404/Dockerfile
build/package-build-dockerfiles/ubuntu1604/Dockerfile
build/run-build-packages.sh
build/run-tests.sh

index a27ac31580a1d6b5cc81ab47e60c8deb649a2f85..5fb3718f38dd8aa5a480b7561bdd1e9824be7c47 100644 (file)
@@ -39,3 +39,7 @@
 
 # Generated git-commit.version file
 /git-commit.version
+
+# npm-rails
+/node_modules
+/npm-debug.log
index 8e9fcbfed91f4dcc268001bd3b9538c68bcd6530..788dcf65764b75d345e4be5a7a408259307edd19 100644 (file)
@@ -102,3 +102,5 @@ gem 'lograge'
 gem 'logstash-event'
 
 gem 'safe_yaml'
+
+gem 'npm-rails'
index 0abe868ccf75bac683e44529ce939a421e772d45..34db9cd71c9ef61187b8593a111e76fa410538ea 100644 (file)
@@ -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
index c55bda037890bd084c0fa14a13e5da87f5130e3a..aa589ed28c7742b8103a8c63d300435fdf69eff9 100644 (file)
 //= require raphael
 //= 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();
@@ -154,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/collections.js b/apps/workbench/app/assets/javascripts/components/collections.js
new file mode 100644 (file)
index 0000000..9f7c804
--- /dev/null
@@ -0,0 +1,160 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.CollectionsTable = {
+    maybeLoadMore: function(dom) {
+        var loader = this.loader
+        if (loader.done || loader.loading)
+            // Can't start getting more items 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)
+        }
+    },
+    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.loader = vnode.attrs.loader
+        vnode.state.onupdate(vnode)
+    },
+    onupdate: function(vnode) {
+        vnode.state.loader = vnode.attrs.loader
+    },
+    onremove: function(vnode) {
+        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', [
+            m('thead', m('tr', [
+                m('th'),
+                m('th', 'uuid'),
+                m('th', 'name'),
+                m('th', 'last modified'),
+            ])),
+            m('tbody', [
+                vnode.attrs.loader.items() && vnode.attrs.loader.items().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.arvados-uuid', item.uuid),
+                        m('td', item.name || '(unnamed)'),
+                        m('td', m(LocalizedDateTime, {parse: item.modified_at})),
+                    ])
+                }),
+            ]),
+            m('tfoot', m('tr', [
+                vnode.attrs.loader.done ? null : m('th[colspan=4]', m('button.btn.btn-xs', {
+                    className: vnode.attrs.loader.loading ? 'btn-default' : 'btn-primary',
+                    style: {
+                        display: 'block',
+                        width: '12em',
+                        marginLeft: 'auto',
+                        marginRight: 'auto',
+                    },
+                    disabled: vnode.attrs.loader.loading,
+                    onclick: function() {
+                        vnode.attrs.loader.loadMore()
+                        return false
+                    },
+                }, vnode.attrs.loader.loading ? '(loading)' : 'Load more')),
+            ])),
+        ])
+    },
+}
+
+window.CollectionsSearch = {
+    oninit: function(vnode) {
+        vnode.state.sessionDB = new SessionDB()
+        vnode.state.searchEntered = m.stream()
+        vnode.state.searchActive = m.stream()
+        // When searchActive changes (e.g., when restoring state
+        // after navigation), update the text field too.
+        vnode.state.searchActive.map(vnode.state.searchEntered)
+        // When searchActive changes, create a new loader that filters
+        // with the given search term.
+        vnode.state.searchActive.map(function(q) {
+            var sessions = vnode.state.sessionDB.loadActive()
+            vnode.state.loader = new MergingLoader({
+                children: Object.keys(sessions).map(function(key) {
+                    var session = sessions[key]
+                    return new MultipageLoader({
+                        sessionKey: key,
+                        loadFunc: function(filters) {
+                            if (q)
+                                filters.push(['any', '@@', q+':*'])
+                            return vnode.state.sessionDB.request(session, 'arvados/v1/collections', {
+                                data: {
+                                    filters: JSON.stringify(filters),
+                                    count: 'none',
+                                },
+                            }).then(function(resp) {
+                                resp.items.map(function(item) {
+                                    item.session = session
+                                })
+                                return resp
+                            })
+                        },
+                    })
+                })
+            })
+        })
+    },
+    view: function(vnode) {
+        var sessions = vnode.state.sessionDB.loadAll()
+        return m('form', {
+            onsubmit: function() {
+                vnode.state.searchActive(vnode.state.searchEntered())
+                vnode.state.forgetSavedState = true
+                return false
+            },
+        }, [
+            m(SaveUIState, {
+                defaultState: '',
+                currentState: vnode.state.searchActive,
+                forgetSavedState: vnode.state.forgetSavedState,
+                saveBodyHeight: true,
+            }),
+            vnode.state.loader && [
+                m('.row', [
+                    m('.col-md-6', [
+                        m('.input-group', [
+                            m('input#search.form-control[placeholder=Search]', {
+                                oninput: m.withAttr('value', vnode.state.searchEntered),
+                                value: vnode.state.searchEntered(),
+                            }),
+                            m('.input-group-btn', [
+                                m('input.btn.btn-primary[type=submit][value="Search"]'),
+                            ]),
+                        ]),
+                    ]),
+                    m('.col-md-6', [
+                        'Searching sites: ',
+                        vnode.state.loader.children.length == 0
+                            ? m('span.label.label-xs.label-danger', 'none')
+                            : vnode.state.loader.children.map(function(child) {
+                                return [m('span.label.label-xs', {
+                                    className: child.items() ? 'label-success' : 'label-warning',
+                                }, child.sessionKey), ' ']
+                            }),
+                        ' ',
+                        m('a[href="/sessions"]', 'Add/remove sites'),
+                    ]),
+                ]),
+                m(CollectionsTable, {
+                    loader: vnode.state.loader,
+                }),
+            ],
+        ])
+    },
+}
diff --git a/apps/workbench/app/assets/javascripts/components/date.js b/apps/workbench/app/assets/javascripts/components/date.js
new file mode 100644 (file)
index 0000000..62eacc3
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.LocalizedDateTime = {
+    view: function(vnode) {
+        return m('span', new Date(Date.parse(vnode.attrs.parse)).toLocaleString())
+    },
+}
diff --git a/apps/workbench/app/assets/javascripts/components/save_ui_state.js b/apps/workbench/app/assets/javascripts/components/save_ui_state.js
new file mode 100644 (file)
index 0000000..b24aef3
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.SaveUIState = {
+    saveState: function() {
+        var state = history.state || {}
+        state.bodyHeight = window.getComputedStyle(document.body)['height']
+        state.currentState = this.currentState()
+        history.replaceState(state, '')
+    },
+    oninit: function(vnode) {
+        vnode.state.currentState = vnode.attrs.currentState
+        var hstate = history.state || {}
+
+        if (vnode.attrs.saveBodyHeight && hstate.bodyHeight) {
+            document.body.style['min-height'] = hstate.bodyHeight
+            delete hstate.bodyHeight
+        }
+
+        if (hstate.currentState) {
+            vnode.attrs.currentState(hstate.currentState)
+            delete hstate.currentState
+        } else {
+            vnode.attrs.currentState(vnode.attrs.defaultState)
+        }
+
+        history.replaceState(hstate, '')
+    },
+    oncreate: function(vnode) {
+        vnode.state.saveState = vnode.state.saveState.bind(vnode.state)
+        window.addEventListener('beforeunload', vnode.state.saveState)
+        vnode.state.onupdate(vnode)
+    },
+    onupdate: function(vnode) {
+        if (vnode.attrs.saveBodyHeight && vnode.attrs.forgetSavedState) {
+            document.body.style['min-height'] = null
+        }
+    },
+    onremove: function(vnode) {
+        window.removeEventListener('beforeunload', vnode.state.saveState)
+    },
+    view: function(vnode) {
+        return null
+    },
+}
diff --git a/apps/workbench/app/assets/javascripts/components/sessions.js b/apps/workbench/app/assets/javascripts/components/sessions.js
new file mode 100644 (file)
index 0000000..17c144c
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+$(document).on('ready', function() {
+    var db = new SessionDB()
+    db.checkForNewToken()
+    db.fillMissingUUIDs()
+})
+
+window.SessionsTable = {
+    oninit: function(vnode) {
+        vnode.state.db = new 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('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.trash),
+                            }, 'Remove ', m('span.glyphicon.glyphicon-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/components/test.js b/apps/workbench/app/assets/javascripts/components/test.js
new file mode 100644 (file)
index 0000000..4893544
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.TestComponent = {
+    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 (file)
index 0000000..f4689b5
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+$(document).on('ready arv:pane:loaded', function() {
+    $('[data-mount-mithril]').each(function() {
+        m.mount(this, window[$(this).data('mount-mithril')])
+    })
+})
diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js
new file mode 100644 (file)
index 0000000..1b44670
--- /dev/null
@@ -0,0 +1,134 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// MultipageLoader retrieves a multi-page result set from the
+// server. The constructor initiates the first page load.
+//
+// config.loadFunc is a function that accepts an array of
+// paging-related filters, and returns a promise for the API
+// response. loadFunc() must retrieve results in "modified_at desc"
+// order.
+//
+// done is true if there are no more pages to load.
+//
+// loading is true if a network request is in progress.
+//
+// items is a stream that resolves to an array of all items retrieved so far.
+//
+// loadMore() loads the next page, if any.
+window.MultipageLoader = function(config) {
+    var loader = this
+    Object.assign(loader, config, {
+        done: false,
+        loading: false,
+        items: m.stream(),
+        thresholdItem: null,
+        loadMore: function() {
+            if (loader.done || loader.loading)
+                return
+            var filters = loader.thresholdItem ? [
+                ["modified_at", "<=", loader.thresholdItem.modified_at],
+                ["uuid", "!=", loader.thresholdItem.uuid],
+            ] : []
+            loader.loading = true
+            loader.loadFunc(filters).then(function(resp) {
+                var items = loader.items() || []
+                Array.prototype.push.apply(items, resp.items)
+                if (resp.items.length == 0)
+                    loader.done = true
+                else
+                    loader.thresholdItem = resp.items[resp.items.length-1]
+                loader.loading = false
+                loader.items(items)
+            }).catch(function(err) {
+                loader.err = err
+                loader.loading = false
+            })
+        },
+    })
+    loader.loadMore()
+}
+
+// MergingLoader merges results from multiple loaders (given in the
+// config.children array) into a single result set.
+//
+// new MergingLoader({children: [loader, loader, ...]})
+//
+// The children must retrieve results in "modified_at desc" order.
+window.MergingLoader = function(config) {
+    var loader = this
+    Object.assign(loader, config, {
+        // Sorted items ready to display, merged from all children.
+        items: m.stream(),
+        done: false,
+        loading: false,
+        loadable: function() {
+            // Return an array of children that we could call
+            // loadMore() on. Update loader.done and loader.loading.
+            loader.done = true
+            loader.loading = false
+            return loader.children.filter(function(child) {
+                if (child.done)
+                    return false
+                loader.done = false
+                if (!child.loading)
+                    return true
+                loader.loading = true
+                return false
+            })
+        },
+        loadMore: function() {
+            // Call loadMore() on children that have reached
+            // lowWaterMark.
+            loader.loadable().map(function(child) {
+                if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
+                    loader.loading = true
+                    child.loadMore()
+                }
+            })
+        },
+        mergeItems: function() {
+            // cutoff is the topmost (recent) of {bottom (oldest) entry of
+            // any child that still has more pages left to fetch}
+            var cutoff
+            loader.children.forEach(function(child) {
+                var items = child.items()
+                if (items.length == 0 || child.done)
+                    return
+                var last = items[items.length-1].modified_at
+                if (!cutoff || cutoff < last)
+                    cutoff = last
+            })
+            var combined = []
+            loader.children.forEach(function(child) {
+                child.itemsDisplayed = 0
+                child.items().every(function(item) {
+                    if (cutoff && item.modified_at < cutoff)
+                        // Some other children haven't caught up to this
+                        // point, so don't display this item or anything
+                        // after it.
+                        return false
+                    combined.push(item)
+                    child.itemsDisplayed++
+                    return true // continue
+                })
+            })
+            loader.items(combined.sort(function(a, b) {
+                return a.modified_at < b.modified_at ? 1 : -1
+            }))
+        },
+        // 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.items in each "load more"
+        // event (except for the case where all items are displayed).
+        lowWaterMark: 23,
+    })
+    var childrenReady = m.stream.merge(loader.children.map(function(child) {
+        return child.items
+    }))
+    childrenReady.map(loader.loadable)
+    childrenReady.map(loader.mergeItems)
+}
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 (file)
index 0000000..1ebff83
--- /dev/null
@@ -0,0 +1,116 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+window.SessionDB = function() {
+    var db = this
+    Object.assign(db, {
+        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
+        },
+        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
+            Object.keys(sessions).forEach(function(key) {
+                if (sessions[key].isFromRails)
+                    delete sessions[key]
+            })
+            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.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
+            // 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) {})
+        },
+        request: function(session, path, opts) {
+            opts = opts || {}
+            opts.headers = opts.headers || {}
+            opts.headers.authorization = 'OAuth2 '+ session.token
+            return m.request(session.baseURL + path, opts)
+        },
+    })
+}
index 5fcb2dc569ff6b2446c602dc26de61a069155ba2..779d95c45b874d4fec157768e19013a1c68a0022 100644 (file)
@@ -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
index d4986538d8635f42027d7f1e7ab561cd2c0fd94f..48fbc6bd04a708326d2dfe0f01afd5ae5383c12f 100644 (file)
@@ -3,17 +3,22 @@
 # 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
 
   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/controllers/tests_controller.rb b/apps/workbench/app/controllers/tests_controller.rb
new file mode 100644 (file)
index 0000000..5d2de4e
--- /dev/null
@@ -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/app/views/collections/multisite.html b/apps/workbench/app/views/collections/multisite.html
new file mode 100644 (file)
index 0000000..9b03f10
--- /dev/null
@@ -0,0 +1,5 @@
+<!-- Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: AGPL-3.0 -->
+
+<div data-mount-mithril="CollectionsSearch"></div>
index 71b1cd15984e6b45151e8ab7ec8ad2d8a60f32bb..b59bad4ec151b6dc003e18020b36a6391fc9f609 100644 (file)
@@ -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 %>
index 3315027b3d9a1b485eaf0b578b86af1e1e68cb30..a2256a056995ec713afd32548d086b7f51da48e4 100644 (file)
@@ -28,6 +28,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=
diff --git a/apps/workbench/app/views/sessions/index.html b/apps/workbench/app/views/sessions/index.html
new file mode 100644 (file)
index 0000000..bf23028
--- /dev/null
@@ -0,0 +1,5 @@
+<!-- Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: AGPL-3.0 -->
+
+<div data-mount-mithril="SessionsTable"></div>
diff --git a/apps/workbench/app/views/tests/mithril.html b/apps/workbench/app/views/tests/mithril.html
new file mode 100644 (file)
index 0000000..a629eb7
--- /dev/null
@@ -0,0 +1 @@
+<div data-mount-mithril="TestComponent"></div>
index a1f35c43c333e9cc31c13b7279e16951ced87af0..891dd432c0dccfedf4f773cd41748c0222124e88 100644 (file)
@@ -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
 
index a3644e51699871351aecba16ad7dda354695443b..8dcc7fdd20cadbf41bb9da7997c06597169e29eb 100644 (file)
@@ -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
@@ -94,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)
@@ -128,6 +130,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/npm_packages b/apps/workbench/npm_packages
new file mode 100644 (file)
index 0000000..56acf9f
--- /dev/null
@@ -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'
index 9f2ade9828c54293f713b772b2fefea976d05a6c..18973dbeee72fbff920a0ae9cd516e85555509fb 100644 (file)
@@ -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
index e35056b5c079b04c275ec22b09df6a6b85f2b0b2..396370dad7c44d6a7393ab93ac8801d559ba34af 100644 (file)
@@ -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
index 0f084b300b0c20a9114c06a50d2096e34d4fced3..cf120c911c563b71fada0a1d788f1f6a8b1035a8 100644 (file)
@@ -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
index f5aced70d6d6d96131be2489e2cfc8da75aa321c..b9998c6e7bc22d1a9edaec3c0098a30129d2d67f 100644 (file)
@@ -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
 
index 0003384d6675e9fd339697a9d145bf0dec65f88d..28ba9a352d7a333d56a3f5a466fe6284c79d64f0 100644 (file)
@@ -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
 
index bf0908969103569ccf4638b4239f49a980e55310..1d07db744ceebe2e1f20f0c468ce949c6baecd7b 100644 (file)
@@ -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
 
index ecfcefc593b894eb55f8c9b8287211a9d5dcc7f0..9e77ad3121756ce5be29a5edec07af63b56fb81a 100644 (file)
@@ -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
 
index b7c02d79de421b4a587eab86ad18713cc24071bc..e4673c8ae1f21bb187bd80632cce26efde4c15d1 100644 (file)
@@ -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
 
index 2958d3323d9972f7cf826ef1fb0cb69c05d448eb..2cde946d494b566d1681f3c16fd851e5a9fb7fa5 100755 (executable)
@@ -622,6 +622,7 @@ if [[ "$?" == "0" ]] ; then
       sed -i 's/secret_token: ~/secret_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/' config/application.yml
       sed -i 's/keep_web_url: false/keep_web_url: exampledotcom/' 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.
index 20780811a58e5ecd59ce9c4b399f3b914c462480..70ea0ef073ba4a8d726ebce8565f1a4e659ab525 100755 (executable)
@@ -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() {
@@ -808,7 +810,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