Merge branch '19954-permission-dedup-doc'
[arvados.git] / apps / workbench / app / assets / javascripts / upload_to_collection.js
index 107e03eae17f6d58209f67f006f8195c021afb26..d66be6385375ee1c9bfe367a7596db90e6cf91d5 100644 (file)
@@ -1,3 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
 var app = angular.module('Workbench', ['Arvados']);
 app.controller('UploadToCollection', UploadToCollection);
 app.directive('arvUuid', arvUuid);
@@ -23,13 +27,18 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             // Angular binding doesn't work its usual magic for file
             // inputs, so we need to $scope.$apply() this update.
             $scope.$apply(function(){
-                var i;
-                var insertAt;
-                for (insertAt=0; (insertAt<$scope.uploadQueue.length &&
-                                  $scope.uploadQueue[insertAt].state != 'Done');
-                     insertAt++);
+                var i, nItemsTodo;
+                // Add these new files after the items already waiting
+                // in the queue -- but before the items that are
+                // 'Done' and have therefore been pushed to the
+                // bottom.
+                for (nItemsTodo = 0;
+                     (nItemsTodo < $scope.uploadQueue.length &&
+                      $scope.uploadQueue[nItemsTodo].state !== 'Done'); ) {
+                    nItemsTodo++;
+                }
                 for (i=0; i<files.length; i++) {
-                    $scope.uploadQueue.splice(insertAt+i, 0,
+                    $scope.uploadQueue.splice(nItemsTodo+i, 0,
                         new FileUploader(files[i]));
                 }
             });
@@ -47,19 +56,20 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             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;
+        countInStates: function(want_states) {
+            var found = 0;
+            $.each($scope.uploadQueue, function() {
+                if (want_states.indexOf(this.state) >= 0) {
+                    ++found;
                 }
-            }
-            return done;
+            });
+            return found;
         }
     });
-    // TODO: watch uploadQueue, abort uploads if entries disappear
+    ////////////////////////////////
 
     var keepProxy;
