Skip to content
Snippets Groups Projects
Commit 0c52be2a authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Upload improvements: refactored upload logic into dedicated module (upload...

Upload improvements: refactored upload logic into dedicated module (upload manager), handled deletion of node metadata for failed or aborted uploads, displayed proper error messages for the various cases
parent 4cad3312
No related branches found
No related tags found
No related merge requests found
Pipeline #2221 passed
...@@ -9,7 +9,7 @@ import axios from 'axios'; ...@@ -9,7 +9,7 @@ import axios from 'axios';
import store from '../../store'; import store from '../../store';
import main from '../../main'; import main from '../../main';
function apiRequest(options, showLoading = true, handleValidationErrors = false) { function apiRequest(options, showLoading = true, handleValidationErrors = false, handleAllErrors = false) {
if (showLoading) { if (showLoading) {
store.commit('setLoading', true); store.commit('setLoading', true);
} }
...@@ -26,12 +26,18 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false) ...@@ -26,12 +26,18 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false)
} }
}) })
.catch(error => { .catch(error => {
if (handleAllErrors) {
reject(error);
} else {
if (showLoading) {
store.commit('setLoading', false); store.commit('setLoading', false);
}
if (handleValidationErrors && error.response && error.response.status === 400) { if (handleValidationErrors && error.response && error.response.status === 400) {
reject(error.response.data); reject(error.response.data);
} else { } else {
main.showError(getErrorMessage(error)); main.showError(getErrorMessage(error));
} }
}
}); });
}); });
} }
...@@ -145,7 +151,7 @@ export default { ...@@ -145,7 +151,7 @@ export default {
source source
}; };
}, },
deleteNodes(paths, showLoading = true) { deleteNodes(paths, calledFromUploadModal = false) {
let url = BASE_API_URL + 'delete'; let url = BASE_API_URL + 'delete';
return apiRequest({ return apiRequest({
method: 'POST', method: 'POST',
...@@ -155,7 +161,9 @@ export default { ...@@ -155,7 +161,9 @@ export default {
'Cache-Control': 'no-cache' 'Cache-Control': 'no-cache'
}, },
data: paths data: paths
}, showLoading, true); // if node deletion is called from upload modal loading animation is
// ignored and error handling is completely handled by upload manager
}, !calledFromUploadModal, true, calledFromUploadModal);
}, },
keepalive() { keepalive() {
let url = BASE_API_URL + 'keepalive'; let url = BASE_API_URL + 'keepalive';
......
...@@ -5,25 +5,31 @@ ...@@ -5,25 +5,31 @@
--> -->
<template> <template>
<b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok.prevent="uploadFiles" :no-close-on-backdrop="blockModal" :no-close-on-esc="blockModal" :hide-header-close="blockModal" :cancel-disabled="blockModal" <b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok.prevent="uploadFiles" :no-close-on-backdrop="blockModal" :no-close-on-esc="blockModal" :hide-header-close="blockModal" :cancel-disabled="blockModal"
:ok-disabled="blockModal" size="lg"> :ok-disabled="blockModal || hasErrors" size="lg">
<b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..." @change="selectionChanged"></b-form-file> <b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..." @change="selectionChanged"></b-form-file>
<b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{uploadFileError}}</b-form-invalid-feedback> <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{ validationError }}</b-form-invalid-feedback>
<div class="mt-3" v-if="!blockModal">Selected files: {{ selectedFiles }}</div> <div class="mt-3" v-if="!blockModal && !hasErrors">Selected files: {{ selectedFiles }}</div>
<div v-if="creatingMetadata" class="mt-3"> <div v-if="uploadsManager.creatingMetadata" class="mt-3">
<b-spinner small variant="primary" label="Spinning"></b-spinner> <b-spinner small variant="primary" label="Spinning"></b-spinner>
Creating metadata... Creating metadata...
</div> </div>
<div v-if="uploadInProgress" class="mt-3"> <div v-if="uploadsManager.uploadInProgress || hasErrors" class="mt-3">
<div v-for="(file, index) in files" :key="index" class="upload-progress-container mt-1"> <div v-for="(file, index) in files" :key="index" class="upload-progress-container mt-1">
<div v-if="!deletionStatuses[index]"> <div v-if="uploadsManager.errors[index] !== null">
<span class="text-danger">Error for {{ file.name }}: {{ uploadsManager.errors[index] }}</span>
</div>
<div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === null">
{{ file.name }}<br /> {{ file.name }}<br />
<span class="text-danger cancel-upload" @click="cancelUpload(index)">&times;</span> <span class="text-danger cancel-upload" @click="cancelUpload(index)" v-if="uploadsManager.progress[index] < 100">&times;</span>
<b-progress :value="progress[index]" :max="100" show-progress animated></b-progress> <b-progress :value="uploadsManager.progress[index]" :max="100" show-progress :animated="uploadsManager.progress[index] < 100"></b-progress>
</div> </div>
<div v-if="deletionStatuses[index]"> <div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === true">
<b-spinner small variant="primary" label="Spinning"></b-spinner> <b-spinner small variant="primary" label="Spinning"></b-spinner>
Upload of {{ file.name }} has been canceled. Waiting for metadata deletion... Upload of {{ file.name }} has been canceled. Waiting for metadata deletion...
</div> </div>
<div v-if="uploadsManager.errors[index] === null && uploadsManager.deletionStatuses[index] === false" class="text-primary">
Upload of {{ file.name }} has been canceled.
</div>
</div> </div>
</div> </div>
</b-modal> </b-modal>
...@@ -31,25 +37,25 @@ ...@@ -31,25 +37,25 @@
<script> <script>
const maxUploadSize = process.env.VUE_APP_MAX_UPLOAD_SIZE; const maxUploadSize = process.env.VUE_APP_MAX_UPLOAD_SIZE;
import main from '../../main';
import client from 'api-client';
import Vue from 'vue';
export default { export default {
data() { data() {
return { return {
files: [], // array of selected files validationError: null
progress: [], // array of upload progress percentages (for tracking upload progress)
cancellations: [], // array of axios cancel tokens (for aborting uploads)
deletionStatuses: [], // status of upload deletions (true if request is being aborted, false otherwise)
creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase)
uploadInProgress: false, // true when data is being uploaded
uploadFileError: null
} }
}, },
computed: { computed: {
uploadsManager() { return this.$store.state.uploadsManager },
files: {
get() {
return this.uploadsManager.files;
},
set(files) {
this.$store.commit('setFilesToUpload', files);
}
},
fileState() { fileState() {
if (this.uploadFileError) { if (this.validationError) {
return false; return false;
} }
return null; return null;
...@@ -65,7 +71,10 @@ export default { ...@@ -65,7 +71,10 @@ export default {
return names.join(', '); return names.join(', ');
}, },
blockModal() { blockModal() {
return this.creatingMetadata || this.uploadInProgress; return this.uploadsManager.creatingMetadata || this.uploadsManager.uploadInProgress;
},
hasErrors() {
return this.uploadsManager.errors.filter(e => e !== null).length > 0;
} }
}, },
methods: { methods: {
...@@ -74,22 +83,26 @@ export default { ...@@ -74,22 +83,26 @@ export default {
this.resetError(); this.resetError();
}, },
resetError() { resetError() {
this.uploadFileError = null; this.validationError = null;
this.$store.commit('resetUploadState');
}, },
selectionChanged() { selectionChanged() {
this.resetError(); this.resetError();
}, },
uploadFiles() { uploadFiles() {
if (this.uploadInProgress || this.creatingMetadata) { if (this.blockModal) {
return; return;
} }
this.$store.commit('resetUploadState');
if (this.files.length === 0) { if (this.files.length === 0) {
this.uploadFileError = "Select at least one file"; this.validationError = "Select at least one file";
} else { } else {
// Check special characters in file names // Check special characters in file names
for (let file of this.files) { for (let file of this.files) {
if (/[<>?":\\/`|'*]/.test(file.name)) { if (/[<>?":\\/`|'*]/.test(file.name)) {
this.uploadFileError = "File " + file.name + " contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `"; this.validationError = "File " + file.name + " contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `";
return; return;
} }
} }
...@@ -97,79 +110,18 @@ export default { ...@@ -97,79 +110,18 @@ export default {
// Check size limit // Check size limit
for (let file of this.files) { for (let file of this.files) {
if (file.size >= maxUploadSize * Math.pow(10, 9)) { if (file.size >= maxUploadSize * Math.pow(10, 9)) {
this.uploadFileError = "File " + file.name + " is too big. Max allowed file size is " + maxUploadSize + " GB"; this.validationError = "File " + file.name + " is too big. Max allowed file size is " + maxUploadSize + " GB";
return; return;
} }
} }
for (let i = 0; i < this.files.length; i++) { // $bvModal instance is passed to the upload manager because modal has
let file = this.files[i]; // to be closed at the end of a successful upload
let reader = new FileReader(); this.$store.dispatch('upload', this.$bvModal);
reader.addEventListener('progress', (event) => {
if (event.loaded && event.total) {
let percent = (event.loaded / event.total) * 100;
this.progress[i] = percent;
}
});
reader.readAsDataURL(file);
}
// reset status arrays
Vue.set(this, 'progress', []);
Vue.set(this, 'cancellations', []);
Vue.set(this, 'deletionStatuses', []);
this.creatingMetadata = true;
// Upload
this.$store.dispatch('uploadFiles', this.files)
.then((uploads) => {
this.creatingMetadata = false;
let promises = [];
for (let upload of uploads) {
promises.push(upload.request);
this.cancellations.push(upload.source);
this.deletionStatuses.push(false);
}
this.uploadInProgress = true;
// wait until all downloads have been completed (both successfully or not)
return Promise.allSettled(promises);
})
.then((responses) => {
let deletionPromises = [];
for (let i = 0; i < responses.length; i++) {
let response = responses[i];
if (response.status !== 'fulfilled') {
if (this.deletionStatuses[i]) {
deletionPromises.push(client.deleteNodes(['/' + this.$store.state.path + '/' + this.files[i].name], false));
} else {
let message = "Unable to upload file " + this.files[i].name;
main.showError(message);
}
}
}
return Promise.allSettled(deletionPromises);
})
.finally(() => {
// Reload current node when all uploads completed
this.$store.dispatch('setPath', this.$store.state.path);
this.$bvModal.hide('upload-files-modal');
this.creatingMetadata = false;
this.uploadInProgress = false;
});
} }
}, },
cancelUpload(index) { cancelUpload(index) {
Vue.set(this.deletionStatuses, index, true); this.$store.dispatch('cancelUpload', index);
this.cancellations[index].cancel();
} }
} }
} }
......
...@@ -9,6 +9,7 @@ import Vue from 'vue'; ...@@ -9,6 +9,7 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import client from 'api-client'; import client from 'api-client';
import main from './main'; import main from './main';
import uploadsManager from './uploadsManager';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -49,6 +50,9 @@ export default new Vuex.Store({ ...@@ -49,6 +50,9 @@ export default new Vuex.Store({
nodesToArchive: [], nodesToArchive: [],
selectedNotArchivableNodes: [] selectedNotArchivableNodes: []
}, },
modules: {
uploadsManager
},
mutations: { mutations: {
setLoading(state, loading) { setLoading(state, loading) {
state.loading = loading; state.loading = loading;
...@@ -204,23 +208,6 @@ export default new Vuex.Store({ ...@@ -204,23 +208,6 @@ export default new Vuex.Store({
dispatch('setPath', state.path); dispatch('setPath', state.path);
}); });
}, },
uploadFiles({ state }, files) {
let names = [];
for (let file of files) {
names.push(file.name);
}
return new Promise((resolve, reject) => {
client.prepareForUpload(state.path, names)
.then(uploadUrls => {
let uploads = [];
for (let i = 0; i < files.length; i++) {
uploads.push(client.uploadFile(uploadUrls[i], files[i]));
}
resolve(uploads);
})
.catch(error => reject(error));
});
},
deleteNodes({ state, dispatch }) { deleteNodes({ state, dispatch }) {
client.deleteNodes(state.nodesToDelete) client.deleteNodes(state.nodesToDelete)
.then(() => { .then(() => {
......
/*
* This file is part of vospace-ui
* Copyright (C) 2021 Istituto Nazionale di Astrofisica
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import Vue from 'vue';
import client from 'api-client';
function resetArray(array) {
array.splice(0, array.length);
}
export default {
state: {
files: [], // array of selected files
progress: [], // array of upload progress percentages (for tracking upload progress)
cancellations: [], // array of axios cancel tokens (for aborting uploads)
deletionStatuses: [], // status of upload deletions (true if request is being aborted, false otherwise)
errors: [], // errors happened during downloads
creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase)
uploadInProgress: false, // true when data is being uploaded
},
mutations: {
setFilesToUpload(state, files) {
state.files = files;
},
resetUploadState(state) {
resetArray(state.progress);
resetArray(state.cancellations);
resetArray(state.deletionStatuses);
resetArray(state.errors);
},
setDeletion(state, data) {
Vue.set(state.deletionStatuses, data.index, data.value);
},
setProgress(state, data) {
Vue.set(state.progress, data.index, data.percent);
},
setCreatingMetadata(state, value) {
state.creatingMetadata = value;
},
setUploadInProgress(state, value) {
state.uploadInProgress = value;
},
setPreUploadResults(state, preUploadResults) {
for (let result of preUploadResults) {
if (Object.keys(result).includes('request')) {
state.cancellations.push(result.source); // axios cancellation token
state.deletionStatuses.push(null);
state.errors.push(null);
} else {
state.cancellations.push(null);
state.deletionStatuses.push(null);
state.errors.push(result);
}
}
state.uploadInProgress = true;
},
setError(state, data) {
Vue.set(state.errors, data.index, data.error);
}
},
actions: {
upload({ state, rootState, commit, dispatch }, bvModal) {
// Progress bars initialization
for (let index = 0; index < state.files.length; index++) {
let file = state.files[index];
let reader = new FileReader();
reader.addEventListener('progress', (event) => {
if (event.loaded && event.total) {
let percent = (event.loaded / event.total) * 100;
commit('setProgress', { index, percent });
}
});
reader.readAsDataURL(file);
}
commit('setCreatingMetadata', true);
// Create nodes metadata and obtain upload URLs
dispatch('prepareUpload')
.then((preUploadResults) => {
commit('setCreatingMetadata', false);
let promises = [];
for (let result of preUploadResults) {
if (Object.keys(result).includes('request')) {
promises.push(result.request); // upload http promise
} else {
// fictitious promise for failed pre-upload event
promises.push(new Promise(resolve => resolve()));
}
}
commit('setPreUploadResults', preUploadResults);
// wait until all downloads have been completed (both successfully or not)
return Promise.allSettled(promises);
})
.then((responses) => {
let deletionPromises = [];
for (let index = 0; index < responses.length; index++) {
let error = state.errors[index];
let response = responses[index];
if (response.status === 'fulfilled' && error === null) {
commit('setProgress', { index, percent: 100 });
} else {
if (state.deletionStatuses[index] || error === null) {
// delete node metadata both when user aborted the upload and when it failed
deletionPromises.push(client.deleteNodes(['/' + rootState.path + '/' + state.files[index].name], true));
if (state.deletionStatuses[index] === null) {
// e.g. server became unreachable during the upload
commit('setError', { index, error: 'Unable to upload file' });
}
}
}
}
// if there are some deletion promises, wait them
return Promise.allSettled(deletionPromises);
})
.then(deletionResults => {
// verify if metadata of aborted uploads has been correctly removed
let resultIndex = 0;
for (let i = 0; i < state.deletionStatuses.length; i++) {
let deletionStatus = state.deletionStatuses[i];
if (deletionStatus !== null) {
let deletionResult = deletionResults[resultIndex];
if (deletionResult.status === 'fulfilled') {
commit('setDeletion', { index: i, value: false });
} else {
commit('setError', { index: i, error: 'Failed to delete node metadata. Manual cleanup may be required.' });
}
resultIndex++;
}
}
})
.finally(() => {
// Reload current node when all uploads completed
dispatch('setPath', rootState.path);
commit('setCreatingMetadata', false);
commit('setUploadInProgress', false);
let hasErrors = state.errors.filter(e => e !== null).length > 0;
if (!hasErrors) {
bvModal.hide('upload-files-modal');
}
});
},
prepareUpload({ state, rootState }) {
let names = [];
for (let file of state.files) {
names.push(file.name);
}
return new Promise((resolve, reject) => {
client.prepareForUpload(rootState.path, names)
.then(preUploadResponses => {
let uploads = [];
for (let i = 0; i < state.files.length; i++) {
let uploadUrl = preUploadResponses[i].url;
if (uploadUrl !== null) {
uploads.push(client.uploadFile(uploadUrl, state.files[i]));
} else {
uploads.push(preUploadResponses[i].error);
}
}
resolve(uploads);
})
.catch(error => reject(error));
});
},
cancelUpload({ state, commit }, index) {
commit('setDeletion', { index, value: true });
state.cancellations[index].cancel();
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment