[ARVADOS] created: d2e9fba1946ead6c059d3df3cf34139563f2db60

git at public.curoverse.com git at public.curoverse.com
Sun Nov 23 18:35:17 EST 2014


        at  d2e9fba1946ead6c059d3df3cf34139563f2db60 (commit)


commit d2e9fba1946ead6c059d3df3cf34139563f2db60
Author: Tom Clegg <tom at curoverse.com>
Date:   Sun Nov 23 18:35:15 2014 -0500

    3781: Clean up pause/resume.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index 33d11f2..bbe76eb 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -1,12 +1,12 @@
 var app = angular.module('Workbench', []);
 app.controller(
     'UploadController',
-    ['$scope', 'numberFilter', 'dateFilter', UploadController]);
+    ['$scope', '$q', '$timeout', 'numberFilter', 'dateFilter', UploadController]);
 
-function UploadController($scope, numberFilter, dateFilter) {
+function UploadController($scope, $q, $timeout, numberFilter, dateFilter) {
     $.extend($scope, {
         uploadQueue: [],
-        uploader: new QueueUploader($scope),
+        uploader: new QueueUploader($scope, $q, $timeout),
         numberFilter: numberFilter,
         dateFilter: dateFilter,
         addFilesToQueue: function(files) {
@@ -16,16 +16,22 @@ function UploadController($scope, numberFilter, dateFilter) {
                 var i;
                 for (i=0; i<files.length; i++) {
                     $scope.uploadQueue.push(
-                        new FileUploader($scope, files[i]));
+                        new FileUploader($scope, $q, files[i]));
                 }
             });
-            $scope.uploader.go($scope).then(
-                $scope.sync,
-                $scope.sync,
-                $scope.sync);
         },
-        sync: function() {
-            $scope.$digest();
+        go: function() {
+            $scope.uploader.go();
+        },
+        stop: function() {
+            $scope.uploader.stop();
+        },
+        removeFileFromQueue: function(index) {
+            var wasRunning = $scope.uploader.running;
+            $scope.uploadQueue[index].stop();
+            $scope.uploadQueue.splice(index, 1);
+            if (wasRunning)
+                $scope.go();
         }
     });
     // TODO: watch uploadQueue, abort uploads if entries disappear
@@ -34,12 +40,9 @@ function UploadController($scope, numberFilter, dateFilter) {
 function SliceReader(uploader, slice) {
     var that = this;
     $.extend(this, {
-        _deferred: null,
-        _failCount: 0,
-        _reader: null,
-        _slice: slice,
-        _uploader: uploader,
         go: function() {
+            // Return a promise, which will be resolved with the
+            // requested slice data.
             this._deferred = $.Deferred();
             this._reader = new FileReader();
             this._reader.onload = this.resolve;
@@ -48,6 +51,11 @@ function SliceReader(uploader, slice) {
             this._reader.readAsArrayBuffer(this._slice.blob);
             return this._deferred.promise();
         },
+        ////////////////////////////////
+        _deferred: null,
+        _reader: null,
+        _slice: slice,
+        _uploader: uploader,
         resolve: function() {
             if (that._reader.result.length != that._slice.size) {
                 // Sometimes we get an onload event even if the read
@@ -63,21 +71,14 @@ function SliceReader(uploader, slice) {
     });
 }
 
-function SliceUploader(uploader) {
+function SliceUploader(uploader, label, data, dataSize) {
     var that = this;
     $.extend(this, {
-        _data: null,
-        _dataSize: null,
-        _deferred: null,
-        _jqxhr: null,
-        _uploader: uploader,
-        go: function(data, dataSize) {
+        go: function() {
             // 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._data = data;
-            this._dataSize = dataSize;
             this._jqxhr = $.ajax({
                 url: this.proxyUriBase(),
                 type: 'POST',
@@ -96,25 +97,37 @@ function SliceUploader(uploader) {
                     return xhr;
                 },
                 processData: false,
-                data: data,
-                context: this
+                data: that._data
             });
-            return (this._deferred = this._jqxhr.then(this.doneSendSlice));
+            return this._jqxhr.then(this.doneSendSlice, this.failSendSlice);
         },
+        stop: function() {
+            this._failMax = 0;
+            this._jqxhr.abort();
+        },
+        _data: data,
+        _dataSize: dataSize,
+        _failCount: 0,
+        _failMax: 3,
+        _label: label,
+        _jqxhr: null,
+        _uploader: uploader,
         progressSendSlice: function(x,y,z) {
             console.log(['uploadProgress',x,y,z]);
-            that._deferred.notify(50,100);
+            that._jqxhr.notify(50, that._dataSize);
         },
         doneSendSlice: function(data, textStatus, jqxhr) {
             return $.Deferred().resolve(data, that._dataSize).promise();
         },
         failSendSlice: function(xhr, textStatus, err) {
-            console.log([xhr, textStatus, err]);
-            if (++that._failCount <= 3) {
-                console.log("Error (" + textStatus + "), retrying slice at " +
-                           that.bytesDone);
-                return that.go(that._data, that._dataSize);
+            if (++that._failCount <= that._failMax) {
+                // TODO: nice to tell the user that retry is happening.
+                console.log('slice ' + that._label + ': ' +
+                            textStatus + ', retry ' + that._failCount);
+                return that.go();
             }
+            // Can't propagate multiple arguments with "return a,b,c"
+            // so we do this indirectly by returning a new promise.
             return $.Deferred().reject(xhr, textStatus, err).promise();
         },
         proxyUriBase: function() {
@@ -126,55 +139,77 @@ function SliceUploader(uploader) {
     });
 }
 
-function FileUploader($scope, file) {
+function FileUploader($scope, $q, file) {
     var that = this;
     $.extend(this, {
+        state: 'Queued',        // Queued, Uploading, Paused, Done
+        progress: 0.0,
+        statistics: null,
+        file: file,
+        go: function() {
+            if (this._deferred)
+                this._deferred.reject(null, 'Restarted', null);
+            this._deferred = $q.defer();
+            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(null, 'Interrupted', null);
+            }
+            if (this._currentUploader) {
+                this._currentUploader.stop();
+                this._currentUploader = null;
+            }
+        },
         _currentSlice: null,
         _deferred: null,
         _locators: [],
         $scope: $scope,
         uploader: $scope.uploader,
-        file: file,
         maxBlobSize: Math.pow(2,26),
         bytesDone: 0,
         queueTime: Date.now(),
         startTime: null,
         startByte: null,
-        _readPos: 0,
-        done: false,
-        state: 'Queued',
-        progress: 0.0,
-        statistics: null,
-        go: function() {
-            this.state = 'Uploading';
-            this.startTime = Date.now();
-            this.startByte = this._readPos;
-            if (this._deferred)
-                this._deferred.reject(null, "Cancelled", "Restarted");
-            this._deferred = $.Deferred();
-            this.goSlice();
-            return this._deferred.promise();
-        },
+        finishTime: null,
+        _readPos: 0,            // number of bytes confirmed uploaded
         goSlice: function() {
             // 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('Done');
                 return;
             }
-            (new SliceUploader(this.uploader)).
-                go(this._currentSlice.blob, this._currentSlice.size).
-                then(this.onUploaderSuccess,
-                     this._deferred.reject,
-                     this.onUploaderProgress);
+            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);
         },
-        onUploaderSuccess: function(locator, dataSize) {
+        onUploaderResolve: function(locator, dataSize) {
             // TODO: check that._currentSlice.size == dataSize
             that._locators.push(locator);
+            that._readPos += dataSize;
             that.goSlice();
         },
+        onUploaderReject: function(xhr, status, err) {
+            that.state = 'Paused';
+            that.setProgress(that._readPos);
+            that._deferred.reject(xhr, status, err);
+        },
         onUploaderProgress: function(sliceDone, sliceSize) {
             that.setProgress(that._readPos - sliceSize + sliceDone);
             console.log("upload progress: " + that.progress);
@@ -185,36 +220,36 @@ function FileUploader($scope, file) {
                 this.file.size - this._readPos);
             this.setProgress(this._readPos);
             if (size == 0) {
-                this.state = 'Done';
                 return false;
             }
             var blob = this.file.slice(
-                this._readPos,
-                this._readPos + size,
+                this._readPos, this._readPos+size,
                 'application/octet-stream; charset=x-user-defined');
-            this._readPos += size;
             return {blob: blob, size: size};
         },
         setProgress: function(bytesDone) {
             var kBps;
             this.progress = Math.min(100, 100 * bytesDone / this.file.size)
             if (bytesDone <= this.startByte) {
-                this.statistics = '';
+                this.statistics = 'Starting';
             } else {
                 kBps = (bytesDone - this.startByte) /
                     (Date.now() - this.startTime);
                 this.statistics = (
-                    '' + this.$scope.numberFilter(bytesDone/1024, '0') + ' KB ' +
-                        'at ~' + this.$scope.numberFilter(kBps, '0') + ' KB/s')
-                if (bytesDone < this.file.size) {
-                    this.statistics += ' -- ETA ' +
+                    '' + this.$scope.numberFilter(bytesDone/1024, '0') + 'K ' +
+                        'at ~' + this.$scope.numberFilter(kBps, '0') + 'K/s')
+                if (this.state == 'Paused') {
+                    // That's all I have to say about that.
+                } else if (bytesDone < this.file.size) {
+                    this.statistics += ', ETA ' +
                         this.$scope.dateFilter(
                             new Date(
                                 Date.now() + (this.file.size - bytesDone) / kBps),
                             'shortTime')
                 } else {
-                    this.statistics += ' -- finished @ ' +
+                    this.statistics += ', finished ' +
                         this.$scope.dateFilter(Date.now(), 'shortTime');
+                    this.finishTime = Date.now();
                 }
             }
             console.log(this.progress);
@@ -223,61 +258,77 @@ function FileUploader($scope, file) {
     });
 }
 
-function QueueUploader($scope) {
+function QueueUploader($scope, $q, $timeout) {
     var that = this;
     $.extend(this, {
-        _running: false,
-        statusError: null,
+        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(
+                'keep_services', 'list',
+                {filters: [['service_type','=','proxy']]}).
+                then(this.doQueueWithProxy);
+            this.reportUploadProgress();
+            return this._deferred.promise();
+        },
+        stop: function() {
+            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')
         ),
-        go: function($scope) {
-            if (this._running) return $.when(true,true);
-            this._running = true;
-            return this.arvados.apiPromise(
-                'keep_services', 'list',
-                {filters: [['service_type','=','proxy']]}).
-                then(this.doQueueWithProxy).
-                then(this.reportUploadSuccess, this.reportUploadError).
-                always(function(){ that._running = false; });
-        },
         doQueueWithProxy: function(data) {
             that.keepProxy = data.items[0];
+            if (!that.keepProxy) {
+                that.state = 'Failed';
+                that.stateReason =
+                    'There seems to be no Keep proxy service available.';
+                that._deferred.reject();
+                return;
+            }
             return that.doQueueWork();
         },
         doQueueWork: function() {
-            that.statusError = null;
-            that.statusSuccess = null;
-            if (!that.keepProxy) {
-                return $.Deferred().
-                    reject(null, null, "Sorry, there seems to be no Keep proxy service available.").
-                    promise();
-            }
+            that.state = 'Running';
+            that.stateReason = null;
             for (var i=0; i<$scope.uploadQueue.length; i++) {
-                if ($scope.uploadQueue[i].state == 'Queued') {
-                    return $scope.uploadQueue[i].
-                        go().
-                        then(
-                            that.doQueueWork,
-                            that.reportUploadError);
+                if ($scope.uploadQueue[i].state != 'Done') {
+                    return $scope.uploadQueue[i].go().then(
+                        that.doQueueWork,
+                        that.reportUploadError,
+                        that.reportUploadProgress);
                 }
             }
-            return $.Deferred().resolve("Done!").promise();
+            that.reportUploadSuccess("Done!");
         },
         reportUploadError: function(jqxhr, textStatus, err) {
-            console.log(["error", jqxhr,textStatus,err]);
-            that.statusError = (
+            that.state = 'Failed';
+            that.stateReason = (
                 (textStatus || 'Error') +
                     (jqxhr && jqxhr.options
                      ? (' (from ' + jqxhr.options.url + ')')
                      : '') +
                     ': ' +
                     (err || '(no further details available, sorry!)'));
+            that._deferred.reject();
+            that.reportUploadProgress();
         },
         reportUploadSuccess: function(message) {
-            that.statusSuccess = message;
+            that.state = 'Idle';
+            that.stateReason = message;
+            that._deferred.resolve();
+            that.reportUploadProgress();
+        },
+        reportUploadProgress: function() {
+            // Ensure updates happen after FileUpload promise callbacks.
+            $timeout(function(){$scope.$apply();});
         }
     });
 }
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
index ea31c84..1b7ad96 100644
--- a/apps/workbench/app/views/collections/_show_files.html.erb
+++ b/apps/workbench/app/views/collections/_show_files.html.erb
@@ -68,24 +68,39 @@ function unselect_all_files() {
     <div class="panel-body" ng-controller="UploadController">
       <div class="panel panel-primary">
         <div class="panel-body">
-          <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');" />
+          <div class="row">
+            <div class="col-sm-6">
+              <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');" />
+            </div>
+            <div class="col-sm-6">
+              <div class="btn-group btn-group-sm pull-right" role="group">
+                <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>
+            </div>
+          </div>
         </div>
       </div>
       <div ng-show="uploader.statusSuccess" class="alert alert-success"><i class="fa fa-flag-checkered"></i> {{uploader.statusSuccess}}</div>
       <div ng-show="uploader.statusError" class="alert alert-danger"><i class="fa fa-warning"></i> {{uploader.statusError}}</div>
       <div ng-repeat="upload in uploadQueue" class="row">
-        <div class="col-sm-2">
-          <div class="progress">
-            <span class="progress-bar" style="width: {{upload.progress}}%"></span>
-          </div>
+        <div class="col-sm-1">
+          <button class="btn btn-xs btn-default" ng-click="removeFileFromQueue($index)" title="cancel" ng-show="upload.state != 'Done'"><i class="fa fa-fw fa-trash-o"></i></button>
+        </div>
+        <div class="col-sm-4 nowrap" style="overflow-x:hidden;text-overflow:ellipsis">
+          <span title="{{upload.file.name}}">
+            {{upload.file.name}}
+          </span>
         </div>
         <div class="col-sm-1" style="text-align: right">
           {{upload.file.size/1024 | number:0}}K
         </div>
-        <div class="col-sm-4">
-          {{upload.file.name}}
+        <div class="col-sm-2">
+          <div class="progress">
+            <span class="progress-bar" style="width: {{upload.progress}}%"></span>
+          </div>
         </div>
-        <div class="col-sm-5" ng-class="{lighten: upload.state != 'Uploading'}">
+        <div class="col-sm-4" ng-class="{lighten: upload.state != 'Uploading'}">
           {{upload.statistics}}
         </div>
       </div>

commit a75145983529310528a6cf0a54d78f85e9101eb6
Author: Tom Clegg <tom at curoverse.com>
Date:   Sun Nov 23 05:07:15 2014 -0500

    3781: Really work the angular queue.

diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index 1c4df0f..33d11f2 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -1,30 +1,35 @@
 var app = angular.module('Workbench', []);
-app.controller('UploadToCollection', ['$scope', function($scope) {
+app.controller(
+    'UploadController',
+    ['$scope', 'numberFilter', 'dateFilter', UploadController]);
+
+function UploadController($scope, numberFilter, dateFilter) {
     $.extend($scope, {
         uploadQueue: [],
+        uploader: new QueueUploader($scope),
+        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.
             $scope.$apply(function(){
                 var i;
                 for (i=0; i<files.length; i++) {
-                    $scope.uploadQueue.push({
-                        file: files[i]
-                    });
+                    $scope.uploadQueue.push(
+                        new FileUploader($scope, files[i]));
                 }
             });
+            $scope.uploader.go($scope).then(
+                $scope.sync,
+                $scope.sync,
+                $scope.sync);
         },
-        statusSuccess: null,
-        statusError: null
+        sync: function() {
+            $scope.$digest();
+        }
     });
-}]);
-
-$(document).on('ready ajax:success', function() {
-    $('.arv-upload-to-collection').
-        not('.arv-upload-setup').
-        addClass('.arv-upload-setup').
-        each(function() { new QueueUploader($(this)) });
-});
+    // TODO: watch uploadQueue, abort uploads if entries disappear
+}
 
 function SliceReader(uploader, slice) {
     var that = this;
@@ -63,6 +68,7 @@ function SliceUploader(uploader) {
     $.extend(this, {
         _data: null,
         _dataSize: null,
+        _deferred: null,
         _jqxhr: null,
         _uploader: uploader,
         go: function(data, dataSize) {
@@ -93,10 +99,11 @@ function SliceUploader(uploader) {
                 data: data,
                 context: this
             });
-            return this._jqxhr.then(this.doneSendSlice);
+            return (this._deferred = this._jqxhr.then(this.doneSendSlice));
         },
         progressSendSlice: function(x,y,z) {
             console.log(['uploadProgress',x,y,z]);
+            that._deferred.notify(50,100);
         },
         doneSendSlice: function(data, textStatus, jqxhr) {
             return $.Deferred().resolve(data, that._dataSize).promise();
@@ -119,38 +126,66 @@ function SliceUploader(uploader) {
     });
 }
 
-function FileUploader(uploader, file) {
+function FileUploader($scope, file) {
     var that = this;
     $.extend(this, {
         _currentSlice: null,
+        _deferred: null,
         _locators: [],
-        uploader: uploader,
+        $scope: $scope,
+        uploader: $scope.uploader,
         file: file,
-        maxBlobSize: 65536,
+        maxBlobSize: Math.pow(2,26),
         bytesDone: 0,
         queueTime: Date.now(),
         startTime: null,
+        startByte: null,
         _readPos: 0,
         done: false,
+        state: 'Queued',
+        progress: 0.0,
+        statistics: null,
         go: function() {
+            this.state = 'Uploading';
+            this.startTime = Date.now();
+            this.startByte = this._readPos;
+            if (this._deferred)
+                this._deferred.reject(null, "Cancelled", "Restarted");
+            this._deferred = $.Deferred();
+            this.goSlice();
+            return this._deferred.promise();
+        },
+        goSlice: function() {
+            // 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) {
-                return $.when(true, true);
+                this._deferred.resolve('Done');
+                return;
             }
-            return (new SliceUploader(this.uploader)).
+            (new SliceUploader(this.uploader)).
                 go(this._currentSlice.blob, this._currentSlice.size).
-                then(this.onUploaderSuccess);
+                then(this.onUploaderSuccess,
+                     this._deferred.reject,
+                     this.onUploaderProgress);
         },
         onUploaderSuccess: function(locator, dataSize) {
+            // TODO: check that._currentSlice.size == dataSize
             that._locators.push(locator);
-            return that.go();
+            that.goSlice();
+        },
+        onUploaderProgress: function(sliceDone, sliceSize) {
+            that.setProgress(that._readPos - sliceSize + sliceDone);
+            console.log("upload progress: " + that.progress);
         },
         nextSlice: function() {
             var size = Math.min(
                 this.maxBlobSize,
                 this.file.size - this._readPos);
+            this.setProgress(this._readPos);
             if (size == 0) {
-                this.done = true;
+                this.state = 'Done';
                 return false;
             }
             var blob = this.file.slice(
@@ -159,71 +194,90 @@ function FileUploader(uploader, file) {
                 'application/octet-stream; charset=x-user-defined');
             this._readPos += size;
             return {blob: blob, size: size};
+        },
+        setProgress: function(bytesDone) {
+            var kBps;
+            this.progress = Math.min(100, 100 * bytesDone / this.file.size)
+            if (bytesDone <= this.startByte) {
+                this.statistics = '';
+            } else {
+                kBps = (bytesDone - this.startByte) /
+                    (Date.now() - this.startTime);
+                this.statistics = (
+                    '' + this.$scope.numberFilter(bytesDone/1024, '0') + ' KB ' +
+                        'at ~' + this.$scope.numberFilter(kBps, '0') + ' KB/s')
+                if (bytesDone < this.file.size) {
+                    this.statistics += ' -- ETA ' +
+                        this.$scope.dateFilter(
+                            new Date(
+                                Date.now() + (this.file.size - bytesDone) / kBps),
+                            'shortTime')
+                } else {
+                    this.statistics += ' -- finished @ ' +
+                        this.$scope.dateFilter(Date.now(), 'shortTime');
+                }
+            }
+            console.log(this.progress);
+            this._deferred.notify();
         }
     });
 }
 
-function QueueUploader(workarea) {
+function QueueUploader($scope) {
     var that = this;
-    $('.arv-upload-start', workarea).on('change', function(event){
-        that.queueFilesFromInput(event);
-    });
     $.extend(this, {
-        workarea: workarea,
-        queue: [],
+        _running: false,
+        statusError: null,
+        statusSuccess: null,
         arvados: new ArvadosClient(
-            $('[data-discovery-uri]').data('discovery-uri'),
+            $('meta[name=arvados-discovery-uri]').attr('content'),
             $('meta[name=arvados-api-token]').attr('content')
         ),
-        queueFilesFromInput: function(event) {
-            var i, f;
-            for (i=0; i<event.target.files.length; i++) {
-                f = event.target.files[i];
-                console.log("Queueing file: " + f.name + " (" + f.type + ")");
-                this.queue.push(new FileUploader(this, f));
-            }
-            this.arvados.apiPromise(
+        go: function($scope) {
+            if (this._running) return $.when(true,true);
+            this._running = true;
+            return this.arvados.apiPromise(
                 'keep_services', 'list',
                 {filters: [['service_type','=','proxy']]}).
                 then(this.doQueueWithProxy).
-                then(this.reportUploadSuccess, this.reportUploadError);
+                then(this.reportUploadSuccess, this.reportUploadError).
+                always(function(){ that._running = false; });
         },
         doQueueWithProxy: function(data) {
             that.keepProxy = data.items[0];
             return that.doQueueWork();
         },
         doQueueWork: function() {
-            $('.alert-danger,.alert-success', that.workarea).hide();
+            that.statusError = null;
+            that.statusSuccess = null;
             if (!that.keepProxy) {
                 return $.Deferred().
                     reject(null, null, "Sorry, there seems to be no Keep proxy service available.").
                     promise();
             }
-            while (that.queue.length > 0 && that.queue[0].done) {
-                that.queue.shift();
-            }
-            if (that.queue.length === 0) {
-                return $.Deferred().resolve("Done!").promise();
-            } else {
-                return that.queue[0].go().then(
-                    that.doQueueWork,
-                    that.reportUploadError);
+            for (var i=0; i<$scope.uploadQueue.length; i++) {
+                if ($scope.uploadQueue[i].state == 'Queued') {
+                    return $scope.uploadQueue[i].
+                        go().
+                        then(
+                            that.doQueueWork,
+                            that.reportUploadError);
+                }
             }
+            return $.Deferred().resolve("Done!").promise();
         },
         reportUploadError: function(jqxhr, textStatus, err) {
             console.log(["error", jqxhr,textStatus,err]);
-            $('.alert-danger', that.workarea).show().find('.content').
-                html((textStatus || 'Error') +
-                     (jqxhr && jqxhr.options
-                      ? (' (from ' + jqxhr.options.url + ')')
-                      : '') +
-                     ': ' +
-                     (err || '(no further details available, sorry!)'));
+            that.statusError = (
+                (textStatus || 'Error') +
+                    (jqxhr && jqxhr.options
+                     ? (' (from ' + jqxhr.options.url + ')')
+                     : '') +
+                    ': ' +
+                    (err || '(no further details available, sorry!)'));
         },
         reportUploadSuccess: function(message) {
-            console.log(message);
-            $('.alert-success', that.workarea).show().find('.content').
-                html(message);
+            that.statusSuccess = message;
         }
     });
 }
diff --git a/apps/workbench/app/assets/stylesheets/application.css.scss b/apps/workbench/app/assets/stylesheets/application.css.scss
index 7dbeac9..8b5580f 100644
--- a/apps/workbench/app/assets/stylesheets/application.css.scss
+++ b/apps/workbench/app/assets/stylesheets/application.css.scss
@@ -47,6 +47,9 @@ table.table-justforlayout {
     font-size: .8em;
     color: #888;
 }
+.lighten {
+    color: #888;
+}
 .arvados-filename,
 .arvados-uuid {
     font-size: .8em;
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
index e25f29a..ea31c84 100644
--- a/apps/workbench/app/views/collections/_show_files.html.erb
+++ b/apps/workbench/app/views/collections/_show_files.html.erb
@@ -63,16 +63,16 @@ function unselect_all_files() {
 <% file_tree = @object.andand.files_tree %>
 <% if file_tree.nil? or file_tree.empty? %>
   <p>This collection is empty.</p>
-  <div ng-app="Workbench" data-discovery-uri="<%= Rails.configuration.arvados_v1_base.sub('/arvados/v1', '') %>/discovery/v1/apis/arvados/v1/rest" class="panel panel-info">
+  <div ng-app="Workbench" class="panel panel-info">
     <div class="panel-heading"><h3 class="panel-title">Upload files</h3></div>
-    <div class="panel-body" ng-controller="UploadToCollection">
+    <div class="panel-body" ng-controller="UploadController">
       <div class="panel panel-primary">
         <div class="panel-body">
           <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');" />
         </div>
       </div>
-      <div ng-show="statusSuccess" class="alert alert-success"><i class="fa fa-flag-checkered"></i> {{statusSuccess}}</div>
-      <div ng-show="statusError" class="alert alert-danger"><i class="fa fa-warning"></i> {{statusError}}</div>
+      <div ng-show="uploader.statusSuccess" class="alert alert-success"><i class="fa fa-flag-checkered"></i> {{uploader.statusSuccess}}</div>
+      <div ng-show="uploader.statusError" class="alert alert-danger"><i class="fa fa-warning"></i> {{uploader.statusError}}</div>
       <div ng-repeat="upload in uploadQueue" class="row">
         <div class="col-sm-2">
           <div class="progress">
@@ -85,8 +85,8 @@ function unselect_all_files() {
         <div class="col-sm-4">
           {{upload.file.name}}
         </div>
-        <div class="col-sm-5">
-          {{upload.state}}
+        <div class="col-sm-5" ng-class="{lighten: upload.state != 'Uploading'}">
+          {{upload.statistics}}
         </div>
       </div>
     </div>
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb
index 3fff432..6fab360 100644
--- a/apps/workbench/app/views/layouts/application.html.erb
+++ b/apps/workbench/app/views/layouts/application.html.erb
@@ -18,6 +18,7 @@
   <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" %>

commit bfe5b9d98f73532231f8a2d4c0f8906e28d536f0
Author: Tom Clegg <tom at curoverse.com>
Date:   Sun Nov 23 03:24:10 2014 -0500

    3781: Build and show upload queue with angular.

diff --git a/apps/workbench/Gemfile b/apps/workbench/Gemfile
index 5ab6eac..365cefc 100644
--- a/apps/workbench/Gemfile
+++ b/apps/workbench/Gemfile
@@ -56,6 +56,8 @@ gem 'bootstrap-sass', '~> 3.1.0'
 gem 'bootstrap-x-editable-rails'
 gem 'bootstrap-tab-history-rails'
 
+gem 'angularjs-rails'
+
 gem 'less'
 gem 'less-rails'
 gem 'wiselinks'
diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock
index 8b9ea94..8c18847 100644
--- a/apps/workbench/Gemfile.lock
+++ b/apps/workbench/Gemfile.lock
@@ -38,6 +38,7 @@ GEM
       tzinfo (~> 1.1)
     addressable (2.3.6)
     andand (1.3.3)
+    angularjs-rails (1.3.3)
     arel (5.0.1.20140414130214)
     arvados (0.1.20141114230720)
       activesupport (>= 3.2.13)
@@ -242,6 +243,7 @@ PLATFORMS
 DEPENDENCIES
   RedCloth
   andand
+  angularjs-rails
   arvados (>= 0.1.20141114230720)
   bootstrap-sass (~> 3.1.0)
   bootstrap-tab-history-rails
diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js
index 7737eee..6b98fd9 100644
--- a/apps/workbench/app/assets/javascripts/application.js
+++ b/apps/workbench/app/assets/javascripts/application.js
@@ -23,6 +23,7 @@
 //= require bootstrap3-editable/bootstrap-editable
 //= require bootstrap-tab-history
 //= require wiselinks
+//= require angular
 //= require_tree .
 
 jQuery(function($){
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index 767c38e..1c4df0f 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -1,3 +1,24 @@
+var app = angular.module('Workbench', []);
+app.controller('UploadToCollection', ['$scope', function($scope) {
+    $.extend($scope, {
+        uploadQueue: [],
+        addFilesToQueue: function(files) {
+            // Angular binding doesn't work its usual magic for file
+            // inputs, so we need to $scope.$apply() this update.
+            $scope.$apply(function(){
+                var i;
+                for (i=0; i<files.length; i++) {
+                    $scope.uploadQueue.push({
+                        file: files[i]
+                    });
+                }
+            });
+        },
+        statusSuccess: null,
+        statusError: null
+    });
+}]);
+
 $(document).on('ready ajax:success', function() {
     $('.arv-upload-to-collection').
         not('.arv-upload-setup').
@@ -151,7 +172,7 @@ function QueueUploader(workarea) {
         workarea: workarea,
         queue: [],
         arvados: new ArvadosClient(
-            workarea.data('discovery-uri'),
+            $('[data-discovery-uri]').data('discovery-uri'),
             $('meta[name=arvados-api-token]').attr('content')
         ),
         queueFilesFromInput: function(event) {
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
index 135c5c0..e25f29a 100644
--- a/apps/workbench/app/views/collections/_show_files.html.erb
+++ b/apps/workbench/app/views/collections/_show_files.html.erb
@@ -63,12 +63,33 @@ function unselect_all_files() {
 <% file_tree = @object.andand.files_tree %>
 <% if file_tree.nil? or file_tree.empty? %>
   <p>This collection is empty.</p>
-  <div class="arv-upload-to-collection" data-discovery-uri="<%= Rails.configuration.arvados_v1_base.sub('/arvados/v1', '') %>/discovery/v1/apis/arvados/v1/rest">
-    <form>
-      <input type="file" multiple class="arv-upload-start" />
-      <div style="display:none" class="alert alert-success"><i class="fa fa-flag-checkered"></i> <span class="content"></span></div>
-      <div style="display:none" class="alert alert-danger"><i class="fa fa-warning"></i> <span class="content"></span></div>
-    </form>
+  <div ng-app="Workbench" data-discovery-uri="<%= Rails.configuration.arvados_v1_base.sub('/arvados/v1', '') %>/discovery/v1/apis/arvados/v1/rest" class="panel panel-info">
+    <div class="panel-heading"><h3 class="panel-title">Upload files</h3></div>
+    <div class="panel-body" ng-controller="UploadToCollection">
+      <div class="panel panel-primary">
+        <div class="panel-body">
+          <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');" />
+        </div>
+      </div>
+      <div ng-show="statusSuccess" class="alert alert-success"><i class="fa fa-flag-checkered"></i> {{statusSuccess}}</div>
+      <div ng-show="statusError" class="alert alert-danger"><i class="fa fa-warning"></i> {{statusError}}</div>
+      <div ng-repeat="upload in uploadQueue" class="row">
+        <div class="col-sm-2">
+          <div class="progress">
+            <span class="progress-bar" style="width: {{upload.progress}}%"></span>
+          </div>
+        </div>
+        <div class="col-sm-1" style="text-align: right">
+          {{upload.file.size/1024 | number:0}}K
+        </div>
+        <div class="col-sm-4">
+          {{upload.file.name}}
+        </div>
+        <div class="col-sm-5">
+          {{upload.state}}
+        </div>
+      </div>
+    </div>
   </div>
 <% else %>
   <ul id="collection_files" class="collection_files <%=preview_selectable_container%>">

commit 1fc13b7710c858b88cdd15957202d3b43000f33f
Author: Tom Clegg <tom at curoverse.com>
Date:   Sun Nov 23 02:09:11 2014 -0500

    3781: Add allowed headers. Respond to OPTIONS at any path.

diff --git a/services/keepproxy/keepproxy.go b/services/keepproxy/keepproxy.go
index 620434f..e547efd 100644
--- a/services/keepproxy/keepproxy.go
+++ b/services/keepproxy/keepproxy.go
@@ -248,7 +248,7 @@ func MakeRESTRouter(
 		rest.Handle(`/{hash:[0-9a-f]{32}}+{hints}`, PutBlockHandler{kc, t}).Methods("PUT")
 		rest.Handle(`/{hash:[0-9a-f]{32}}`, PutBlockHandler{kc, t}).Methods("PUT")
 		rest.Handle(`/`, PutBlockHandler{kc, t}).Methods("POST")
-		rest.Handle(`/{hash:[0-9a-f]{32}}{ignore}`, OptionsHandler{}).Methods("OPTIONS")
+		rest.Handle(`/`, OptionsHandler{}).Methods("OPTIONS")
 	}
 
 	rest.NotFoundHandler = InvalidPathHandler{}
@@ -256,6 +256,13 @@ func MakeRESTRouter(
 	return rest
 }
 
+func SetCorsHeaders(resp http.ResponseWriter) {
+	resp.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
+	resp.Header().Set("Access-Control-Allow-Origin", "*")
+	resp.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Length, Content-Type, X-Keep-Desired-Replicas")
+	resp.Header().Set("Access-Control-Max-Age", "86486400")
+}
+
 func (this InvalidPathHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 	log.Printf("%s: %s %s unroutable", GetRemoteAddress(req), req.Method, req.URL.Path)
 	http.Error(resp, "Bad request", http.StatusBadRequest)
@@ -263,15 +270,11 @@ func (this InvalidPathHandler) ServeHTTP(resp http.ResponseWriter, req *http.Req
 
 func (this OptionsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 	log.Printf("%s: %s %s", GetRemoteAddress(req), req.Method, req.URL.Path)
-	resp.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
-	resp.Header().Set("Access-Control-Allow-Origin", "*")
-	resp.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Keep-Desired-Replicas")
-	resp.Header().Set("Access-Control-Max-Age", "86486400")
+	SetCorsHeaders(resp)
 }
 
 func (this GetBlockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
-	resp.Header().Set("Access-Control-Allow-Origin", "*")
-	resp.Header().Set("Access-Control-Allow-Headers", "Authorization")
+	SetCorsHeaders(resp)
 
 	kc := *this.KeepClient
 
@@ -335,6 +338,7 @@ func (this GetBlockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Reques
 }
 
 func (this PutBlockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+	SetCorsHeaders(resp)
 
 	kc := *this.KeepClient
 
diff --git a/services/keepproxy/keepproxy_test.go b/services/keepproxy/keepproxy_test.go
index 667a0b2..0ac843f 100644
--- a/services/keepproxy/keepproxy_test.go
+++ b/services/keepproxy/keepproxy_test.go
@@ -349,7 +349,7 @@ func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
 			fmt.Sprintf("http://localhost:29954/%x+3",
 				md5.Sum([]byte("foo"))))
 		c.Check(err, Equals, nil)
-		c.Check(resp.Header.Get("Access-Control-Allow-Headers"), Equals, "Authorization")
+		c.Check(resp.Header.Get("Access-Control-Allow-Headers"), Equals, "Authorization, Content-Length, Content-Type, X-Keep-Desired-Replicas")
 		c.Check(resp.Header.Get("Access-Control-Allow-Origin"), Equals, "*")
 	}
 }

commit 9d5164a06a3dbf2732a149f4e1b9945b942fb30f
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 16:03:36 2014 -0500

    3781: Upload blobs to proxy.

diff --git a/apps/workbench/app/assets/javascripts/arvados_client.js b/apps/workbench/app/assets/javascripts/arvados_client.js
index 6f4eed1..92db84f 100644
--- a/apps/workbench/app/assets/javascripts/arvados_client.js
+++ b/apps/workbench/app/assets/javascripts/arvados_client.js
@@ -1,9 +1,10 @@
-function arvadosClient(discoveryUri, apiToken) {
-    var self = {
+function ArvadosClient(discoveryUri, apiToken) {
+    $.extend(this, {
         apiToken: apiToken,
-        api: function(controller, action, params) {
-            return self.getDiscoveryDoc().then(function() {
-                meth = self.discoveryDoc.resources[controller].methods[action];
+        discoveryUri: discoveryUri,
+        apiPromise: function(controller, action, params) {
+            return this.getDiscoveryDoc().then(function() {
+                meth = this.discoveryDoc.resources[controller].methods[action];
                 data = $.extend({}, params, {_method: meth.httpMethod});
                 $.each(data, function(k, v) {
                     if (typeof(v) == 'object') {
@@ -11,27 +12,27 @@ function arvadosClient(discoveryUri, apiToken) {
                     }
                 });
                 return $.ajax({
-                    url: self.discoveryDoc.baseUrl + meth.path,
+                    url: this.discoveryDoc.baseUrl + meth.path,
                     type: 'POST',
                     crossDomain: true,
                     dataType: 'json',
                     data: data,
                     headers: {
-                        Authorization: 'OAuth2 ' + self.apiToken
+                        Authorization: 'OAuth2 ' + this.apiToken
                     }
                 });
             });
         },
         getDiscoveryDoc: function() {
-            if (self.promiseDiscovery) return self.promiseDiscovery;
-            self.promiseDiscovery = $.ajax({
-                url: discoveryUri,
-                crossDomain: true
+            if (this.promiseDiscovery) return this.promiseDiscovery;
+            this.promiseDiscovery = $.ajax({
+                url: this.discoveryUri,
+                crossDomain: true,
+                context: this
             }).then(function(data, status, xhr) {
-                self.discoveryDoc = data;
+                this.discoveryDoc = data;
             });
-            return self.promiseDiscovery;
+            return this.promiseDiscovery;
         }
-    };
-    return self;
+    });
 }
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
index aff6c91..767c38e 100644
--- a/apps/workbench/app/assets/javascripts/upload_to_collection.js
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -2,39 +2,207 @@ $(document).on('ready ajax:success', function() {
     $('.arv-upload-to-collection').
         not('.arv-upload-setup').
         addClass('.arv-upload-setup').
-        each(setupUploader);
+        each(function() { new QueueUploader($(this)) });
 });
 
-function setupUploader() {
-    var $workarea = $(this);
-    var self = {
-        $workarea: $workarea,
+function SliceReader(uploader, slice) {
+    var that = this;
+    $.extend(this, {
+        _deferred: null,
+        _failCount: 0,
+        _reader: null,
+        _slice: slice,
+        _uploader: uploader,
+        go: function() {
+            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();
+        },
+        resolve: function() {
+            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(
+                    null, "Read error",
+                    "Short read: wanted " + that._slice.size +
+                        ", received " + that._reader.result.length).promise();
+            }
+            return that._deferred.resolve(that._reader.result);
+        }
+    });
+}
+
+function SliceUploader(uploader) {
+    var that = this;
+    $.extend(this, {
+        _data: null,
+        _dataSize: null,
+        _jqxhr: null,
+        _uploader: uploader,
+        go: function(data, dataSize) {
+            // 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._data = data;
+            this._dataSize = dataSize;
+            this._jqxhr = $.ajax({
+                url: this.proxyUriBase(),
+                type: 'POST',
+                crossDomain: true,
+                headers: {
+                    'Authorization': 'OAuth2 '+this._uploader.arvados.apiToken,
+                    'Content-Type': 'application/octet-stream',
+                    'X-Keep-Desired-Replicas': '2'
+                },
+                xhr: function() {
+                    var xhr = $.ajaxSettings.xhr();
+                    if (xhr.upload) {
+                        xhr.upload.addEventListener(
+                            'progress', this.progressSendSlice);
+                    }
+                    return xhr;
+                },
+                processData: false,
+                data: data,
+                context: this
+            });
+            return this._jqxhr.then(this.doneSendSlice);
+        },
+        progressSendSlice: function(x,y,z) {
+            console.log(['uploadProgress',x,y,z]);
+        },
+        doneSendSlice: function(data, textStatus, jqxhr) {
+            return $.Deferred().resolve(data, that._dataSize).promise();
+        },
+        failSendSlice: function(xhr, textStatus, err) {
+            console.log([xhr, textStatus, err]);
+            if (++that._failCount <= 3) {
+                console.log("Error (" + textStatus + "), retrying slice at " +
+                           that.bytesDone);
+                return that.go(that._data, that._dataSize);
+            }
+            return $.Deferred().reject(xhr, textStatus, err).promise();
+        },
+        proxyUriBase: function() {
+            var proxy = this._uploader.keepProxy;
+            return ((proxy.service_ssl_flag ? 'https' : 'http') +
+                    '://' + proxy.service_host + ':' +
+                    proxy.service_port + '/');
+        }
+    });
+}
+
+function FileUploader(uploader, file) {
+    var that = this;
+    $.extend(this, {
+        _currentSlice: null,
+        _locators: [],
+        uploader: uploader,
+        file: file,
+        maxBlobSize: 65536,
+        bytesDone: 0,
+        queueTime: Date.now(),
+        startTime: null,
+        _readPos: 0,
+        done: false,
+        go: function() {
+            this._currentSlice = this.nextSlice();
+            if (!this._currentSlice) {
+                return $.when(true, true);
+            }
+            return (new SliceUploader(this.uploader)).
+                go(this._currentSlice.blob, this._currentSlice.size).
+                then(this.onUploaderSuccess);
+        },
+        onUploaderSuccess: function(locator, dataSize) {
+            that._locators.push(locator);
+            return that.go();
+        },
+        nextSlice: function() {
+            var size = Math.min(
+                this.maxBlobSize,
+                this.file.size - this._readPos);
+            if (size == 0) {
+                this.done = true;
+                return false;
+            }
+            var blob = this.file.slice(
+                this._readPos,
+                this._readPos + size,
+                'application/octet-stream; charset=x-user-defined');
+            this._readPos += size;
+            return {blob: blob, size: size};
+        }
+    });
+}
+
+function QueueUploader(workarea) {
+    var that = this;
+    $('.arv-upload-start', workarea).on('change', function(event){
+        that.queueFilesFromInput(event);
+    });
+    $.extend(this, {
+        workarea: workarea,
         queue: [],
-        arvados: arvadosClient(
-            $workarea.data('discovery-uri'),
+        arvados: new ArvadosClient(
+            workarea.data('discovery-uri'),
             $('meta[name=arvados-api-token]').attr('content')
         ),
         queueFilesFromInput: function(event) {
-            self.queue.push({fileInput: event.target});
-            self.arvados.api('keep_services', 'list', {filters: [['service_type','=','proxy']]}).then(function(data) {
-                self.keepProxy = data.items[0];
-                self.doUploadWork();
-            }, self.reportUploadError);
-        },
-        doUploadWork: function() {
-            console.log(["work queue is", self.queue]);
-            console.log(["keep proxy is", self.keepProxy]);
-            if (!self.keepProxy) {
-                self.reportUploadError(
-                    "Sorry, there seems to be no Keep proxy service available.");
-                return;
-            }
-        },
-        reportUploadError: function(err) {
-            $('.alert-danger', $workarea).show().find('.content').html(err);
+            var i, f;
+            for (i=0; i<event.target.files.length; i++) {
+                f = event.target.files[i];
+                console.log("Queueing file: " + f.name + " (" + f.type + ")");
+                this.queue.push(new FileUploader(this, f));
+            }
+            this.arvados.apiPromise(
+                'keep_services', 'list',
+                {filters: [['service_type','=','proxy']]}).
+                then(this.doQueueWithProxy).
+                then(this.reportUploadSuccess, this.reportUploadError);
+        },
+        doQueueWithProxy: function(data) {
+            that.keepProxy = data.items[0];
+            return that.doQueueWork();
+        },
+        doQueueWork: function() {
+            $('.alert-danger,.alert-success', that.workarea).hide();
+            if (!that.keepProxy) {
+                return $.Deferred().
+                    reject(null, null, "Sorry, there seems to be no Keep proxy service available.").
+                    promise();
+            }
+            while (that.queue.length > 0 && that.queue[0].done) {
+                that.queue.shift();
+            }
+            if (that.queue.length === 0) {
+                return $.Deferred().resolve("Done!").promise();
+            } else {
+                return that.queue[0].go().then(
+                    that.doQueueWork,
+                    that.reportUploadError);
+            }
+        },
+        reportUploadError: function(jqxhr, textStatus, err) {
+            console.log(["error", jqxhr,textStatus,err]);
+            $('.alert-danger', that.workarea).show().find('.content').
+                html((textStatus || 'Error') +
+                     (jqxhr && jqxhr.options
+                      ? (' (from ' + jqxhr.options.url + ')')
+                      : '') +
+                     ': ' +
+                     (err || '(no further details available, sorry!)'));
+        },
+        reportUploadSuccess: function(message) {
+            console.log(message);
+            $('.alert-success', that.workarea).show().find('.content').
+                html(message);
         }
-    };
-    $('.arv-upload-start', $workarea).on('change', self.queueFilesFromInput);
-    $workarea.on('arv:upload:work', self.doUploadWork);
-    return self;
+    });
 }
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
index 4ca58e9..135c5c0 100644
--- a/apps/workbench/app/views/collections/_show_files.html.erb
+++ b/apps/workbench/app/views/collections/_show_files.html.erb
@@ -66,6 +66,7 @@ function unselect_all_files() {
   <div class="arv-upload-to-collection" data-discovery-uri="<%= Rails.configuration.arvados_v1_base.sub('/arvados/v1', '') %>/discovery/v1/apis/arvados/v1/rest">
     <form>
       <input type="file" multiple class="arv-upload-start" />
+      <div style="display:none" class="alert alert-success"><i class="fa fa-flag-checkered"></i> <span class="content"></span></div>
       <div style="display:none" class="alert alert-danger"><i class="fa fa-warning"></i> <span class="content"></span></div>
     </form>
   </div>

commit b982b8ac8c61b862067ced40e001714d6dedf800
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 15:45:14 2014 -0500

    3781: Add POST method for writing without knowing MD5.

diff --git a/services/keepproxy/keepproxy.go b/services/keepproxy/keepproxy.go
index b74a982..620434f 100644
--- a/services/keepproxy/keepproxy.go
+++ b/services/keepproxy/keepproxy.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"github.com/gorilla/mux"
 	"io"
+	"io/ioutil"
 	"log"
 	"net"
 	"net/http"
@@ -246,6 +247,7 @@ func MakeRESTRouter(
 	if enable_put {
 		rest.Handle(`/{hash:[0-9a-f]{32}}+{hints}`, PutBlockHandler{kc, t}).Methods("PUT")
 		rest.Handle(`/{hash:[0-9a-f]{32}}`, PutBlockHandler{kc, t}).Methods("PUT")
+		rest.Handle(`/`, PutBlockHandler{kc, t}).Methods("POST")
 		rest.Handle(`/{hash:[0-9a-f]{32}}{ignore}`, OptionsHandler{}).Methods("OPTIONS")
 	}
 
@@ -261,7 +263,7 @@ func (this InvalidPathHandler) ServeHTTP(resp http.ResponseWriter, req *http.Req
 
 func (this OptionsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
 	log.Printf("%s: %s %s", GetRemoteAddress(req), req.Method, req.URL.Path)
-	resp.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, OPTIONS")
+	resp.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
 	resp.Header().Set("Access-Control-Allow-Origin", "*")
 	resp.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Keep-Desired-Replicas")
 	resp.Header().Set("Access-Control-Max-Age", "86486400")
@@ -384,7 +386,20 @@ func (this PutBlockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Reques
 	}
 
 	// Now try to put the block through
-	hash, replicas, err := kc.PutHR(hash, req.Body, contentLength)
+	var replicas int
+	var err error
+	if hash == "" {
+		if bytes, err := ioutil.ReadAll(req.Body); err != nil {
+			msg := fmt.Sprintf("Error reading request body: %s", err)
+			log.Printf(msg)
+			http.Error(resp, msg, http.StatusInternalServerError)
+			return
+		} else {
+			hash, replicas, err = kc.PutB(bytes)
+		}
+	} else {
+		hash, replicas, err = kc.PutHR(hash, req.Body, contentLength)
+	}
 
 	// Tell the client how many successful PUTs we accomplished
 	resp.Header().Set(keepclient.X_Keep_Replicas_Stored, fmt.Sprintf("%d", replicas))
diff --git a/services/keepproxy/keepproxy_test.go b/services/keepproxy/keepproxy_test.go
index ba9793d..667a0b2 100644
--- a/services/keepproxy/keepproxy_test.go
+++ b/services/keepproxy/keepproxy_test.go
@@ -14,6 +14,7 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"strings"
 	"testing"
 	"time"
 )
@@ -339,7 +340,7 @@ func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
 		c.Check(resp.StatusCode, Equals, 200)
 		body, err := ioutil.ReadAll(resp.Body)
 		c.Check(string(body), Equals, "")
-		c.Check(resp.Header.Get("Access-Control-Allow-Methods"), Equals, "GET, HEAD, PUT, OPTIONS")
+		c.Check(resp.Header.Get("Access-Control-Allow-Methods"), Equals, "GET, HEAD, POST, PUT, OPTIONS")
 		c.Check(resp.Header.Get("Access-Control-Allow-Origin"), Equals, "*")
 	}
 
@@ -352,3 +353,24 @@ func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
 		c.Check(resp.Header.Get("Access-Control-Allow-Origin"), Equals, "*")
 	}
 }
+
+func (s *ServerRequiredSuite) TestPostWithoutHash(c *C) {
+	runProxy(c, []string{"keepproxy"}, "4axaw8zxe0qm22wa6urpp5nskcne8z88cvbupv653y1njyi05h", 29955)
+	waitForListener()
+	defer closeListener()
+
+	{
+		client := http.Client{}
+		req, err := http.NewRequest("POST",
+			"http://localhost:29955/",
+			strings.NewReader("qux"))
+		req.Header.Add("Authorization", "OAuth2 4axaw8zxe0qm22wa6urpp5nskcne8z88cvbupv653y1njyi05h")
+		req.Header.Add("Content-Type", "application/octet-stream")
+		resp, err := client.Do(req)
+		c.Check(err, Equals, nil)
+		body, err := ioutil.ReadAll(resp.Body)
+		c.Check(err, Equals, nil)
+		c.Check(string(body), Equals,
+			fmt.Sprintf("%x+%d", md5.Sum([]byte("qux")), 3))
+	}
+}

commit 3df51093d693e89e14e35d3c5f73e74978b7e20e
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 14:40:34 2014 -0500

    3781: Beginnings of API client and uploader

diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js
index 1990b8b..7737eee 100644
--- a/apps/workbench/app/assets/javascripts/application.js
+++ b/apps/workbench/app/assets/javascripts/application.js
@@ -26,12 +26,6 @@
 //= require_tree .
 
 jQuery(function($){
-    $.ajaxSetup({
-        headers: {
-            'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
-        }
-    });
-
     $(document).ajaxStart(function(){
       $('.modal-with-loading-spinner .spinner').show();
     }).ajaxStop(function(){
diff --git a/apps/workbench/app/assets/javascripts/arvados_client.js b/apps/workbench/app/assets/javascripts/arvados_client.js
new file mode 100644
index 0000000..6f4eed1
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/arvados_client.js
@@ -0,0 +1,37 @@
+function arvadosClient(discoveryUri, apiToken) {
+    var self = {
+        apiToken: apiToken,
+        api: function(controller, action, params) {
+            return self.getDiscoveryDoc().then(function() {
+                meth = self.discoveryDoc.resources[controller].methods[action];
+                data = $.extend({}, params, {_method: meth.httpMethod});
+                $.each(data, function(k, v) {
+                    if (typeof(v) == 'object') {
+                        data[k] = JSON.stringify(v);
+                    }
+                });
+                return $.ajax({
+                    url: self.discoveryDoc.baseUrl + meth.path,
+                    type: 'POST',
+                    crossDomain: true,
+                    dataType: 'json',
+                    data: data,
+                    headers: {
+                        Authorization: 'OAuth2 ' + self.apiToken
+                    }
+                });
+            });
+        },
+        getDiscoveryDoc: function() {
+            if (self.promiseDiscovery) return self.promiseDiscovery;
+            self.promiseDiscovery = $.ajax({
+                url: discoveryUri,
+                crossDomain: true
+            }).then(function(data, status, xhr) {
+                self.discoveryDoc = data;
+            });
+            return self.promiseDiscovery;
+        }
+    };
+    return self;
+}
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
new file mode 100644
index 0000000..aff6c91
--- /dev/null
+++ b/apps/workbench/app/assets/javascripts/upload_to_collection.js
@@ -0,0 +1,40 @@
+$(document).on('ready ajax:success', function() {
+    $('.arv-upload-to-collection').
+        not('.arv-upload-setup').
+        addClass('.arv-upload-setup').
+        each(setupUploader);
+});
+
+function setupUploader() {
+    var $workarea = $(this);
+    var self = {
+        $workarea: $workarea,
+        queue: [],
+        arvados: arvadosClient(
+            $workarea.data('discovery-uri'),
+            $('meta[name=arvados-api-token]').attr('content')
+        ),
+        queueFilesFromInput: function(event) {
+            self.queue.push({fileInput: event.target});
+            self.arvados.api('keep_services', 'list', {filters: [['service_type','=','proxy']]}).then(function(data) {
+                self.keepProxy = data.items[0];
+                self.doUploadWork();
+            }, self.reportUploadError);
+        },
+        doUploadWork: function() {
+            console.log(["work queue is", self.queue]);
+            console.log(["keep proxy is", self.keepProxy]);
+            if (!self.keepProxy) {
+                self.reportUploadError(
+                    "Sorry, there seems to be no Keep proxy service available.");
+                return;
+            }
+        },
+        reportUploadError: function(err) {
+            $('.alert-danger', $workarea).show().find('.content').html(err);
+        }
+    };
+    $('.arv-upload-start', $workarea).on('change', self.queueFilesFromInput);
+    $workarea.on('arv:upload:work', self.doUploadWork);
+    return self;
+}
diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb
index 76d8731..4ca58e9 100644
--- a/apps/workbench/app/views/collections/_show_files.html.erb
+++ b/apps/workbench/app/views/collections/_show_files.html.erb
@@ -63,6 +63,12 @@ function unselect_all_files() {
 <% file_tree = @object.andand.files_tree %>
 <% if file_tree.nil? or file_tree.empty? %>
   <p>This collection is empty.</p>
+  <div class="arv-upload-to-collection" data-discovery-uri="<%= Rails.configuration.arvados_v1_base.sub('/arvados/v1', '') %>/discovery/v1/apis/arvados/v1/rest">
+    <form>
+      <input type="file" multiple class="arv-upload-start" />
+      <div style="display:none" class="alert alert-danger"><i class="fa fa-warning"></i> <span class="content"></span></div>
+    </form>
+  </div>
 <% else %>
   <ul id="collection_files" class="collection_files <%=preview_selectable_container%>">
   <% dirstack = [file_tree.first.first] %>
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb
index 324714e..3fff432 100644
--- a/apps/workbench/app/views/layouts/application.html.erb
+++ b/apps/workbench/app/views/layouts/application.html.erb
@@ -17,6 +17,7 @@
   <% 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="robots" content="NOINDEX, NOFOLLOW">
   <%= stylesheet_link_tag    "application", :media => "all" %>
   <%= javascript_include_tag "application" %>
diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb
index 0429f33..7769e1b 100644
--- a/apps/workbench/app/views/projects/show.html.erb
+++ b/apps/workbench/app/views/projects/show.html.erb
@@ -6,17 +6,30 @@
 
 <% content_for :tab_line_buttons do %>
   <% if @object.editable? %>
-    <%= link_to(
-	  choose_collections_path(
-	    title: 'Add data to project:',
-	    multiple: true,
-	    action_name: 'Add',
-	    action_href: actions_path(id: @object.uuid),
-	    action_method: 'post',
-	    action_data: {selection_param: 'selection[]', copy_selections_into_project: @object.uuid, success: 'page-refresh'}.to_json),
-	  { class: "btn btn-primary btn-sm", remote: true, method: 'get', title: "Add data to this project", data: {'event-after-select' => 'page-refresh'} }) do %>
-      <i class="fa fa-fw fa-plus"></i> Add data...
-    <% end %>
+    <div class="btn-group btn-group-sm">
+      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Add data <span class="caret"></span></button>
+      <ul class="dropdown-menu" role="menu">
+        <li>
+          <%= link_to(
+                choose_collections_path(
+                  title: 'Choose a collection to copy into this project:',
+                  multiple: true,
+                  action_name: 'Copy',
+                  action_href: actions_path(id: @object.uuid),
+                  action_method: 'post',
+                  action_data: {selection_param: 'selection[]', copy_selections_into_project: @object.uuid, success: 'page-refresh'}.to_json),
+                { remote: true, method: 'get', title: "Copy a collection from another project into this one", data: {'event-after-select' => 'page-refresh', 'toggle' => 'dropdown'} }) do %>
+            <i class="fa fa-fw fa-clipboard"></i> ...from a different project
+          <% end %>
+        </li>
+        <li>
+          <%= link_to(collections_path(collection: {manifest_text: ""}),
+              { method: 'post', title: "Add data to this project", data: {'event-after-select' => 'page-refresh', 'toggle' => 'dropdown'} }) do %>
+            <i class="fa fa-fw fa-upload"></i> ...from your computer
+          <% end %>
+        </li>
+      </ul>
+    </div>
     <%= link_to(
 	  choose_pipeline_templates_path(
 	    title: 'Choose a pipeline to run:',

commit af04cad1fbc8b90fca94610b312b1f710a0a94b1
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 05:01:13 2014 -0500

    3781: Set CORS headers in keepproxy responses.

diff --git a/services/keepproxy/keepproxy.go b/services/keepproxy/keepproxy.go
index de4ccaf..b74a982 100644
--- a/services/keepproxy/keepproxy.go
+++ b/services/keepproxy/keepproxy.go
@@ -222,6 +222,8 @@ type PutBlockHandler struct {
 
 type InvalidPathHandler struct{}
 
+type OptionsHandler struct{}
+
 // MakeRESTRouter
 //     Returns a mux.Router that passes GET and PUT requests to the
 //     appropriate handlers.
@@ -244,6 +246,7 @@ func MakeRESTRouter(
 	if enable_put {
 		rest.Handle(`/{hash:[0-9a-f]{32}}+{hints}`, PutBlockHandler{kc, t}).Methods("PUT")
 		rest.Handle(`/{hash:[0-9a-f]{32}}`, PutBlockHandler{kc, t}).Methods("PUT")
+		rest.Handle(`/{hash:[0-9a-f]{32}}{ignore}`, OptionsHandler{}).Methods("OPTIONS")
 	}
 
 	rest.NotFoundHandler = InvalidPathHandler{}
@@ -256,7 +259,17 @@ func (this InvalidPathHandler) ServeHTTP(resp http.ResponseWriter, req *http.Req
 	http.Error(resp, "Bad request", http.StatusBadRequest)
 }
 
+func (this OptionsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+	log.Printf("%s: %s %s", GetRemoteAddress(req), req.Method, req.URL.Path)
+	resp.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, OPTIONS")
+	resp.Header().Set("Access-Control-Allow-Origin", "*")
+	resp.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Keep-Desired-Replicas")
+	resp.Header().Set("Access-Control-Max-Age", "86486400")
+}
+
 func (this GetBlockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+	resp.Header().Set("Access-Control-Allow-Origin", "*")
+	resp.Header().Set("Access-Control-Allow-Headers", "Authorization")
 
 	kc := *this.KeepClient
 
diff --git a/services/keepproxy/keepproxy_test.go b/services/keepproxy/keepproxy_test.go
index 88ac8a6..ba9793d 100644
--- a/services/keepproxy/keepproxy_test.go
+++ b/services/keepproxy/keepproxy_test.go
@@ -222,7 +222,7 @@ func (s *ServerRequiredSuite) TestPutAskGet(c *C) {
 }
 
 func (s *ServerRequiredSuite) TestPutAskGetForbidden(c *C) {
-	log.Print("TestPutAndGet start")
+	log.Print("TestPutAskGetForbidden start")
 
 	kc := runProxy(c, []string{"keepproxy"}, "123abc", 29951)
 	waitForListener()
@@ -260,7 +260,7 @@ func (s *ServerRequiredSuite) TestPutAskGetForbidden(c *C) {
 		log.Print("Get")
 	}
 
-	log.Print("TestPutAndGetForbidden done")
+	log.Print("TestPutAskGetForbidden done")
 }
 
 func (s *ServerRequiredSuite) TestGetDisabled(c *C) {
@@ -320,3 +320,35 @@ func (s *ServerRequiredSuite) TestPutDisabled(c *C) {
 
 	log.Print("TestPutDisabled done")
 }
+
+func (s *ServerRequiredSuite) TestCorsHeaders(c *C) {
+	runProxy(c, []string{"keepproxy"}, "4axaw8zxe0qm22wa6urpp5nskcne8z88cvbupv653y1njyi05h", 29954)
+	waitForListener()
+	defer closeListener()
+
+	{
+		client := http.Client{}
+		req, err := http.NewRequest("OPTIONS",
+			fmt.Sprintf("http://localhost:29954/%x+3",
+				md5.Sum([]byte("foo"))),
+			nil)
+		req.Header.Add("Access-Control-Request-Method", "PUT")
+		req.Header.Add("Access-Control-Request-Headers", "Authorization, X-Keep-Desired-Replicas")
+		resp, err := client.Do(req)
+		c.Check(err, Equals, nil)
+		c.Check(resp.StatusCode, Equals, 200)
+		body, err := ioutil.ReadAll(resp.Body)
+		c.Check(string(body), Equals, "")
+		c.Check(resp.Header.Get("Access-Control-Allow-Methods"), Equals, "GET, HEAD, PUT, OPTIONS")
+		c.Check(resp.Header.Get("Access-Control-Allow-Origin"), Equals, "*")
+	}
+
+	{
+		resp, err := http.Get(
+			fmt.Sprintf("http://localhost:29954/%x+3",
+				md5.Sum([]byte("foo"))))
+		c.Check(err, Equals, nil)
+		c.Check(resp.Header.Get("Access-Control-Allow-Headers"), Equals, "Authorization")
+		c.Check(resp.Header.Get("Access-Control-Allow-Origin"), Equals, "*")
+	}
+}

commit a5a99ae63eaae6b10b7718f6e685eecd99137495
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 04:49:53 2014 -0500

    3781: Set CORS headers in API responses.

diff --git a/services/api/app/controllers/application_controller.rb b/services/api/app/controllers/application_controller.rb
index 4f0364f..eacd5f2 100644
--- a/services/api/app/controllers/application_controller.rb
+++ b/services/api/app/controllers/application_controller.rb
@@ -27,6 +27,7 @@ class ApplicationController < ActionController::Base
 
   ERROR_ACTIONS = [:render_error, :render_not_found]
 
+  before_filter :set_cors_headers
   before_filter :respond_with_json_by_default
   before_filter :remote_ip
   before_filter :load_read_auths
@@ -345,6 +346,13 @@ class ApplicationController < ActionController::Base
     end
   end
 
+  def set_cors_headers
+    response.headers['Access-Control-Allow-Origin'] = '*'
+    response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
+    response.headers['Access-Control-Allow-Headers'] = 'Authorization'
+    response.headers['Access-Control-Max-Age'] = '86486400'
+  end
+
   def respond_with_json_by_default
     html_index = request.accepts.index(Mime::HTML)
     if html_index.nil? or request.accepts[0...html_index].include?(Mime::JSON)
diff --git a/services/api/app/controllers/static_controller.rb b/services/api/app/controllers/static_controller.rb
index d624ea8..9c66f01 100644
--- a/services/api/app/controllers/static_controller.rb
+++ b/services/api/app/controllers/static_controller.rb
@@ -3,7 +3,7 @@ class StaticController < ApplicationController
 
   skip_before_filter :find_object_by_uuid
   skip_before_filter :render_404_if_no_object
-  skip_before_filter :require_auth_scope, :only => [ :home, :login_failure ]
+  skip_before_filter :require_auth_scope, only: [:home, :empty, :login_failure]
 
   def home
     respond_to do |f|
@@ -20,4 +20,8 @@ class StaticController < ApplicationController
     end
   end
 
+  def empty
+    render text: "-"
+  end
+
 end
diff --git a/services/api/app/controllers/user_sessions_controller.rb b/services/api/app/controllers/user_sessions_controller.rb
index 3e79915..cdcb720 100644
--- a/services/api/app/controllers/user_sessions_controller.rb
+++ b/services/api/app/controllers/user_sessions_controller.rb
@@ -1,6 +1,7 @@
 class UserSessionsController < ApplicationController
   before_filter :require_auth_scope, :only => [ :destroy ]
 
+  skip_before_filter :set_cors_headers
   skip_before_filter :find_object_by_uuid
   skip_before_filter :render_404_if_no_object
 
diff --git a/services/api/config/routes.rb b/services/api/config/routes.rb
index 705822a..096a0a5 100644
--- a/services/api/config/routes.rb
+++ b/services/api/config/routes.rb
@@ -3,6 +3,9 @@ Server::Application.routes.draw do
 
   # See http://guides.rubyonrails.org/routing.html
 
+  # OPTIONS requests just get an empty response with CORS headers.
+  match '*a', :to => 'static#empty', :via => 'OPTIONS'
+
   namespace :arvados do
     namespace :v1 do
       resources :api_client_authorizations do

commit 106746eaa37489d6f4277ea49f04c897dcb8bd23
Author: Tom Clegg <tom at curoverse.com>
Date:   Sat Nov 22 04:44:00 2014 -0500

    3781: Remove js cruft from api server.

diff --git a/services/api/app/assets/javascripts/api_client_authorizations.js.coffee b/services/api/app/assets/javascripts/api_client_authorizations.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/api_client_authorizations.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/api_clients.js.coffee b/services/api/app/assets/javascripts/api_clients.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/api_clients.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/application.js b/services/api/app/assets/javascripts/application.js
deleted file mode 100644
index 37c7bfc..0000000
--- a/services/api/app/assets/javascripts/application.js
+++ /dev/null
@@ -1,9 +0,0 @@
-// This is a manifest file that'll be compiled into including all the files listed below.
-// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
-// be included in the compiled file accessible from http://example.com/assets/application.js
-// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
-// the compiled file.
-//
-//= require jquery
-//= require jquery_ujs
-//= require_tree .
diff --git a/services/api/app/assets/javascripts/authorized_keys.js.coffee b/services/api/app/assets/javascripts/authorized_keys.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/authorized_keys.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/collections.js.coffee b/services/api/app/assets/javascripts/collections.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/collections.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/commit_ancestors.js.coffee b/services/api/app/assets/javascripts/commit_ancestors.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/commit_ancestors.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/commits.js.coffee b/services/api/app/assets/javascripts/commits.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/commits.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/groups.js.coffee b/services/api/app/assets/javascripts/groups.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/groups.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/humans.js.coffee b/services/api/app/assets/javascripts/humans.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/humans.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/job_tasks.js.coffee b/services/api/app/assets/javascripts/job_tasks.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/job_tasks.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/jobs.js.coffee b/services/api/app/assets/javascripts/jobs.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/jobs.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/keep_disks.js.coffee b/services/api/app/assets/javascripts/keep_disks.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/keep_disks.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/links.js.coffee b/services/api/app/assets/javascripts/links.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/links.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/logs.js.coffee b/services/api/app/assets/javascripts/logs.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/logs.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/nodes.js b/services/api/app/assets/javascripts/nodes.js
deleted file mode 100644
index a734426..0000000
--- a/services/api/app/assets/javascripts/nodes.js
+++ /dev/null
@@ -1,17 +0,0 @@
-// -*- mode: javascript; js-indent-level: 4; indent-tabs-mode: nil; -*-
-// Place all the behaviors and hooks related to the matching controller here.
-// All this logic will automatically be available in application.js.
-
-var loaded_nodes_js;
-$(function(){
-    if (loaded_nodes_js) return; loaded_nodes_js = true;
-
-    $('[data-showhide-selector]').on('click', function(e){
-        var x = $($(this).attr('data-showhide-selector'));
-        if (x.css('display') == 'none')
-            x.show();
-        else
-            x.hide();
-    });
-    $('[data-showhide-default]').hide();
-});
diff --git a/services/api/app/assets/javascripts/nodes.js.coffee b/services/api/app/assets/javascripts/nodes.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/nodes.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/pipeline_instances.js.coffee b/services/api/app/assets/javascripts/pipeline_instances.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/pipeline_instances.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/pipeline_templates.js.coffee b/services/api/app/assets/javascripts/pipeline_templates.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/pipeline_templates.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/repositories.js.coffee b/services/api/app/assets/javascripts/repositories.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/repositories.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/specimens.js.coffee b/services/api/app/assets/javascripts/specimens.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/specimens.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/traits.js.coffee b/services/api/app/assets/javascripts/traits.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/traits.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
diff --git a/services/api/app/assets/javascripts/virtual_machines.js.coffee b/services/api/app/assets/javascripts/virtual_machines.js.coffee
deleted file mode 100644
index 7615679..0000000
--- a/services/api/app/assets/javascripts/virtual_machines.js.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/

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


hooks/post-receive
-- 




More information about the arvados-commits mailing list