diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index e038065ac290f36af5dfffef843f24369df6fbdf..dde106615f11bb218a846a1fd278255cbd266863 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -9,7 +9,7 @@ import axios from 'axios'; import store from '../../store'; import main from '../../main'; -function apiRequest(options, showLoading = true, handleValidationErrors = false) { +function apiRequest(options, showLoading = true, handleValidationErrors = false, handleAllErrors = false) { if (showLoading) { store.commit('setLoading', true); } @@ -26,11 +26,17 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false) } }) .catch(error => { - store.commit('setLoading', false); - if (handleValidationErrors && error.response && error.response.status === 400) { - reject(error.response.data); + if (handleAllErrors) { + reject(error); } else { - main.showError(getErrorMessage(error)); + if (showLoading) { + store.commit('setLoading', false); + } + if (handleValidationErrors && error.response && error.response.status === 400) { + reject(error.response.data); + } else { + main.showError(getErrorMessage(error)); + } } }); }); @@ -145,7 +151,7 @@ export default { source }; }, - deleteNodes(paths, showLoading = true) { + deleteNodes(paths, calledFromUploadModal = false) { let url = BASE_API_URL + 'delete'; return apiRequest({ method: 'POST', @@ -155,7 +161,9 @@ export default { 'Cache-Control': 'no-cache' }, 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() { let url = BASE_API_URL + 'keepalive'; diff --git a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue index 158e6b23940f90875ea88bde704f15d7314ba185..af3f2657587e667cd11372b5fae7f34b8710e3a1 100644 --- a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue +++ b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue @@ -5,25 +5,31 @@ --> <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" - :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-invalid-feedback id="upload-file-input-feedback" class="text-right">{{uploadFileError}}</b-form-invalid-feedback> - <div class="mt-3" v-if="!blockModal">Selected files: {{ selectedFiles }}</div> - <div v-if="creatingMetadata" class="mt-3"> + <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{ validationError }}</b-form-invalid-feedback> + <div class="mt-3" v-if="!blockModal && !hasErrors">Selected files: {{ selectedFiles }}</div> + <div v-if="uploadsManager.creatingMetadata" class="mt-3"> <b-spinner small variant="primary" label="Spinning"></b-spinner> Creating metadata... </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-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 /> - <span class="text-danger cancel-upload" @click="cancelUpload(index)">×</span> - <b-progress :value="progress[index]" :max="100" show-progress animated></b-progress> + <span class="text-danger cancel-upload" @click="cancelUpload(index)" v-if="uploadsManager.progress[index] < 100">×</span> + <b-progress :value="uploadsManager.progress[index]" :max="100" show-progress :animated="uploadsManager.progress[index] < 100"></b-progress> </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> Upload of {{ file.name }} has been canceled. Waiting for metadata deletion... </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> </b-modal> @@ -31,25 +37,25 @@ <script> const maxUploadSize = process.env.VUE_APP_MAX_UPLOAD_SIZE; -import main from '../../main'; -import client from 'api-client'; -import Vue from 'vue'; export default { data() { return { - 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) - creatingMetadata: false, // true when nodes metadata is being created (pre-upload phase) - uploadInProgress: false, // true when data is being uploaded - uploadFileError: null + validationError: null } }, computed: { + uploadsManager() { return this.$store.state.uploadsManager }, + files: { + get() { + return this.uploadsManager.files; + }, + set(files) { + this.$store.commit('setFilesToUpload', files); + } + }, fileState() { - if (this.uploadFileError) { + if (this.validationError) { return false; } return null; @@ -65,7 +71,10 @@ export default { return names.join(', '); }, 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: { @@ -74,22 +83,26 @@ export default { this.resetError(); }, resetError() { - this.uploadFileError = null; + this.validationError = null; + this.$store.commit('resetUploadState'); }, selectionChanged() { this.resetError(); }, uploadFiles() { - if (this.uploadInProgress || this.creatingMetadata) { + if (this.blockModal) { return; } + + this.$store.commit('resetUploadState'); + if (this.files.length === 0) { - this.uploadFileError = "Select at least one file"; + this.validationError = "Select at least one file"; } else { // Check special characters in file names for (let file of this.files) { 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; } } @@ -97,79 +110,18 @@ export default { // Check size limit for (let file of this.files) { 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; } } - for (let i = 0; i < this.files.length; i++) { - let file = this.files[i]; - let reader = new FileReader(); - - 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; - }); + // $bvModal instance is passed to the upload manager because modal has + // to be closed at the end of a successful upload + this.$store.dispatch('upload', this.$bvModal); } }, cancelUpload(index) { - Vue.set(this.deletionStatuses, index, true); - this.cancellations[index].cancel(); + this.$store.dispatch('cancelUpload', index); } } } diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index 63d0fc70715b88f626eaffaa044d3fd81ac5aef3..21640ef240ed5aee5f538e2ceafb10140409796d 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -9,6 +9,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import client from 'api-client'; import main from './main'; +import uploadsManager from './uploadsManager'; Vue.use(Vuex); @@ -49,6 +50,9 @@ export default new Vuex.Store({ nodesToArchive: [], selectedNotArchivableNodes: [] }, + modules: { + uploadsManager + }, mutations: { setLoading(state, loading) { state.loading = loading; @@ -204,23 +208,6 @@ export default new Vuex.Store({ 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 }) { client.deleteNodes(state.nodesToDelete) .then(() => { diff --git a/vospace-ui-frontend/src/uploadsManager.js b/vospace-ui-frontend/src/uploadsManager.js new file mode 100644 index 0000000000000000000000000000000000000000..c70ba510e55ed7e6949735f754d21616ca14f127 --- /dev/null +++ b/vospace-ui-frontend/src/uploadsManager.js @@ -0,0 +1,181 @@ +/* + * 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(); + } + } +}