1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 var app = angular.module('Workbench', ['Arvados']);
6 app.controller('UploadToCollection', UploadToCollection);
7 app.directive('arvUuid', arvUuid);
10 // Copy the given uuid into the current $scope.
13 link: function(scope, element, attributes) {
14 scope.uuid = attributes.arvUuid;
19 UploadToCollection.$inject = ['$scope', '$filter', '$q', '$timeout',
20 'ArvadosClient', 'arvadosApiToken'];
21 function UploadToCollection($scope, $filter, $q, $timeout,
22 ArvadosClient, arvadosApiToken) {
25 uploader: new QueueUploader(),
26 addFilesToQueue: function(files) {
27 // Angular binding doesn't work its usual magic for file
28 // inputs, so we need to $scope.$apply() this update.
29 $scope.$apply(function(){
31 // Add these new files after the items already waiting
32 // in the queue -- but before the items that are
33 // 'Done' and have therefore been pushed to the
36 (nItemsTodo < $scope.uploadQueue.length &&
37 $scope.uploadQueue[nItemsTodo].state !== 'Done'); ) {
40 for (i=0; i<files.length; i++) {
41 $scope.uploadQueue.splice(nItemsTodo+i, 0,
42 new FileUploader(files[i]));
50 $scope.uploader.stop();
52 removeFileFromQueue: function(index) {
53 var wasRunning = $scope.uploader.running;
54 $scope.uploadQueue[index].stop();
55 $scope.uploadQueue.splice(index, 1);
59 countInStates: function(want_states) {
61 $.each($scope.uploadQueue, function() {
62 if (want_states.indexOf(this.state) >= 0) {
69 ////////////////////////////////
72 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).';
74 function SliceReader(_slice) {
79 ////////////////////////////////
83 // Return a promise, which will be resolved with the
84 // requested slice data.
85 _deferred = $.Deferred();
86 _reader = new FileReader();
87 _reader.onload = resolve;
88 _reader.onerror = _deferred.reject;
89 _reader.onprogress = _deferred.notify;
90 _reader.readAsArrayBuffer(_slice.blob);
91 return _deferred.promise();
94 if (that._reader.result.length !== that._slice.size) {
95 // Sometimes we get an onload event even if the read
96 // did not return the desired number of bytes. We
97 // treat that as a fail.
100 "Short read: wanted " + _slice.size +
101 ", received " + _reader.result.length);
104 return _deferred.resolve(_reader.result);
108 function SliceUploader(_label, _data, _dataSize) {
113 ////////////////////////////////
120 // Send data to the Keep proxy. Retry a few times on
121 // fail. Return a promise that will get resolved with
122 // resolve(locator) when the block is accepted by the
124 _deferred = $.Deferred();
125 if (proxyUriBase().match(/^http:/) &&
126 window.location.origin.match(/^https:/)) {
127 // In this case, requests will fail, and no ajax
128 // success/fail handlers will be called (!), which
129 // will leave our status saying "uploading" and the
130 // user waiting for something to happen. Better to
134 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.'
139 return _deferred.promise();
145 textStatus: 'stopped',
146 err: 'interrupted at slice '+_label
155 'Authorization': 'OAuth2 '+arvadosApiToken,
156 'Content-Type': 'application/octet-stream',
157 'X-Keep-Desired-Replicas': '2'
160 // Make an xhr that reports upload progress
161 var xhr = $.ajaxSettings.xhr();
163 xhr.upload.onprogress = onSendProgress;
170 _jqxhr.then(onSendResolve, onSendReject);
172 function onSendProgress(xhrProgressEvent) {
173 _deferred.notify(xhrProgressEvent.loaded, _dataSize);
175 function onSendResolve(data, textStatus, jqxhr) {
176 _deferred.resolve(data, _dataSize);
178 function onSendReject(xhr, textStatus, err) {
179 if (++_failCount < _failMax) {
180 // TODO: nice to tell the user that retry is happening.
181 console.log('slice ' + _label + ': ' +
182 textStatus + ', retry ' + _failCount);
186 {xhr: xhr, textStatus: textStatus, err: err});
189 function proxyUriBase() {
190 return ((keepProxy.service_ssl_flag ? 'https' : 'http') +
191 '://' + keepProxy.service_host + ':' +
192 keepProxy.service_port + '/');
196 function FileUploader(file) {
201 state: 'Queued', // Queued, Uploading, Paused, Uploaded, Done
204 stop: stop // User wants to stop.
206 ////////////////////////////////
208 var _currentUploader;
211 var _maxBlobSize = Math.pow(2,26);
213 var _queueTime = Date.now();
217 var _readPos = 0; // number of bytes confirmed uploaded
220 _deferred.reject({textStatus: 'restarted'});
221 _deferred = $.Deferred();
222 that.state = 'Uploading';
223 _startTime = Date.now();
224 _startByte = _readPos;
227 return _deferred.promise().always(function() { _deferred = null; });
231 that.state = 'Paused';
232 _deferred.reject({textStatus: 'stopped', err: 'interrupted'});
234 if (_currentUploader) {
235 _currentUploader.stop();
236 _currentUploader = null;
240 // Ensure this._deferred gets resolved or rejected --
241 // either right here, or when a new promise arranged right
242 // here is fulfilled.
243 _currentSlice = nextSlice();
244 if (!_currentSlice) {
245 // All slices have been uploaded, but the work won't
246 // be truly Done until the target collection has been
247 // updated by the QueueUploader. This state is called:
248 that.state = 'Uploaded';
249 setProgress(_readPos);
250 _currentUploader = null;
251 _deferred.resolve([that]);
254 _currentUploader = new SliceUploader(
258 _currentUploader.go().then(
263 function onUploaderResolve(locator, dataSize) {
264 var sizeHint = (''+locator).split('+')[1];
265 if (!locator || parseInt(sizeHint) !== dataSize) {
266 console.log("onUploaderResolve, but locator '" + locator +
267 "' with size hint '" + sizeHint +
268 "' does not look right for dataSize=" + dataSize);
269 return onUploaderReject({
271 err: "Bad response from slice upload"
274 that.locators.push(locator);
275 _readPos += dataSize;
276 _currentUploader = null;
279 function onUploaderReject(reason) {
280 that.state = 'Paused';
281 setProgress(_readPos);
282 _currentUploader = null;
284 _deferred.reject(reason);
286 function onUploaderProgress(sliceDone, sliceSize) {
287 setProgress(_readPos + sliceDone);
289 function nextSlice() {
292 that.file.size - _readPos);
293 setProgress(_readPos);
297 var blob = that.file.slice(
298 _readPos, _readPos+size,
299 'application/octet-stream; charset=x-user-defined');
300 return {blob: blob, size: size};
302 function setProgress(bytesDone) {
304 if (that.file.size == 0)
307 that.progress = Math.min(100, 100 * bytesDone / that.file.size);
308 if (bytesDone > _startByte) {
309 kBps = (bytesDone - _startByte) /
310 (Date.now() - _startTime);
312 '' + $filter('number')(bytesDone/1024, '0') + ' KiB ' +
313 'at ~' + $filter('number')(kBps, '0') + ' KiB/s')
314 if (that.state === 'Paused') {
315 that.statistics += ', paused';
316 } else if (that.state === 'Uploading') {
317 that.statistics += ', ETA ' +
320 Date.now() + (that.file.size - bytesDone) / kBps),
324 that.statistics = that.state;
326 if (that.state === 'Uploaded') {
327 // 'Uploaded' gets reported as 'finished', which is a
328 // little misleading because the collection hasn't
329 // been updated yet. But FileUploader's portion of the
330 // work (and the time when it makes sense to show
331 // speed and ETA) is finished.
332 that.statistics += ', finished ' +
333 $filter('date')(Date.now(), 'shortTime');
334 _finishTime = Date.now();
341 function QueueUploader() {
343 state: 'Idle', // Idle, Running, Stopped, Failed
349 ////////////////////////////////
351 var _deferred; // the one we promise to go()'s caller
352 var _deferredAppend; // tracks current appendToCollection
354 if (_deferred) return _deferred.promise();
355 if (_deferredAppend) return _deferredAppend.promise();
356 _deferred = $.Deferred();
357 that.state = 'Running';
358 ArvadosClient.apiPromise(
359 'keep_services', 'list',
360 {filters: [['service_type','=','proxy']]}).
361 then(doQueueWithProxy);
363 return _deferred.promise().always(function() { _deferred = null; });
366 that.state = 'Stopped';
368 _deferred.reject({});
370 for (var i=0; i<$scope.uploadQueue.length; i++)
371 $scope.uploadQueue[i].stop();
374 function doQueueWithProxy(data) {
375 keepProxy = data.items[0];
377 that.state = 'Failed';
379 'There seems to be no Keep proxy service available.';
380 _deferred.reject(null, 'error', that.stateReason);
383 return doQueueWork();
385 function doQueueWork() {
386 // If anything is not Done, do it.
387 if ($scope.uploadQueue.length > 0 &&
388 $scope.uploadQueue[0].state !== 'Done') {
390 that.stateReason = null;
391 return $scope.uploadQueue[0].go().
392 then(appendToCollection, null, onQueueProgress).
393 then(doQueueWork, onQueueReject);
395 // Queue work has been stopped. Just update the
401 // If everything is Done, resolve the promise and clean
402 // up. Note this can happen even after the _deferred
403 // promise has been rejected: specifically, when stop() is
404 // called too late to prevent completion of the last
405 // upload. In that case we want to update state to "Idle",
406 // rather than leave it at "Stopped".
409 function onQueueReject(reason) {
411 // Outcome has already been decided (by stop()).
415 that.state = 'Failed';
417 (reason.textStatus || 'Error') +
418 (reason.xhr && reason.xhr.options
419 ? (' (from ' + reason.xhr.options.url + ')')
422 (reason.err || defaultErrorMessage));
423 if (reason.xhr && reason.xhr.responseText)
424 that.stateReason += ' -- ' + reason.xhr.responseText;
425 _deferred.reject(reason);
428 function onQueueResolve() {
430 that.stateReason = 'Done!';
435 function onQueueProgress() {
436 // Ensure updates happen after FileUpload promise callbacks.
437 $timeout(function(){$scope.$apply();});
439 function appendToCollection(uploads) {
440 _deferredAppend = $.Deferred();
441 ArvadosClient.apiPromise(
442 'collections', 'get',
443 { uuid: $scope.uuid }).
444 then(function(collection) {
445 var manifestText = '';
446 $.each(uploads, function(_, upload) {
447 var locators = upload.locators;
448 if (locators.length === 0) {
449 // Every stream must have at least one
450 // data locator, even if it is zero bytes
452 locators = ['d41d8cd98f00b204e9800998ecf8427e+0'];
454 filename = ArvadosClient.uniqueNameForManifest(
455 collection.manifest_text,
456 '.', upload.file.name);
457 collection.manifest_text += '. ' +
459 ' 0:' + upload.file.size.toString() + ':' +
463 return ArvadosClient.apiPromise(
464 'collections', 'update',
468 collection.manifest_text }
472 // Mark the completed upload(s) as Done and push
473 // them to the bottom of the queue.
474 var i, qLen = $scope.uploadQueue.length;
475 for (i=0; i<qLen; i++) {
476 if (uploads.indexOf($scope.uploadQueue[i]) >= 0) {
477 $scope.uploadQueue[i].state = 'Done';
478 $scope.uploadQueue.push.apply(
480 $scope.uploadQueue.splice(i, 1));
486 then(_deferredAppend.resolve,
487 _deferredAppend.reject);
488 return _deferredAppend.promise().
490 _deferredAppend = null;