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
Loading
Loading
Loading
Loading
Loading
+15 −7
Original line number Diff line number Diff line
@@ -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,12 +26,18 @@ function apiRequest(options, showLoading = true, handleValidationErrors = false)
        }
      })
      .catch(error => {
        if (handleAllErrors) {
          reject(error);
        } else {
          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';
+43 −91
Original line number Diff line number Diff line
@@ -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)">&times;</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">&times;</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);
    }
  }
}
+4 −17
Original line number Diff line number Diff line
@@ -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(() => {
+181 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
  }
}