[ARVADOS] updated: 1df5dac7a399d6e8d740ef861229ddab8b698442

git at public.curoverse.com git at public.curoverse.com
Tue Nov 25 18:20:53 EST 2014


Summary of changes:
 .../app/assets/javascripts/angular_shim.js         |  16 +-
 .../app/assets/javascripts/arvados_client.js       | 158 ++++---
 apps/workbench/app/assets/javascripts/tab_panes.js |   2 +-
 .../app/assets/javascripts/upload_to_collection.js | 502 +++++++++++----------
 .../app/views/collections/_show_upload.html.erb    |  16 +-
 .../app/views/layouts/application.html.erb         |   4 +-
 apps/workbench/app/views/projects/show.html.erb    |   6 +-
 .../test/integration/pipeline_instances_test.rb    |   6 +-
 8 files changed, 377 insertions(+), 333 deletions(-)

       via  1df5dac7a399d6e8d740ef861229ddab8b698442 (commit)
       via  bb32ded4360a7ff35205bb620ff448c342e0883a (commit)
       via  c836dc7a116bc3385333af9fa4d2297e09591bd6 (commit)
       via  c5ea28a77f5ba29e8d953f5b14147ab0addd5d84 (commit)
       via  c9d3d15d17906aa4d65a0bb044596621be6516a7 (commit)
       via  78191f9893da5bbfc33f46c2794ca6d92a61be17 (commit)
       via  23f6cfa6ed5686a79f7945cf8a2cb9ef46cf7d46 (commit)
       via  90b59fc16bae23485dbdcb8af3009c2efc03e6d6 (commit)
      from  d95550d1827701788a59b2c834caddf4e3e1729d (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit 1df5dac7a399d6e8d740ef861229ddab8b698442
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 18:20:53 2014 -0500

    3781: Show #finished counter in info alert while running.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index cd93db3..267543c 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -46,6 +46,15 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             $scope.uploadQueue.splice(index, 1);
             if (wasRunning)
                 $scope.go();
+        },
+        countDone: function() {
+            var done=0;
+            for (var i=0; i<$scope.uploadQueue.length; i++) {
+                if ($scope.uploadQueue[i].state == 'Done') {
+                    ++done;
+                }
+            }
+            return done;
         }
     });
     // TODO: watch uploadQueue, abort uploads if entries disappear
diff --git a/apps/workbench/app/views/collections/_show_upload.html.erb b/apps/workbench/app/views/collections/_show_upload.html.erb
index 9997ad4..f85f628 100644
--- a/apps/workbench/app/views/collections/_show_upload.html.erb
+++ b/apps/workbench/app/views/collections/_show_upload.html.erb
@@ -13,6 +13,14 @@
           </div>
         </div>
         <div class="col-sm-8">
+          <div ng-show="uploader.state == 'Running'"
+               class="alert alert-info"
+               ><i class="fa fa-gear"></i>
+            Upload in progress.
+            <span ng-show="countDone() > 0">
+              {{countDone()}} file{{countDone()>1?'s':''}} finished.
+            </span>
+          </div>
           <div ng-show="uploader.state == 'Idle' && uploader.stateReason"
                class="alert alert-success"
                ><i class="fa fa-flag-checkered"></i> {{uploader.stateReason}}
@@ -30,9 +38,9 @@
       <button class="btn btn-xs btn-default"
               ng-show="!upload.committed"
               ng-click="removeFileFromQueue($index)"
-              title="cancel"><i class="fa fa-fw fa-trash-o"></i></button>
+              title="cancel"><i class="fa fa-fw fa-times"></i></button>
       <span class="label label-success label-info"
-            ng-show="upload.committed">added</span>
+            ng-show="upload.committed">finished</span>
     </div>
     <div class="col-sm-4 nowrap" style="overflow-x:hidden;text-overflow:ellipsis">
       <span title="{{upload.file.name}}">

commit bb32ded4360a7ff35205bb620ff448c342e0883a
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 17:57:57 2014 -0500

    3781: Fix interrupt handling.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index 88f3e8e..cd93db3 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -109,6 +109,10 @@ function UploadToCollection($scope, $filter, $q, $timeout,
         function stop() {
             _failMax = 0;
             _jqxhr.abort();
+            _deferred.reject({
+                textStatus: 'stopped',
+                err: 'interrupted at slice '+_label
+            });
         }
         function goSend() {
             _jqxhr = $.ajax({
@@ -166,10 +170,11 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             state: 'Queued',    // Queued, Uploading, Paused, Done
             statistics: null,
             go: go,
-            stop: stop
+            stop: stop          // User wants to stop.
         });
         ////////////////////////////////
         var that = this;
+        var _currentUploader;
         var _currentSlice;
         var _deferred;
         var _maxBlobSize = Math.pow(2,26);
@@ -183,17 +188,17 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             if (_deferred)
                 _deferred.reject({textStatus: 'restarted'});
             _deferred = $q.defer();
-            that.statistics = 'Starting';
             that.state = 'Uploading';
             _startTime = Date.now();
             _startByte = _readPos;
+            setProgress();
             goSlice();
             return _deferred.promise;
         }
         function stop() {
             if (_deferred) {
                 that.state = 'Paused';
-                _deferred.reject({textStatus: 'interrupted'});
+                _deferred.reject({textStatus: 'stopped', err: 'interrupted'});
             }
             if (_currentUploader) {
                 _currentUploader.stop();
@@ -207,6 +212,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             _currentSlice = nextSlice();
             if (!_currentSlice) {
                 that.state = 'Done';
+                setProgress(_readPos);
                 _currentUploader = null;
                 _deferred.resolve([that]);
                 return;
@@ -225,18 +231,19 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                 console.log("onUploaderResolve but locator=" + locator +
                             " and " + _currentSlice.size + " != " + dataSize);
                 return onUploaderReject({
-                    textStatus: "Error",
+                    textStatus: "error",
                     err: "Bad response from slice upload"
                 });
             }
             that.locators.push(locator);
             _readPos += dataSize;
+            _currentUploader = null;
             goSlice();
         }
         function onUploaderReject(reason) {
-            that.statistics = 'Interrupted';
             that.state = 'Paused';
             setProgress(_readPos);
+            _currentUploader = null;
             _deferred.reject(reason);
         }
         function onUploaderProgress(sliceDone, sliceSize) {
@@ -265,7 +272,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                     '' + $filter('number')(bytesDone/1024, '0') + 'K ' +
                         'at ~' + $filter('number')(kBps, '0') + 'K/s')
                 if (that.state == 'Paused') {
-                    // That's all I have to say about that.
+                    that.statistics += ', paused';
                 } else if (that.state == 'Uploading') {
                     that.statistics += ', ETA ' +
                         $filter('date')(
@@ -277,6 +284,8 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                         $filter('date')(Date.now(), 'shortTime');
                     _finishTime = Date.now();
                 }
+            } else {
+                that.statistics = that.state;
             }
             _deferred.notify();
         }
@@ -331,13 +340,9 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             // If anything is not-done, do it.
             if ($scope.uploadQueue.length > 0 &&
                 $scope.uploadQueue[0].state != 'Done') {
-                return $scope.uploadQueue[0].go().then(
-                    appendToCollection,
-                    onQueueReject,
-                    onQueueProgress
-                ).then(
-                    doQueueWork,
-                    onQueueReject);
+                return $scope.uploadQueue[0].go().
+                    then(appendToCollection, null, onQueueProgress).
+                    then(doQueueWork, onQueueReject);
             }
             // If everything is done, resolve the promise and clean up.
             return onQueueResolve();
@@ -399,7 +404,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                         uploads[i].committed = true;
                     }
                 });