+    var defaultErrorMessage = 'A network error occurred: either the server was unreachable, or there is a server configuration problem. Please check your browser debug console for a more specific error message (browser security features prevent us from showing the details here).';
 
     function SliceReader(_slice) {
         var that = this;
@@ -81,7 +91,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             return _deferred.promise();
         }
         function resolve() {
-            if (that._reader.result.length != that._slice.size) {
+            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.
@@ -112,7 +122,20 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             // resolve(locator) when the block is accepted by the
             // proxy.
             _deferred = $.Deferred();
-            goSend();
+            if (proxyUriBase().match(/^http:/) &&
+                window.location.origin.match(/^https:/)) {
+                // In this case, requests will fail, and no ajax
+                // success/fail handlers will be called (!), which
+                // will leave our status saying "uploading" and the
+                // user waiting for something to happen. Better to
+                // give up now.
+                _deferred.reject({
+                    textStatus: 'error',
+                    err: 'There is a server configuration problem. Proxy ' + proxyUriBase() + ' cannot be used from origin ' + window.location.origin + ' due to the browser\'s mixed-content (https/http) policy.'
+                });
+            } else {
+                goSend();
+            }
             return _deferred.promise();
         }
         function stop() {
@@ -172,11 +195,10 @@ function UploadToCollection($scope, $filter, $q, $timeout,
 
     function FileUploader(file) {
         $.extend(this, {
-            committed: false,
             file: file,
             locators: [],
             progress: 0.0,
-            state: 'Queued',    // Queued, Uploading, Paused, Done
+            state: 'Queued',    // Queued, Uploading, Paused, Uploaded, Done
             statistics: null,
             go: go,
             stop: stop          // User wants to stop.
@@ -196,13 +218,13 @@ function UploadToCollection($scope, $filter, $q, $timeout,
         function go() {
             if (_deferred)
                 _deferred.reject({textStatus: 'restarted'});
-            _deferred = $q.defer();
+            _deferred = $.Deferred();
             that.state = 'Uploading';
             _startTime = Date.now();
             _startByte = _readPos;
             setProgress();
             goSlice();
-            return _deferred.promise;
+            return _deferred.promise().always(function() { _deferred = null; });
         }
         function stop() {
             if (_deferred) {
@@ -220,7 +242,10 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             // here is fulfilled.
             _currentSlice = nextSlice();
             if (!_currentSlice) {
-                that.state = 'Done';
+                // All slices have been uploaded, but the work won't
+                // be truly Done until the target collection has been
+                // updated by the QueueUploader. This state is called:
+                that.state = 'Uploaded';
                 setProgress(_readPos);
                 _currentUploader = null;
                 _deferred.resolve([that]);
@@ -236,9 +261,11 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                 onUploaderProgress);
         }
         function onUploaderResolve(locator, dataSize) {
-            if (!locator || _currentSlice.size != dataSize) {
-                console.log("onUploaderResolve but locator=" + locator +
-                            " and " + _currentSlice.size + " != " + dataSize);
+            var sizeHint = (''+locator).split('+')[1];
+            if (!locator || parseInt(sizeHint) !== dataSize) {
+                console.log("onUploaderResolve, but locator '" + locator +
+                            "' with size hint '" + sizeHint +
+                            "' does not look right for dataSize=" + dataSize);
                 return onUploaderReject({
                     textStatus: "error",
                     err: "Bad response from slice upload"
@@ -253,7 +280,8 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             that.state = 'Paused';
             setProgress(_readPos);
             _currentUploader = null;
-            _deferred.reject(reason);
+            if (_deferred)
+                _deferred.reject(reason);
         }
         function onUploaderProgress(sliceDone, sliceSize) {
             setProgress(_readPos + sliceDone);
@@ -263,7 +291,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                 _maxBlobSize,
                 that.file.size - _readPos);
             setProgress(_readPos);
-            if (size == 0) {
+            if (size === 0) {
                 return false;
             }
             var blob = that.file.slice(
@@ -273,36 +301,46 @@ function UploadToCollection($scope, $filter, $q, $timeout,
         }
         function setProgress(bytesDone) {
             var kBps;
-            that.progress = Math.min(100, 100 * bytesDone / that.file.size)
+            if (that.file.size == 0)
+                that.progress = 100;
+            else
+                that.progress = Math.min(100, 100 * bytesDone / that.file.size);
             if (bytesDone > _startByte) {
                 kBps = (bytesDone - _startByte) /
                     (Date.now() - _startTime);
                 that.statistics = (
                     '' + $filter('number')(bytesDone/1024, '0') + ' KiB ' +
                         'at ~' + $filter('number')(kBps, '0') + ' KiB/s')
-                if (that.state == 'Paused') {
+                if (that.state === 'Paused') {
                     that.statistics += ', paused';
-                } else if (that.state == 'Uploading') {
+                } else if (that.state === 'Uploading') {
                     that.statistics += ', ETA ' +
                         $filter('date')(
                             new Date(
                                 Date.now() + (that.file.size - bytesDone) / kBps),
                             'shortTime')
-                } else {
-                    that.statistics += ', finished ' +
-                        $filter('date')(Date.now(), 'shortTime');
-                    _finishTime = Date.now();
                 }
             } else {
                 that.statistics = that.state;
             }
-            _deferred.notify();
+            if (that.state === 'Uploaded') {
+                // 'Uploaded' gets reported as 'finished', which is a
+                // little misleading because the collection hasn't
+                // been updated yet. But FileUploader's portion of the
+                // work (and the time when it makes sense to show
+                // speed and ETA) is finished.
+                that.statistics += ', finished ' +
+                    $filter('date')(Date.now(), 'shortTime');
+                _finishTime = Date.now();
+            }
+            if (_deferred)
+                _deferred.notify();
         }
     }
 
     function QueueUploader() {
         $.extend(this, {
-            state: 'Idle',
+            state: 'Idle',      // Idle, Running, Stopped, Failed
             stateReason: null,
             statusSuccess: null,
             go: go,
@@ -310,9 +348,11 @@ function UploadToCollection($scope, $filter, $q, $timeout,
         });
         ////////////////////////////////
         var that = this;
-        var _deferred;
+        var _deferred;          // the one we promise to go()'s caller
+        var _deferredAppend;    // tracks current appendToCollection
         function go() {
-            if (that.state == 'Running') return _deferred.promise;
+            if (_deferred) return _deferred.promise();
+            if (_deferredAppend) return _deferredAppend.promise();
             _deferred = $.Deferred();
             that.state = 'Running';
             ArvadosClient.apiPromise(
@@ -320,11 +360,16 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                 {filters: [['service_type','=','proxy']]}).
                 then(doQueueWithProxy);
             onQueueProgress();
-            return _deferred.promise();
+            return _deferred.promise().always(function() { _deferred = null; });
         }
         function stop() {
+            that.state = 'Stopped';
+            if (_deferred) {
+                _deferred.reject({});
+            }
             for (var i=0; i<$scope.uploadQueue.length; i++)
                 $scope.uploadQueue[i].stop();
+            onQueueProgress();
         }
         function doQueueWithProxy(data) {
             keepProxy = data.items[0];
@@ -338,25 +383,35 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             return doQueueWork();
         }
         function doQueueWork() {
-            var i;
-            that.state = 'Running';
-            that.stateReason = null;
-            // Push the done things to the bottom of the queue.
-            for (i=0; (i<$scope.uploadQueue.length &&
-                       $scope.uploadQueue[i].state == 'Done'); i++);
-            if (i>0)
-                $scope.uploadQueue.push.apply($scope.uploadQueue, $scope.uploadQueue.splice(0, i));
-            // If anything is not-done, do it.
+            // If anything is not Done, do it.
             if ($scope.uploadQueue.length > 0 &&
-                $scope.uploadQueue[0].state != 'Done') {
-                return $scope.uploadQueue[0].go().
-                    then(appendToCollection, null, onQueueProgress).
-                    then(doQueueWork, onQueueReject);
+                $scope.uploadQueue[0].state !== 'Done') {
+                if (_deferred) {
+                    that.stateReason = null;
+                    return $scope.uploadQueue[0].go().
+                        then(appendToCollection, null, onQueueProgress).
+                        then(doQueueWork, onQueueReject);
+                } else {
+                    // Queue work has been stopped. Just update the
+                    // view.
+                    onQueueProgress();
+                    return;
+                }
             }
-            // If everything is done, resolve the promise and clean up.
-            return onQueueResolve();
+            // If everything is Done, resolve the promise and clean
+            // up. Note this can happen even after the _deferred
+            // promise has been rejected: specifically, when stop() is
+            // called too late to prevent completion of the last
+            // upload. In that case we want to update state to "Idle",
+            // rather than leave it at "Stopped".
+            onQueueResolve();
         }
         function onQueueReject(reason) {
+            if (!_deferred) {
+                // Outcome has already been decided (by stop()).
+                return;
+            }
+
             that.state = 'Failed';
             that.stateReason = (
                 (reason.textStatus || 'Error') +
@@ -364,7 +419,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                      ? (' (from ' + reason.xhr.options.url + ')')
                      : '') +
                     ': ' +
-                    (reason.err || ''));
+                    (reason.err || defaultErrorMessage));
             if (reason.xhr && reason.xhr.responseText)
                 that.stateReason += ' -- ' + reason.xhr.responseText;
             _deferred.reject(reason);
@@ -373,7 +428,8 @@ function UploadToCollection($scope, $filter, $q, $timeout,
         function onQueueResolve() {
             that.state = 'Idle';
             that.stateReason = 'Done!';
-            _deferred.resolve();
+            if (_deferred)
+                _deferred.resolve();
             onQueueProgress();
         }
         function onQueueProgress() {
@@ -381,39 +437,58 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             $timeout(function(){$scope.$apply();});
         }
         function appendToCollection(uploads) {
-            var deferred = $q.defer();
-            return ArvadosClient.apiPromise(
+            _deferredAppend = $.Deferred();
+            ArvadosClient.apiPromise(
                 'collections', 'get',
                 { uuid: $scope.uuid }).
                 then(function(collection) {
                     var manifestText = '';
-                    var upload, i;
-                    for (i=0; i<uploads.length; i++) {
-                        upload = uploads[i];
+                    $.each(uploads, function(_, upload) {
+                        var locators = upload.locators;
+                        if (locators.length === 0) {
+                            // Every stream must have at least one
+                            // data locator, even if it is zero bytes
+                            // long:
+                            locators = ['d41d8cd98f00b204e9800998ecf8427e+0'];
+                        }
                         filename = ArvadosClient.uniqueNameForManifest(
                             collection.manifest_text,
                             '.', upload.file.name);
                         collection.manifest_text += '. ' +
-                            upload.locators.join(' ') +
+                            locators.join(' ') +
                             ' 0:' + upload.file.size.toString() + ':' +
                             filename +
                             '\n';
-                    }
+                    });
                     return ArvadosClient.apiPromise(
                         'collections', 'update',
                         { uuid: $scope.uuid,
                           collection:
                           { manifest_text:
                             collection.manifest_text }
-                        }).
-                        then(deferred.resolve);
-                }, onQueueReject).then(function() {
-                    var i;
-                    for(i=0; i<uploads.length; i++) {
-                        uploads[i].committed = true;
+                        });
+                }).
+                then(function() {
+                    // Mark the completed upload(s) as Done and push
+                    // them to the bottom of the queue.
+                    var i, qLen = $scope.uploadQueue.length;
+                    for (i=0; i<qLen; i++) {
+                        if (uploads.indexOf($scope.uploadQueue[i]) >= 0) {
+                            $scope.uploadQueue[i].state = 'Done';
+                            $scope.uploadQueue.push.apply(
+                                $scope.uploadQueue,
+                                $scope.uploadQueue.splice(i, 1));
+                            --i;
+                            --qLen;
+                        }
                     }
+                }).
+                then(_deferredAppend.resolve,
+                     _deferredAppend.reject);
+            return _deferredAppend.promise().
+                always(function() {
+                    _deferredAppend = null;
                 });
-            return deferred.promise.then(doQueueWork);
         }
     }
 }