Merge branch '19954-permission-dedup-doc'
[arvados.git] / apps / workbench / app / assets / javascripts / upload_to_collection.js
index 89c6c3dc163dcc654ade56ff35782db32735e333..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);
@@ -65,6 +69,7 @@ function UploadToCollection($scope, $filter, $q, $timeout,
     ////////////////////////////////
 
     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;
@@ -117,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() {
@@ -177,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.
@@ -201,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) {
@@ -225,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]);
@@ -260,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);
@@ -280,7 +301,10 @@ 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);
@@ -295,21 +319,28 @@ function UploadToCollection($scope, $filter, $q, $timeout,
                             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,
@@ -317,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(
@@ -327,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];
@@ -345,32 +383,35 @@ function UploadToCollection($scope, $filter, $q, $timeout,
             return doQueueWork();
         }
         function doQueueWork() {
-            var nItemsDone;
-            that.state = 'Running';
-            that.stateReason = null;
-            // Are there any Done things at the top of the queue?
-            for (nItemsDone = 0;
-                 (nItemsDone < $scope.uploadQueue.length &&
-                  $scope.uploadQueue[nItemsDone].state === 'Done'); ) {
-                nItemsDone++;
-            }
-            // If so, push them down to the bottom of the queue.
-            if (nItemsDone > 0) {
-                $scope.uploadQueue.push.apply(
-                    $scope.uploadQueue,
-                    $scope.uploadQueue.splice(0, nItemsDone));
-            }
-            // 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);
+                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') +
@@ -378,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);
@@ -387,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() {
@@ -395,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);
         }
     }
 }