-            return deferred;
+            return deferred.promise.then(doQueueWork);
         }
     }
 }
diff --git a/apps/workbench/app/views/collections/_show_upload.html.erb b/apps/workbench/app/views/collections/_show_upload.html.erb
index b2b1bcf..9997ad4 100644
--- a/apps/workbench/app/views/collections/_show_upload.html.erb
+++ b/apps/workbench/app/views/collections/_show_upload.html.erb
@@ -7,7 +7,7 @@
       <div class="row">
         <div class="col-sm-4">
           <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');">
-          <div class="btn-group btn-group-sm" role="group" style="margin-top: .5em">
+          <div class="btn-group btn-group-sm" role="group" style="margin-top: 1.5em">
             <button type="button" class="btn btn-default" ng-click="stop()" ng-disabled="uploader.state != 'Running'"><i class="fa fa-fw fa-pause"></i> Pause</button>
             <button type="button" class="btn btn-primary" ng-click="go()" ng-disabled="uploader.state == 'Running' || uploadQueue.length == 0"><i class="fa fa-fw fa-play"></i> Start</button>
           </div>

commit c836dc7a116bc3385333af9fa4d2297e09591bd6
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 17:24:38 2014 -0500

    3781: Fix up filter usage.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index ebd22fc..88f3e8e 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -12,17 +12,13 @@ function arvUuid() {
     };
 }
 
-UploadToCollection.$inject = ['$scope', '$q', '$timeout',
-                              'dateFilter', 'numberFilter',
+UploadToCollection.$inject = ['$scope', '$filter', '$q', '$timeout',
                               'ArvadosClient', 'arvadosApiToken'];
-function UploadToCollection($scope, $q, $timeout,
-                            numberFilter, dateFilter,
+function UploadToCollection($scope, $filter, $q, $timeout,
                             ArvadosClient, arvadosApiToken) {
     $.extend($scope, {
         uploadQueue: [],
         uploader: new QueueUploader(),
-        numberFilter: numberFilter,
-        dateFilter: dateFilter,
         addFilesToQueue: function(files) {
             // Angular binding doesn't work its usual magic for file
             // inputs, so we need to $scope.$apply() this update.
@@ -266,19 +262,19 @@ function UploadToCollection($scope, $q, $timeout,
                 kBps = (bytesDone - _startByte) /
                     (Date.now() - _startTime);
                 that.statistics = (
-                    '' + $scope.numberFilter(bytesDone/1024, '0') + 'K ' +
-                        'at ~' + $scope.numberFilter(kBps, '0') + 'K/s')
+                    '' + $filter('number')(bytesDone/1024, '0') + 'K ' +
+                        'at ~' + $filter('number')(kBps, '0') + 'K/s')
                 if (that.state == 'Paused') {
                     // That's all I have to say about that.
                 } else if (that.state == 'Uploading') {
                     that.statistics += ', ETA ' +
-                        $scope.dateFilter(
+                        $filter('date')(
                             new Date(
                                 Date.now() + (that.file.size - bytesDone) / kBps),
                             'shortTime')
                 } else {
                     that.statistics += ', finished ' +
-                        $scope.dateFilter(Date.now(), 'shortTime');
+                        $filter('date')(Date.now(), 'shortTime');
                     _finishTime = Date.now();
                 }
             }

commit c5ea28a77f5ba29e8d953f5b14147ab0addd5d84
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 17:13:29 2014 -0500

    3781: Tweak directive and controller names.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index 83398b7..ebd22fc 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -1,22 +1,23 @@
 var app = angular.module('Workbench', ['Arvados']);
-app.
-    directive('uuid', function() {
-        // Copy the given uuid into the current $scope.
-        return {
-            restrict: 'A',
-            link: function(scope, element, attributes) {
-                scope.uuid = attributes.uuid;
-            }
+app.controller('UploadToCollection', UploadToCollection);
+app.directive('arvUuid', arvUuid);
+
+function arvUuid() {
+    // Copy the given uuid into the current $scope.
+    return {
+        restrict: 'A',
+        link: function(scope, element, attributes) {
+            scope.uuid = attributes.arvUuid;
         }
-    }).
-    controller('UploadController', UploadController);
+    };
+}
 
-UploadController.$inject = ['$scope', '$q', '$timeout',
-                            'dateFilter', 'numberFilter',
-                            'ArvadosClient', 'arvadosApiToken']
-function UploadController($scope, $q, $timeout,
-                          numberFilter, dateFilter,
-                          ArvadosClient, arvadosApiToken) {
+UploadToCollection.$inject = ['$scope', '$q', '$timeout',
+                              'dateFilter', 'numberFilter',
+                              'ArvadosClient', 'arvadosApiToken'];
+function UploadToCollection($scope, $q, $timeout,
+                            numberFilter, dateFilter,
+                            ArvadosClient, arvadosApiToken) {
     $.extend($scope, {
         uploadQueue: [],
         uploader: new QueueUploader(),
diff --git a/apps/workbench/app/views/collections/_show_upload.html.erb b/apps/workbench/app/views/collections/_show_upload.html.erb
index c20cea3..b2b1bcf 100644
--- a/apps/workbench/app/views/collections/_show_upload.html.erb
+++ b/apps/workbench/app/views/collections/_show_upload.html.erb
@@ -1,7 +1,7 @@
 <div class="arv-log-refresh-control"
      data-load-throttle="86486400000" <%# 1001 nights %>
      ></div>
-<div ng-controller="UploadController" uuid="<%= @object.uuid %>">
+<div ng-cloak ng-controller="UploadToCollection" arv-uuid="<%= @object.uuid %>">
   <div class="panel panel-primary">
     <div class="panel-body">
       <div class="row">

commit c9d3d15d17906aa4d65a0bb044596621be6516a7
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 14:09:25 2014 -0500

    3781: Fix double-compiling of angular bits.

diff --git a/apps/workbench/app/assets/javascripts/angular_shim.js b/apps/workbench/app/assets/javascripts/angular_shim.js
index f329cb7..a480eaf 100644
--- a/apps/workbench/app/assets/javascripts/angular_shim.js
+++ b/apps/workbench/app/assets/javascripts/angular_shim.js
@@ -1,8 +1,12 @@
-// Compile any new HTML content that was loaded via ajax.
+// Compile any new HTML content that was loaded via jQuery.ajax().
+// Currently this only works for tabs because they emit an
+// arv:pane:loaded event after updating the DOM.
 
-$(document).on('ajax:success arv:pane:loaded', function() {
-    angular.element(document).injector().invoke(function($compile) {
-        var scope = angular.element(document).scope();
-        $compile(document)(scope);
-    });
+$(document).on('arv:pane:loaded', function(event, updatedElement) {
+    if (updatedElement) {
+        angular.element(updatedElement).injector().invoke(function($compile) {
+            var scope = angular.element(updatedElement).scope();
+            $compile(updatedElement)(scope);
+        });
+    }
 });
diff --git a/apps/workbench/app/assets/javascripts/tab_panes.js b/apps/workbench/app/assets/javascripts/tab_panes.js
index 07e46fe..f603440 100644
--- a/apps/workbench/app/assets/javascripts/tab_panes.js
+++ b/apps/workbench/app/assets/javascripts/tab_panes.js
@@ -124,7 +124,7 @@ $(document).on('arv:pane:reload', '[data-pane-content-url]', function(e) {
             $pane.removeClass('pane-loading');
             $pane.addClass('pane-loaded');
             $pane.attr('data-loaded-at', (new Date()).getTime());
-            $pane.trigger('arv:pane:loaded');
+            $pane.trigger('arv:pane:loaded', $pane);
 
             if ($pane.hasClass('pane-stale')) {
                 $pane.trigger('arv:pane:reload');

commit 78191f9893da5bbfc33f46c2794ca6d92a61be17
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 11:24:37 2014 -0500

    3781: Refactor JS classes.

diff --git a/apps/workbench/app/assets/javascripts/arvados_client.js b/apps/workbench/app/assets/javascripts/arvados_client.js
index a1f34d2..584928f 100644
--- a/apps/workbench/app/assets/javascripts/arvados_client.js
+++ b/apps/workbench/app/assets/javascripts/arvados_client.js
@@ -1,78 +1,94 @@
-function ArvadosClient(discoveryUri, apiToken) {
+angular.
+    module('Arvados', []).
+    service('ArvadosClient', ArvadosClient);
+
+ArvadosClient.$inject = ['arvadosApiToken', 'arvadosDiscoveryUri']
+function ArvadosClient(arvadosApiToken, arvadosDiscoveryUri) {
     $.extend(this, {
-        apiToken: apiToken,
-        discoveryUri: discoveryUri,
-        apiPromise: function(controller, action, params) {
-            return this.getDiscoveryDoc().then(function() {
-                var meth = this.discoveryDoc.resources[controller].methods[action];
-                var data = $.extend({}, params, {_method: meth.httpMethod});
-                $.each(data, function(k, v) {
-                    if (typeof(v) == 'object') {
-                        data[k] = JSON.stringify(v);
-                    }
-                });
-                var path = meth.path.replace(/{(.*?)}/, function(_, key) {
-                    var val = data[key];
-                    delete data[key];
-                    return encodeURIComponent(val);
-                });
-                return $.ajax({
-                    url: this.discoveryDoc.baseUrl + path,
-                    type: 'POST',
-                    crossDomain: true,
-                    dataType: 'json',
-                    data: data,
-                    headers: {
-                        Authorization: 'OAuth2 ' + this.apiToken
-                    }
-                });
+        apiPromise: apiPromise,
+        uniqueNameForManifest: uniqueNameForManifest
+    });
+    return this;
+    ////////////////////////////////
+
+    var that = this;
+    var promiseDiscovery;
+    var discoveryDoc;
+
+    function apiPromise(controller, action, params) {
+        // Start an API call. Return a promise that will resolve with
+        // the API response.
+        return getDiscoveryDoc().then(function() {
+            var meth = discoveryDoc.resources[controller].methods[action];
+            var data = $.extend({}, params, {_method: meth.httpMethod});
+            $.each(data, function(k, v) {
+                if (typeof(v) == 'object') {
+                    data[k] = JSON.stringify(v);
+                }
+            });
+            var path = meth.path.replace(/{(.*?)}/, function(_, key) {
+                var val = data[key];
+                delete data[key];
+                return encodeURIComponent(val);
             });
-        },
-        getDiscoveryDoc: function() {
-            if (this.promiseDiscovery) return this.promiseDiscovery;
-            this.promiseDiscovery = $.ajax({
-                url: this.discoveryUri,
+            return $.ajax({
+                url: discoveryDoc.baseUrl + path,
+                type: 'POST',
                 crossDomain: true,
-                context: this
+                dataType: 'json',
+                data: data,
+                headers: {
+                    Authorization: 'OAuth2 ' + arvadosApiToken
+                }
+            });
+        });
+    }
+
+    function uniqueNameForManifest(manifest, streamName, origName) {
+        // Return an (escaped) filename starting with (unescaped)
+        // origName that won't conflict with any existing names in
+        // the manifest if saved under streamName. streamName must
+        // be exactly as given in the manifest, e.g., "." or
+        // "./foo" or "./foo/bar".
+        //
+        // Example:
+        //
+        // unique('./foo [...] 0:0:bar\040baz\n', '.', 'foo/bar baz')
+        // =>
+        // 'foo/bar\\040baz\\040(1)'
+        var newName;
+        var nameStub = origName;
+        var suffixInt = null;
+        var ok = false;
+        while (!ok) {
+            ok = true;
+            // Add ' (N)' before the filename extension, if any.
+            newName = (!suffixInt ? nameStub :
+                       nameStub.replace(/(\.[^.]*)?$/, ' ('+suffixInt+')$1')).
+                replace(/ /g, '\\040');
+            $.each(manifest.split('\n'), function(_, line) {
+                var i, match, foundName;
+                var toks = line.split(' ');
+                for (var i=1; i<toks.length && ok; i++)
+                    if (match = toks[i].match(/^\d+:\d+:(\S+)/))
+                        if (toks[0] + '/' + match[1] === streamName + '/' + newName) {
+                            suffixInt = (suffixInt || 0) + 1;
+                            ok = false;
+                        }
+            });
+        }
+        return newName;
+    }
+
+    function getDiscoveryDoc() {
+        if (!promiseDiscovery) {
+            promiseDiscovery = $.ajax({
+                url: arvadosDiscoveryUri,
+                crossDomain: true
             }).then(function(data, status, xhr) {
-                this.discoveryDoc = data;
+                discoveryDoc = data;
             });
-            return this.promiseDiscovery;
-        },
-        uniqueNameForManifest: function(manifest, streamName, origName) {
-            // Return an (escaped) filename starting with (unescaped)
-            // origName that won't conflict with any existing names in
-            // the manifest if saved under streamName. streamName must
-            // be exactly as given in the manifest, e.g., "." or
-            // "./foo" or "./foo/bar".
-            //
-            // Example:
-            //
-            // unique('./foo [...] 0:0:bar\040baz\n', '.', 'foo/bar baz')
-            // =>
-            // 'foo/bar\\040baz\\040(1)'
-            var newName;
-            var nameStub = origName;
-            var suffixInt = null;
-            var ok = false;
-            while (!ok) {
-                ok = true;
-                // Add ' (N)' before the filename extension, if any.
-                newName = (!suffixInt ? nameStub :
-                           nameStub.replace(/(\.[^.]*)?$/, ' ('+suffixInt+')$1')).
-                    replace(/ /g, '\\040');
-                $.each(manifest.split('\n'), function(_, line) {
-                    var i, match, foundName;
-                    var toks = line.split(' ');
-                    for (var i=1; i<toks.length && ok; i++)
-                        if (match = toks[i].match(/^\d+:\d+:(\S+)/))
-                            if (toks[0] + '/' + match[1] === streamName + '/' + newName) {
-                                suffixInt = (suffixInt || 0) + 1;
-                                ok = false;
-                            }
-                });
-            }
-            return newName;
         }
-    });
+        return promiseDiscovery;
+    }
 }
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index c4fbf67..83398b7 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -1,4 +1,4 @@
-var app = angular.module('Workbench', []);
+var app = angular.module('Workbench', ['Arvados']);
 app.
     directive('uuid', function() {
         // Copy the given uuid into the current $scope.
@@ -9,15 +9,17 @@ app.
             }
         }
     }).
-    controller(
-        'UploadController',
-        ['$scope', '$q', '$timeout', 'numberFilter', 'dateFilter',
-         UploadController]);
+    controller('UploadController', UploadController);
 
-function UploadController($scope, $q, $timeout, numberFilter, dateFilter) {
+UploadController.$inject = ['$scope', '$q', '$timeout',
+                            'dateFilter', 'numberFilter',
+                            'ArvadosClient', 'arvadosApiToken']
+function UploadController($scope, $q, $timeout,
+                          numberFilter, dateFilter,
+                          ArvadosClient, arvadosApiToken) {
     $.extend($scope, {
         uploadQueue: [],
-        uploader: new QueueUploader($scope, $q, $timeout),
+        uploader: new QueueUploader(),
         numberFilter: numberFilter,
         dateFilter: dateFilter,
         addFilesToQueue: function(files) {
@@ -31,7 +33,7 @@ function UploadController($scope, $q, $timeout, numberFilter, dateFilter) {
                      insertAt++);
                 for (i=0; i<files.length; i++) {
                     $scope.uploadQueue.splice(insertAt+i, 0,
-                        new FileUploader($scope, $q, files[i]));
+                        new FileUploader(files[i]));
                 }
             });
         },
@@ -50,278 +52,277 @@ function UploadController($scope, $q, $timeout, numberFilter, dateFilter) {
         }
     });
     // TODO: watch uploadQueue, abort uploads if entries disappear
-}
 
-function SliceReader(uploader, slice) {
-    var that = this;
-    $.extend(this, {
-        go: function() {
+    var keepProxy;
+
+    function SliceReader(_slice) {
+        var that = this;
+        $.extend(this, {
+            go: go
+        });
+        ////////////////////////////////
+        var _deferred;
+        var _reader;
+        function go() {
             // Return a promise, which will be resolved with the
             // requested slice data.
-            this._deferred = $.Deferred();
-            this._reader = new FileReader();
-            this._reader.onload = this.resolve;
-            this._reader.onerror = this._deferred.reject;
-            this._reader.onprogress = this._deferred.notify;
-            this._reader.readAsArrayBuffer(this._slice.blob);
-            return this._deferred.promise();
-        },
-        ////////////////////////////////
-        _deferred: null,
-        _reader: null,
-        _slice: slice,
-        _uploader: uploader,
-        resolve: function() {
+            _deferred = $.Deferred();
+            _reader = new FileReader();
+            _reader.onload = resolve;
+            _reader.onerror = _deferred.reject;
+            _reader.onprogress = _deferred.notify;
+            _reader.readAsArrayBuffer(_slice.blob);
+            return _deferred.promise();
+        }
+        function resolve() {
             if (that._reader.result.length != that._slice.size) {
                 // Sometimes we get an onload event even if the read
                 // did not return the desired number of bytes. We
                 // treat that as a fail.
-                return $.Deferred().reject(
+                _deferred.reject(
                     null, "Read error",
-                    "Short read: wanted " + that._slice.size +
-                        ", received " + that._reader.result.length).promise();
+                    "Short read: wanted " + _slice.size +
+                        ", received " + _reader.result.length);
+                return;
             }
-            return that._deferred.resolve(that._reader.result);
+            return _deferred.resolve(_reader.result);
         }
-    });
-}
+    }
 
-function SliceUploader(uploader, label, data, dataSize) {
-    var that = this;
-    $.extend(this, {
-        go: function() {
+    function SliceUploader(_label, _data, _dataSize) {
+        $.extend(this, {
+            go: go,
+            stop: stop
+        });
+        ////////////////////////////////
+        var that = this;
+        var _deferred;
+        var _failCount = 0;
+        var _failMax = 3;
+        var _jqxhr;
+        function go() {
             // Send data to the Keep proxy. Retry a few times on
             // fail. Return a promise that will get resolved with
             // resolve(locator) when the block is accepted by the
             // proxy.
-            this._deferred = $.Deferred();
-            this.goSend();
-            return this._deferred.promise();
-        },
-        stop: function() {
-            this._failMax = 0;
-            this._jqxhr.abort();
-        },
-        _data: data,
-        _dataSize: dataSize,
-        _deferred: null,
-        _failCount: 0,
-        _failMax: 3,
-        _label: label,
-        _jqxhr: null,
-        _uploader: uploader,
-        goSend: function() {
-            this._jqxhr = $.ajax({
-                url: this.proxyUriBase(),
+            _deferred = $.Deferred();
+            goSend();
+            return _deferred.promise();
+        }
+        function stop() {
+            _failMax = 0;
+            _jqxhr.abort();
+        }
+        function goSend() {
+            _jqxhr = $.ajax({
+                url: proxyUriBase(),
                 type: 'POST',
                 crossDomain: true,
                 headers: {
-                    'Authorization': 'OAuth2 '+this._uploader.arvados.apiToken,
+                    'Authorization': 'OAuth2 '+arvadosApiToken,
                     'Content-Type': 'application/octet-stream',
                     'X-Keep-Desired-Replicas': '2'
                 },
                 xhr: function() {
+                    // Make an xhr that reports upload progress
                     var xhr = $.ajaxSettings.xhr();
                     if (xhr.upload) {
-                        xhr.upload.onprogress = that.onSendProgress;
+                        xhr.upload.onprogress = onSendProgress;
                     }
                     return xhr;
                 },
                 processData: false,
-                data: that._data
+                data: _data
             });
-            this._jqxhr.then(this.onSendResolve, this.onSendReject);
-        },
-        onSendProgress: function(xhrProgressEvent) {
-            that._deferred.notify(xhrProgressEvent.position, that._dataSize);
-        },
-        onSendResolve: function(data, textStatus, jqxhr) {
-            that._deferred.resolve(data, that._dataSize);
-        },
-        onSendReject: function(xhr, textStatus, err) {
-            if (++that._failCount < that._failMax) {
+            _jqxhr.then(onSendResolve, onSendReject);
+        }
+        function onSendProgress(xhrProgressEvent) {
+            _deferred.notify(xhrProgressEvent.loaded, _dataSize);
+        }
+        function onSendResolve(data, textStatus, jqxhr) {
+            _deferred.resolve(data, _dataSize);
+        }
+        function onSendReject(xhr, textStatus, err) {
+            if (++_failCount < _failMax) {
                 // TODO: nice to tell the user that retry is happening.
-                console.log('slice ' + that._label + ': ' +
-                            textStatus + ', retry ' + that._failCount);
-                that.goSend();
+                console.log('slice ' + _label + ': ' +
+                            textStatus + ', retry ' + _failCount);
+                goSend();
             } else {
-                that._deferred.reject(
+                _deferred.reject(
                     {xhr: xhr, textStatus: textStatus, err: err});
             }
-        },
-        proxyUriBase: function() {
-            var proxy = this._uploader.keepProxy;
-            return ((proxy.service_ssl_flag ? 'https' : 'http') +
-                    '://' + proxy.service_host + ':' +
-                    proxy.service_port + '/');
         }
-    });
-}
+        function proxyUriBase() {
+            return ((keepProxy.service_ssl_flag ? 'https' : 'http') +
+                    '://' + keepProxy.service_host + ':' +
+                    keepProxy.service_port + '/');
+        }
+    }
 
-function FileUploader($scope, $q, file) {
-    var that = this;
-    $.extend(this, {
-        committed: false,
-        file: file,
-        locators: [],
-        progress: 0.0,
-        state: 'Queued',        // Queued, Uploading, Paused, Done
-        statistics: null,
-        go: function() {
-            if (this._deferred)
-                this._deferred.reject({textStatus: 'restarted'});
-            this._deferred = $q.defer();
-            this.statistics = 'Starting';
-            this.state = 'Uploading';
-            this.startTime = Date.now();
-            this.startByte = this._readPos;
-            this.goSlice();
-            return this._deferred.promise;
-        },
-        stop: function() {
-            if (this._deferred) {
-                this.state = 'Paused';
-                this._deferred.reject({textStatus: 'interrupted'});
+    function FileUploader(file) {
+        $.extend(this, {
+            committed: false,
+            file: file,
+            locators: [],
+            progress: 0.0,
+            state: 'Queued',    // Queued, Uploading, Paused, Done
+            statistics: null,
+            go: go,
+            stop: stop
+        });
+        ////////////////////////////////
+        var that = this;
+        var _currentSlice;
+        var _deferred;
+        var _maxBlobSize = Math.pow(2,26);
+        var _bytesDone = 0;
+        var _queueTime = Date.now();
+        var _startTime;
+        var _startByte;
+        var _finishTime;
+        var _readPos = 0;       // number of bytes confirmed uploaded
+        function go() {
+            if (_deferred)
+                _deferred.reject({textStatus: 'restarted'});
+            _deferred = $q.defer();
+            that.statistics = 'Starting';
+            that.state = 'Uploading';
+            _startTime = Date.now();
+            _startByte = _readPos;
+            goSlice();
+            return _deferred.promise;
+        }
+        function stop() {
+            if (_deferred) {
+                that.state = 'Paused';
+                _deferred.reject({textStatus: 'interrupted'});
             }
-            if (this._currentUploader) {
-                this._currentUploader.stop();
-                this._currentUploader = null;
+            if (_currentUploader) {
+                _currentUploader.stop();
+                _currentUploader = null;
             }
-        },
-        _currentSlice: null,
-        _deferred: null,
-        $scope: $scope,
-        uploader: $scope.uploader,
-        maxBlobSize: Math.pow(2,26),
-        bytesDone: 0,
-        queueTime: Date.now(),
-        startTime: null,
-        startByte: null,
-        finishTime: null,
-        _readPos: 0,            // number of bytes confirmed uploaded
-        goSlice: function() {
+        }
+        function goSlice() {
             // Ensure this._deferred gets resolved or rejected --
             // either right here, or when a new promise arranged right
             // here is fulfilled.
-            this._currentSlice = this.nextSlice();
-            if (!this._currentSlice) {
-                this.state = 'Done';
-                this._currentUploader = null;
-                this._deferred.resolve([this]);
+            _currentSlice = nextSlice();
+            if (!_currentSlice) {
+                that.state = 'Done';
+                _currentUploader = null;
+                _deferred.resolve([that]);
                 return;
             }
-            this._currentUploader = new SliceUploader(
-                this.uploader,
-                this._readPos.toString(),
-                this._currentSlice.blob,
-                this._currentSlice.size);
-            this._currentUploader.go().then(
-                this.onUploaderResolve,
-                this.onUploaderReject,
-                this.onUploaderProgress);
-        },
-        onUploaderResolve: function(locator, dataSize) {
-            // TODO: check that._currentSlice.size == dataSize
-            if (!locator || that._currentSlice.size != dataSize) {
+            _currentUploader = new SliceUploader(
+                _readPos.toString(),
+                _currentSlice.blob,
+                _currentSlice.size);
+            _currentUploader.go().then(
+                onUploaderResolve,
+                onUploaderReject,
+                onUploaderProgress);
+        }
+        function onUploaderResolve(locator, dataSize) {
+            if (!locator || _currentSlice.size != dataSize) {
                 console.log("onUploaderResolve but locator=" + locator +
-                            " and " + that._currentSlice.size + " != " + dataSize);
-                return that.onUploaderReject({
+                            " and " + _currentSlice.size + " != " + dataSize);
+                return onUploaderReject({
                     textStatus: "Error",
                     err: "Bad response from slice upload"
                 });
             }
             that.locators.push(locator);
-            that._readPos += dataSize;
-            that.goSlice();
-        },
-        onUploaderReject: function(reason) {
+            _readPos += dataSize;
+            goSlice();
+        }
+        function onUploaderReject(reason) {
             that.statistics = 'Interrupted';
             that.state = 'Paused';
-            that.setProgress(that._readPos);
-            that._deferred.reject(reason);
-        },
-        onUploaderProgress: function(sliceDone, sliceSize) {
-            that.setProgress(that._readPos + sliceDone);
-        },
-        nextSlice: function() {
+            setProgress(_readPos);
+            _deferred.reject(reason);
+        }
+        function onUploaderProgress(sliceDone, sliceSize) {
+            setProgress(_readPos + sliceDone);
+        }
+        function nextSlice() {
             var size = Math.min(
-                this.maxBlobSize,
-                this.file.size - this._readPos);
-            this.setProgress(this._readPos);
+                _maxBlobSize,
+                that.file.size - _readPos);
+            setProgress(_readPos);
             if (size == 0) {
                 return false;
             }
-            var blob = this.file.slice(
-                this._readPos, this._readPos+size,
+            var blob = that.file.slice(
+                _readPos, _readPos+size,
                 'application/octet-stream; charset=x-user-defined');
             return {blob: blob, size: size};
-        },
-        setProgress: function(bytesDone) {
+        }
+        function setProgress(bytesDone) {
             var kBps;
-            this.progress = Math.min(100, 100 * bytesDone / this.file.size)
-            if (bytesDone > this.startByte) {
-                kBps = (bytesDone - this.startByte) /
-                    (Date.now() - this.startTime);
-                this.statistics = (
-                    '' + this.$scope.numberFilter(bytesDone/1024, '0') + 'K ' +
-                        'at ~' + this.$scope.numberFilter(kBps, '0') + 'K/s')
-                if (this.state == 'Paused') {
+            that.progress = Math.min(100, 100 * bytesDone / that.file.size)
+            if (bytesDone > _startByte) {
+                kBps = (bytesDone - _startByte) /
+                    (Date.now() - _startTime);
+                that.statistics = (
+                    '' + $scope.numberFilter(bytesDone/1024, '0') + 'K ' +
+                        'at ~' + $scope.numberFilter(kBps, '0') + 'K/s')
+                if (that.state == 'Paused') {
                     // That's all I have to say about that.
                 } else if (that.state == 'Uploading') {
-                    this.statistics += ', ETA ' +
-                        this.$scope.dateFilter(
+                    that.statistics += ', ETA ' +
+                        $scope.dateFilter(
                             new Date(
-                                Date.now() + (this.file.size - bytesDone) / kBps),
+                                Date.now() + (that.file.size - bytesDone) / kBps),
                             'shortTime')
                 } else {
-                    this.statistics += ', finished ' +
-                        this.$scope.dateFilter(Date.now(), 'shortTime');
-                    this.finishTime = Date.now();
+                    that.statistics += ', finished ' +
+                        $scope.dateFilter(Date.now(), 'shortTime');
+                    _finishTime = Date.now();
                 }
             }
-            this._deferred.notify();
+            _deferred.notify();
         }
-    });
-}
+    }
 
-function QueueUploader($scope, $q, $timeout) {
-    var that = this;
-    $.extend(this, {
-        state: 'Idle',
-        stateReason: null,
-        statusSuccess: null,
-        go: function() {
-            if (this.state == 'Running') return this._deferred.promise;
-            this._deferred = $.Deferred();
-            this.state = 'Running';
-            this.arvados.apiPromise(
+    function QueueUploader() {
+        $.extend(this, {
+            state: 'Idle',
+            stateReason: null,
+            statusSuccess: null,
+            go: go,
+            stop: stop
+        });
+        ////////////////////////////////
+        var that = this;
+        var _deferred;
+        function go() {
+            if (that.state == 'Running') return _deferred.promise;
+            _deferred = $.Deferred();
+            that.state = 'Running';
+            ArvadosClient.apiPromise(
                 'keep_services', 'list',
                 {filters: [['service_type','=','proxy']]}).
-                then(this.doQueueWithProxy);
-            this.onQueueProgress();
-            return this._deferred.promise();
-        },
-        stop: function() {
+                then(doQueueWithProxy);
+            onQueueProgress();
+            return _deferred.promise();
+        }
+        function stop() {
             for (var i=0; i<$scope.uploadQueue.length; i++)
                 $scope.uploadQueue[i].stop();
-        },
-        ////////////////////////////////
-        arvados: new ArvadosClient(
-            $('meta[name=arvados-discovery-uri]').attr('content'),
-            $('meta[name=arvados-api-token]').attr('content')
-        ),
-        doQueueWithProxy: function(data) {
-            that.keepProxy = data.items[0];
-            if (!that.keepProxy) {
+        }
+        function doQueueWithProxy(data) {
+            keepProxy = data.items[0];
+            if (!keepProxy) {
                 that.state = 'Failed';
                 that.stateReason =
                     'There seems to be no Keep proxy service available.';
-                that._deferred.reject(null, 'error', that.stateReason);
+                _deferred.reject(null, 'error', that.stateReason);
                 return;
             }
-            return that.doQueueWork();
-        },
-        doQueueWork: function() {
+            return doQueueWork();
+        }
+        function doQueueWork() {
             var i;
             that.state = 'Running';
             that.stateReason = null;
@@ -334,17 +335,17 @@ function QueueUploader($scope, $q, $timeout) {
             if ($scope.uploadQueue.length > 0 &&
                 $scope.uploadQueue[0].state != 'Done') {
                 return $scope.uploadQueue[0].go().then(
-                    that.appendToCollection,
-                    that.onQueueReject,
-                    that.onQueueProgress
+                    appendToCollection,
+                    onQueueReject,
+                    onQueueProgress
                 ).then(
-                    that.doQueueWork,
-                    that.onQueueReject);
+                    doQueueWork,
+                    onQueueReject);
             }
             // If everything is done, resolve the promise and clean up.
-            return that.onQueueResolve();
-        },
-        onQueueReject: function(reason) {
+            return onQueueResolve();
+        }
+        function onQueueReject(reason) {
             that.state = 'Failed';
             that.stateReason = (
                 (reason.textStatus || 'Error') +
@@ -355,22 +356,22 @@ function QueueUploader($scope, $q, $timeout) {
                     (reason.err || ''));
             if (reason.xhr && reason.xhr.responseText)
                 that.stateReason += ' -- ' + reason.xhr.responseText;
-            that._deferred.reject(reason);
-            that.onQueueProgress();
-        },
-        onQueueResolve: function() {
+            _deferred.reject(reason);
+            onQueueProgress();
+        }
+        function onQueueResolve() {
             that.state = 'Idle';
             that.stateReason = 'Done!';
-            that._deferred.resolve();
-            that.onQueueProgress();
-        },
-        onQueueProgress: function() {
+            _deferred.resolve();
+            onQueueProgress();
+        }
+        function onQueueProgress() {
             // Ensure updates happen after FileUpload promise callbacks.
             $timeout(function(){$scope.$apply();});
-        },
-        appendToCollection: function(uploads) {
+        }
+        function appendToCollection(uploads) {
             var deferred = $q.defer();
-            return $scope.uploader.arvados.apiPromise(
+            return ArvadosClient.apiPromise(
                 'collections', 'get',
                 { uuid: $scope.uuid }).
                 then(function(collection) {
@@ -378,7 +379,7 @@ function QueueUploader($scope, $q, $timeout) {
                     var upload, i;
                     for (i=0; i<uploads.length; i++) {
                         upload = uploads[i];
-                        filename = $scope.uploader.arvados.uniqueNameForManifest(
+                        filename = ArvadosClient.uniqueNameForManifest(
                             collection.manifest_text,
                             '.', upload.file.name);
                         collection.manifest_text += '. ' +
@@ -387,7 +388,7 @@ function QueueUploader($scope, $q, $timeout) {
                             filename +
                             '\n';
                     }
-                    return $scope.uploader.arvados.apiPromise(
+                    return ArvadosClient.apiPromise(
                         'collections', 'update',
                         { uuid: $scope.uuid,
                           collection:
@@ -395,7 +396,7 @@ function QueueUploader($scope, $q, $timeout) {
                             collection.manifest_text }
                         }).
                         then(deferred.resolve);
-                }, that.onUploaderReject).then(function() {
+                }, onQueueReject).then(function() {
                     var i;
                     for(i=0; i<uploads.length; i++) {
                         uploads[i].committed = true;
@@ -403,5 +404,5 @@ function QueueUploader($scope, $q, $timeout) {
                 });
             return deferred;
         }
-    });
+    }
 }
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb
index 91c3c55..cdc47c1 100644
--- a/apps/workbench/app/views/layouts/application.html.erb
+++ b/apps/workbench/app/views/layouts/application.html.erb
@@ -17,14 +17,14 @@
   <% 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 %>
-  <meta name="arvados-api-token" content="<%=Thread.current[:arvados_api_token]%>">
-  <meta name="arvados-discovery-uri" content="<%= Rails.configuration.arvados_v1_base.sub '/arvados/v1', '/discovery/v1/apis/arvados/v1/rest' %>">
   <meta name="robots" content="NOINDEX, NOFOLLOW">
   <%= stylesheet_link_tag    "application", :media => "all" %>
   <%= javascript_include_tag "application" %>
   <%= csrf_meta_tags %>
   <%= yield :head %>
   <%= javascript_tag do %>
+    angular.module('Arvados').value('arvadosApiToken', '<%=Thread.current[:arvados_api_token]%>');
+    angular.module('Arvados').value('arvadosDiscoveryUri', '<%= Rails.configuration.arvados_v1_base.sub '/arvados/v1', '/discovery/v1/apis/arvados/v1/rest' %>');
   <%= yield :js %>
   <% end %>
   <style>

commit 23f6cfa6ed5686a79f7945cf8a2cb9ef46cf7d46
Author: Tom Clegg <tom at curoverse.com>
Date:   Tue Nov 25 01:55:25 2014 -0500

    3781: Use ensure_unique_name.

diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb
index aac698c..58d8f4e 100644
--- a/apps/workbench/app/views/projects/show.html.erb
+++ b/apps/workbench/app/views/projects/show.html.erb
@@ -23,7 +23,7 @@
           <% end %>
         </li>
         <li>
-          <%= link_to(collections_path(collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'), {
+          <%= link_to(collections_path(options: {ensure_unique_name: true}, collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'), {
               method: 'post',
               title: "Upload files into a new collection",
               data: {toggle: 'dropdown'}}) do %>

commit 90b59fc16bae23485dbdcb8af3009c2efc03e6d6
Author: Tom Clegg <tom at curoverse.com>
Date:   Mon Nov 24 19:29:24 2014 -0500

    3781: Update existing tests for new UI.

diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb
index 3d9c634..aac698c 100644
--- a/apps/workbench/app/views/projects/show.html.erb
+++ b/apps/workbench/app/views/projects/show.html.erb
@@ -23,8 +23,10 @@
           <% end %>
         </li>
         <li>
-          <%= link_to(collections_path(collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'),
-              { method: 'post', title: "Add data to this project", data: {'event-after-select' => 'page-refresh', 'toggle' => 'dropdown'} }) do %>
+          <%= link_to(collections_path(collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'), {
+              method: 'post',
+              title: "Upload files into a new collection",
+              data: {toggle: 'dropdown'}}) do %>
             <i class="fa fa-fw fa-upload"></i> ...from your computer
           <% end %>
         </li>
diff --git a/apps/workbench/test/integration/pipeline_instances_test.rb b/apps/workbench/test/integration/pipeline_instances_test.rb
index 7e3696c..1669376 100644
--- a/apps/workbench/test/integration/pipeline_instances_test.rb
+++ b/apps/workbench/test/integration/pipeline_instances_test.rb
@@ -34,10 +34,11 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     find("#projects-menu").click
     find('.dropdown-menu a,button', text: 'A Project').click
     find('.btn', text: 'Add data').click
+    find('.dropdown-menu a,button', text: '...from a different project').click
     within('.modal-dialog') do
       wait_for_ajax
       first('span', text: 'foo_tag').click
-      find('.btn', text: 'Add').click
+      find('.btn', text: 'Copy').click
     end
     using_wait_time(Capybara.default_wait_time * 3) do
       wait_for_ajax
@@ -124,10 +125,11 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     find("#projects-menu").click
     find('.dropdown-menu a,button', text: 'A Project').click
     find('.btn', text: 'Add data').click
+    find('.dropdown-menu a,button', text: '...from a different project').click
     within('.modal-dialog') do
       wait_for_ajax
       first('span', text: 'foo_tag').click
-      find('.btn', text: 'Add').click
+      find('.btn', text: 'Copy').click
     end
     using_wait_time(Capybara.default_wait_time * 3) do
       wait_for_ajax

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list