From 43421e91474fda9cded9593490af81ddad0b62fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Fri, 26 Apr 2024 11:46:42 +0200 Subject: [PATCH] Resolve "List Multipart Uploads and abort them" --- package-lock.json | 112 +++++----- src/components/AppHeader.vue | 7 + .../object-storage/BucketListItem.vue | 29 ++- .../object-storage/modals/CopyObjectModal.vue | 4 +- .../form-mode/ParameterFileInput.vue | 14 +- src/router/s3Routes.ts | 8 + src/stores/buckets.ts | 40 +--- src/stores/s3objects.ts | 70 +++++- .../object-storage/MultiPartUploadsView.vue | 199 ++++++++++++++++++ 9 files changed, 381 insertions(+), 102 deletions(-) create mode 100644 src/views/object-storage/MultiPartUploadsView.vue diff --git a/package-lock.json b/package-lock.json index dd0ffb9..ca2246f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,9 +70,9 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.0.tgz", - "integrity": "sha512-I+d5/XrazqY86/kGsmjVercjjJ+w6MVXJj7vnHfUgXzaoLJAl0/tPk2WXVpHUeRqHqyJ6AGkXBqx6Dc3wJkrCQ==", + "version": "11.6.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.1.tgz", + "integrity": "sha512-DxjgKBCoyReu4p5HMvpmgSOfRhhBcuf5V5soDDRgOTZMwsA4KSFzol1abFZgiCTE11L2kKGca5Md9GwDdXVBwQ==", "dev": true, "dependencies": { "@jsdevtools/ono": "^7.1.3", @@ -2732,12 +2732,12 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.24.tgz", - "integrity": "sha512-vbW/tgbwJYj62N/Ww99x0zhFTkZDTcGh3uwJEuadZ/nF9/xuFMC4693P9r+3sxGXISABpDKvffY5ApH9pmdd1A==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.25.tgz", + "integrity": "sha512-Y2pLLopaElgWnMNolgG8w3C5nNUVev80L7hdQ5iIKPtMJvhVpG0zhnBG/g3UajJmZdvW0fktyZTotEHD1Srhbg==", "dependencies": { "@babel/parser": "^7.24.4", - "@vue/shared": "3.4.24", + "@vue/shared": "3.4.25", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" @@ -2749,24 +2749,24 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/@vue/compiler-dom": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.24.tgz", - "integrity": "sha512-4XgABML/4cNndVsQndG6BbGN7+EoisDwi3oXNovqL/4jdNhwvP8/rfRMTb6FxkxIxUUtg6AI1/qZvwfSjxJiWA==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.25.tgz", + "integrity": "sha512-Ugz5DusW57+HjllAugLci19NsDK+VyjGvmbB2TXaTcSlQxwL++2PETHx/+Qv6qFwNLzSt7HKepPe4DcTE3pBWg==", "dependencies": { - "@vue/compiler-core": "3.4.24", - "@vue/shared": "3.4.24" + "@vue/compiler-core": "3.4.25", + "@vue/shared": "3.4.25" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.24.tgz", - "integrity": "sha512-nRAlJUK02FTWfA2nuvNBAqsDZuERGFgxZ8sGH62XgFSvMxO2URblzulExsmj4gFZ8e+VAyDooU9oAoXfEDNxTA==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.25.tgz", + "integrity": "sha512-m7rryuqzIoQpOBZ18wKyq05IwL6qEpZxFZfRxlNYuIPDqywrXQxgUwLXIvoU72gs6cRdY6wHD0WVZIFE4OEaAQ==", "dependencies": { "@babel/parser": "^7.24.4", - "@vue/compiler-core": "3.4.24", - "@vue/compiler-dom": "3.4.24", - "@vue/compiler-ssr": "3.4.24", - "@vue/shared": "3.4.24", + "@vue/compiler-core": "3.4.25", + "@vue/compiler-dom": "3.4.25", + "@vue/compiler-ssr": "3.4.25", + "@vue/shared": "3.4.25", "estree-walker": "^2.0.2", "magic-string": "^0.30.10", "postcss": "^8.4.38", @@ -2787,12 +2787,12 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.24.tgz", - "integrity": "sha512-ZsAtr4fhaUFnVcDqwW3bYCSDwq+9Gk69q2r/7dAHDrOMw41kylaMgOP4zRnn6GIEJkQznKgrMOGPMFnLB52RbQ==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.25.tgz", + "integrity": "sha512-H2ohvM/Pf6LelGxDBnfbbXFPyM4NE3hrw0e/EpwuSiYu8c819wx+SVGdJ65p/sFrYDd6OnSDxN1MB2mN07hRSQ==", "dependencies": { - "@vue/compiler-dom": "3.4.24", - "@vue/shared": "3.4.24" + "@vue/compiler-dom": "3.4.25", + "@vue/shared": "3.4.25" } }, "node_modules/@vue/devtools-api": { @@ -2862,48 +2862,48 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.24.tgz", - "integrity": "sha512-nup3fSYg4i4LtNvu9slF/HF/0dkMQYfepUdORBcMSsankzRPzE7ypAFurpwyRBfU1i7Dn1kcwpYsE1wETSh91g==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.25.tgz", + "integrity": "sha512-mKbEtKr1iTxZkAG3vm3BtKHAOhuI4zzsVcN0epDldU/THsrvfXRKzq+lZnjczZGnTdh3ojd86/WrP+u9M51pWQ==", "dependencies": { - "@vue/shared": "3.4.24" + "@vue/shared": "3.4.25" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.24.tgz", - "integrity": "sha512-c7iMfj6cJMeAG3s5yOn9Rc5D9e2/wIuaozmGf/ICGCY3KV5H7mbTVdvEkd4ZshTq7RUZqj2k7LMJWVx+EBiY1g==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.25.tgz", + "integrity": "sha512-3qhsTqbEh8BMH3pXf009epCI5E7bKu28fJLi9O6W+ZGt/6xgSfMuGPqa5HRbUxLoehTNp5uWvzCr60KuiRIL0Q==", "dependencies": { - "@vue/reactivity": "3.4.24", - "@vue/shared": "3.4.24" + "@vue/reactivity": "3.4.25", + "@vue/shared": "3.4.25" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.24.tgz", - "integrity": "sha512-uXKzuh/Emfad2Y7Qm0ABsLZZV6H3mAJ5ZVqmAOlrNQRf+T5mxpPGZBfec1hkP41t6h6FwF6RSGCs/gd8WbuySQ==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.25.tgz", + "integrity": "sha512-ode0sj77kuwXwSc+2Yhk8JMHZh1sZp9F/51wdBiz3KGaWltbKtdihlJFhQG4H6AY+A06zzeMLkq6qu8uDSsaoA==", "dependencies": { - "@vue/runtime-core": "3.4.24", - "@vue/shared": "3.4.24", + "@vue/runtime-core": "3.4.25", + "@vue/shared": "3.4.25", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.24.tgz", - "integrity": "sha512-H+DLK4sQF6sRgzKyofmlEVBIV/9KrQU6HIV7nt6yIwSGGKvSwlV8pqJlebUKLpbXaNHugdSfAbP6YmXF69lxow==", + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.25.tgz", + "integrity": "sha512-8VTwq0Zcu3K4dWV0jOwIVINESE/gha3ifYCOKEhxOj6MEl5K5y8J8clQncTcDhKF+9U765nRw4UdUEXvrGhyVQ==", "dependencies": { - "@vue/compiler-ssr": "3.4.24", - "@vue/shared": "3.4.24" + "@vue/compiler-ssr": "3.4.25", + "@vue/shared": "3.4.25" }, "peerDependencies": { - "vue": "3.4.24" + "vue": "3.4.25" } }, "node_modules/@vue/shared": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.24.tgz", - "integrity": "sha512-BW4tajrJBM9AGAknnyEw5tO2xTmnqgup0VTnDAMcxYmqOX0RG0b9aSUGAbEKolD91tdwpA6oCwbltoJoNzpItw==" + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.25.tgz", + "integrity": "sha512-k0yappJ77g2+KNrIaF0FFnzwLvUBLUYr8VOwz+/6vLsmItFp51AcxLL7Ey3iPd7BIRyWPOcqUjMnm7OkahXllA==" }, "node_modules/@vue/tsconfig": { "version": "0.5.1", @@ -6695,15 +6695,15 @@ } }, "node_modules/vue": { - "version": "3.4.24", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.24.tgz", - "integrity": "sha512-NPdx7dLGyHmKHGRRU5bMRYVE+rechR+KDU5R2tSTNG36PuMwbfAJ+amEvOAw7BPfZp5sQulNELSLm5YUkau+Sg==", - "dependencies": { - "@vue/compiler-dom": "3.4.24", - "@vue/compiler-sfc": "3.4.24", - "@vue/runtime-dom": "3.4.24", - "@vue/server-renderer": "3.4.24", - "@vue/shared": "3.4.24" + "version": "3.4.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.25.tgz", + "integrity": "sha512-HWyDqoBHMgav/OKiYA2ZQg+kjfMgLt/T0vg4cbIF7JbXAjDexRf5JRg+PWAfrAkSmTd2I8aPSXtooBFWHB98cg==", + "dependencies": { + "@vue/compiler-dom": "3.4.25", + "@vue/compiler-sfc": "3.4.25", + "@vue/runtime-dom": "3.4.25", + "@vue/server-renderer": "3.4.25", + "@vue/shared": "3.4.25" }, "peerDependencies": { "typescript": "*" diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 857988f..afc1331 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -101,6 +101,13 @@ watch( >S3 Bucket Keys </router-link> </li> + <li> + <router-link + class="dropdown-item" + :to="{ name: 's3_multipart-uploads' }" + >Multipart Uploads + </router-link> + </li> </ul> </li> <li class="nav-item dropdown"> diff --git a/src/components/object-storage/BucketListItem.vue b/src/components/object-storage/BucketListItem.vue index e27f990..ce41b99 100644 --- a/src/components/object-storage/BucketListItem.vue +++ b/src/components/object-storage/BucketListItem.vue @@ -89,23 +89,24 @@ function toggleBucketPublicState() { onMounted(() => { if (!props.loading) { new Tooltip("#tooltip-" + randomIDSuffix); - new Tooltip("#ownBucketIcon-" + randomIDSuffix); - new Tooltip("#sharedBucketIcon-" + randomIDSuffix); - successToast = new Toast("#success-public-bucket-" + randomIDSuffix); - errorToast = new Toast("#error-public-bucket-" + randomIDSuffix); + new Tooltip(`#own-bucket-icon-${randomIDSuffix}`); + new Tooltip(`#shared-bucket-icon-${randomIDSuffix}`); + new Tooltip(`#public-bucket-icon-${randomIDSuffix}`); + successToast = new Toast(`#success-public-bucket-${randomIDSuffix}`); + errorToast = new Toast(`#error-public-bucket-${randomIDSuffix}`); } }); </script> <template> <bootstrap-toast - :toast-id="'success-public-bucket-' + randomIDSuffix" + :toast-id="`success-public-bucket-${randomIDSuffix}`" v-if="!loading" > Bucket {{ bucket.name }} is now {{ bucket.public ? "public" : "private" }} </bootstrap-toast> <bootstrap-toast - :toast-id="'error-public-bucket-' + randomIDSuffix" + :toast-id="`error-public-bucket-${randomIDSuffix}`" color-class="danger" v-if="!loading" > @@ -127,7 +128,7 @@ onMounted(() => { /> <bucket-detail-modal v-if="props.active" - :modalId="'view-bucket-details-modal' + randomIDSuffix" + :modalId="`view-bucket-details-modal-${randomIDSuffix}`" :bucket="props.bucket" :edit-user-permission="permission" /> @@ -157,9 +158,17 @@ onMounted(() => { {{ bucket.name }} </span> <div class="text-nowrap"> + <font-awesome-icon + :hidden="!bucket.public" + :id="`public-bucket-icon-${randomIDSuffix}`" + icon="fa-solid fa-earth-americas" + class="me-2" + data-bs-toogle="tooltip" + data-bs-title="Public Bucket" + /> <font-awesome-icon :hidden="bucket.owner_id !== userRepository.currentUID" - :id="'ownBucketIcon-' + randomIDSuffix" + :id="`own-bucket-icon-${randomIDSuffix}`" icon="fa-solid fa-user" :class="{ 'me-2': @@ -179,7 +188,7 @@ onMounted(() => { (permissionRepository.bucketPermissionsMapping[bucket.name] ?? []) .length === 0 " - :id="'sharedBucketIcon-' + randomIDSuffix" + :id="`shared-bucket-icon-${randomIDSuffix}`" icon="fa-solid fa-users" :class="{ 'me-2': props.active }" data-bs-toogle="tooltip" @@ -194,7 +203,7 @@ onMounted(() => { <font-awesome-icon class="info-icon" data-bs-toggle="modal" - :data-bs-target="'#view-bucket-details-modal' + randomIDSuffix" + :data-bs-target="`#view-bucket-details-modal-${randomIDSuffix}`" v-if="props.active" icon="fa-solid fa-circle-info" /> diff --git a/src/components/object-storage/modals/CopyObjectModal.vue b/src/components/object-storage/modals/CopyObjectModal.vue index d03cfcb..69b6c92 100644 --- a/src/components/object-storage/modals/CopyObjectModal.vue +++ b/src/components/object-storage/modals/CopyObjectModal.vue @@ -135,7 +135,9 @@ onMounted(() => { > <option disabled selected>Select one...</option> <option - v-for="bucket in bucketRepository.writableBuckets" + v-for="bucket in bucketRepository.buckets.filter((b) => + bucketRepository.writableBucket(b.name), + )" :key="bucket.name" :value="bucket.name" > diff --git a/src/components/parameter-schema/form-mode/ParameterFileInput.vue b/src/components/parameter-schema/form-mode/ParameterFileInput.vue index 4bb6fe7..6b76c7a 100644 --- a/src/components/parameter-schema/form-mode/ParameterFileInput.vue +++ b/src/components/parameter-schema/form-mode/ParameterFileInput.vue @@ -178,11 +178,17 @@ onMounted(() => { > <option selected disabled value="">Please select a bucket</option> <option - v-for="bucket in bucketRepository.ownBucketsAndFullPermissions" - :key="bucket" - :value="bucket" + v-for="bucket in bucketRepository.buckets" + :key="bucket.name" + :value="bucket.name" > - {{ bucket }} + {{ bucket.name }} + <template v-if="!bucketRepository.writableBucket(bucket.name)" + >(read-only) + </template> + <template v-if="!bucketRepository.readableBucket(bucket.name)" + >(write-only) + </template> </option> </select> <input diff --git a/src/router/s3Routes.ts b/src/router/s3Routes.ts index bc7c73b..9ec2a42 100644 --- a/src/router/s3Routes.ts +++ b/src/router/s3Routes.ts @@ -28,4 +28,12 @@ export const s3Routes: RouteRecordRaw[] = [ }, component: () => import("../views/object-storage/S3KeysView.vue"), }, + { + path: "object-storage/multipart-uploads", + name: "s3_multipart-uploads", + meta: { + title: "Multipart Uploads", + }, + component: () => import("../views/object-storage/MultiPartUploadsView.vue"), + }, ]; diff --git a/src/stores/buckets.ts b/src/stores/buckets.ts index 53ada47..4691117 100644 --- a/src/stores/buckets.ts +++ b/src/stores/buckets.ts @@ -3,7 +3,6 @@ import { BucketPermissionService, BucketService, BucketType, - Permission, } from "@/client/s3proxy"; import type { BucketOut, @@ -34,61 +33,44 @@ export const useBucketStore = defineStore({ }); return tempList; }, - ownBucketsAndFullPermissions(): string[] { + ownBuckets(): BucketOut[] { const authStore = useAuthStore(); - const names = this.buckets - .filter((bucket) => bucket.owner_id === authStore.currentUID) - .map((bucket) => bucket.name) - .concat( - Object.values(this.ownPermissions) - .filter((perm) => perm.permission === Permission.READWRITE) - .map((perm) => perm.bucket_name), - ); - names.sort(); - return names; + return this.buckets.filter( + (bucket) => bucket.owner_id === authStore.currentUID, + ); }, getBucketPermissions(): (bucketName: string) => BucketPermissionOut[] { return (bucketName) => this.bucketPermissionsMapping[bucketName] ?? []; }, permissionFeatureAllowed(): (bucketName: string) => boolean { + const authStore = useAuthStore(); return (bucketName) => { // If a permission for the bucket exist, then false if (this.ownPermissions[bucketName] != undefined) { return false; } // If the bucket doesn't exist, then false - return this.bucketMapping[bucketName] != undefined; + return this.bucketMapping[bucketName]?.owner_id == authStore.currentUID; }; }, - writableBuckets(): BucketOut[] { - return this.buckets.filter((bucket) => { - if (this.ownPermissions[bucket.name] != undefined) { - return this.ownPermissions[bucket.name].permission !== "READ"; - } - return true; - }); - }, writableBucket(): (bucketName: string) => boolean { + const authStore = useAuthStore(); return (bucketName) => { // If this is a foreign bucket, check that the user has write permission if (this.ownPermissions[bucketName] != undefined) { return this.ownPermissions[bucketName].permission !== "READ"; } - // If the bucket doesn't exist, then false - return this.bucketMapping[bucketName] != undefined; + return this.bucketMapping[bucketName]?.owner_id == authStore.currentUID; }; }, readableBucket(): (bucketName: string) => boolean { + const authStore = useAuthStore(); return (bucketName) => { // If this is a foreign bucket, check that the user has read permission if (this.ownPermissions[bucketName] != undefined) { return this.ownPermissions[bucketName].permission !== "WRITE"; } - // If the user owns the bucket, check the bucket constraint - if (this.bucketMapping[bucketName] != null) { - return true; - } - return false; + return this.bucketMapping[bucketName]?.owner_id == authStore.currentUID; }; }, }, @@ -134,7 +116,7 @@ export const useBucketStore = defineStore({ delete this.bucketMapping[bucketName]; }, fetchOwnBuckets(onFinally?: () => void): Promise<BucketOut[]> { - if (this.buckets.length > 0) { + if (this.ownBuckets.length > 0) { onFinally?.(); } const userStore = useAuthStore(); diff --git a/src/stores/s3objects.ts b/src/stores/s3objects.ts index 149a3eb..8072183 100644 --- a/src/stores/s3objects.ts +++ b/src/stores/s3objects.ts @@ -1,9 +1,15 @@ import { defineStore } from "pinia"; -import type { _Object as S3Object, HeadObjectOutput } from "@aws-sdk/client-s3"; +import type { + _Object as S3Object, + HeadObjectOutput, + MultipartUpload, +} from "@aws-sdk/client-s3"; import { + AbortMultipartUploadCommand, CopyObjectCommand, GetObjectCommand, HeadObjectCommand, + ListMultipartUploadsCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -20,6 +26,7 @@ import dayjs from "dayjs"; import { Upload } from "@aws-sdk/lib-storage"; import type { Progress } from "@aws-sdk/lib-storage"; import type { AbortController } from "@smithy/types"; +import { useAuthStore } from "@/stores/users"; export const useS3ObjectStore = defineStore({ id: "s3objects", @@ -27,6 +34,7 @@ export const useS3ObjectStore = defineStore({ ({ objectMapping: {}, objectMetaMapping: {}, + multiPartUploadsMapping: {}, client: new S3Client({ region: "us-east-1", endpoint: environment.S3_URL, @@ -39,6 +47,7 @@ export const useS3ObjectStore = defineStore({ }) as { objectMapping: Record<string, S3Object[]>; objectMetaMapping: Record<string, HeadObjectOutput>; + multiPartUploadsMapping: Record<string, MultipartUpload[]>; client: S3Client; }, getters: { @@ -96,6 +105,28 @@ export const useS3ObjectStore = defineStore({ }, }); }, + fetchMultipartUploads(bucketName: string): Promise<MultipartUpload[]> { + const listCommand = new ListMultipartUploadsCommand({ + Bucket: bucketName, + }); + return this.client + .send(listCommand) + .then((response) => response.Uploads ?? []) + .then((uploads) => { + this.multiPartUploadsMapping[bucketName] = uploads; + const userRepository = useAuthStore(); + userRepository.fetchUsernames( + uploads + .map((upload) => upload.Initiator?.ID ?? "") + .filter( + (initiator) => + initiator.length > 0 && + initiator != userRepository.currentUID, + ), + ); + return uploads; + }); + }, async fetchS3Objects( bucketName: string, prefix?: string, @@ -237,7 +268,23 @@ export const useS3ObjectStore = defineStore({ if (onProgress != undefined) { parallelUploads3.on("httpUploadProgress", onProgress); } - await parallelUploads3.done(); + try { + await parallelUploads3.done(); + } catch (e) { + // if there is an error with the multipart upload, send an abort multipart upload command + const uploadObject = JSON.parse(JSON.stringify(parallelUploads3)); + if (uploadObject["isMultiPart"] && uploadObject["uploadId"]) { + await this.client.send( + new AbortMultipartUploadCommand({ + // AbortMultipartUploadRequest + Bucket: bucketName, // required + Key: key, // required + UploadId: uploadObject["uploadId"], // required + }), + ); + } + throw e; + } const newObj = { Key: key, Size: file.size ?? 0, @@ -263,5 +310,24 @@ export const useS3ObjectStore = defineStore({ return newObj; }); }, + abortMultipartUpload( + bucketName: string, + key: string, + uploadId: string, + ): Promise<void> { + const cmd = new AbortMultipartUploadCommand({ + Bucket: bucketName, + Key: key, + UploadId: uploadId, + }); + return this.client.send(cmd).then(() => { + const index = this.multiPartUploadsMapping[bucketName]?.findIndex( + (upload) => upload.UploadId == uploadId, + ); + if (index != undefined && index > -1) { + this.multiPartUploadsMapping[bucketName]?.splice(index, 1); + } + }); + }, }, }); diff --git a/src/views/object-storage/MultiPartUploadsView.vue b/src/views/object-storage/MultiPartUploadsView.vue new file mode 100644 index 0000000..9fe9132 --- /dev/null +++ b/src/views/object-storage/MultiPartUploadsView.vue @@ -0,0 +1,199 @@ +<script setup lang="ts"> +import { computed, onMounted, reactive } from "vue"; +import { useS3ObjectStore } from "@/stores/s3objects"; +import { useBucketStore } from "@/stores/buckets"; +import { useNameStore } from "@/stores/names"; +import dayjs from "dayjs"; +import { useAuthStore } from "@/stores/users"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { Tooltip } from "bootstrap"; +import { useS3KeyStore } from "@/stores/s3keys"; + +const objectRepository = useS3ObjectStore(); +const bucketRepository = useBucketStore(); +const nameRepository = useNameStore(); +const userRepository = useAuthStore(); +const s3keyRepository = useS3KeyStore(); + +let refreshTimeout: NodeJS.Timeout | undefined = undefined; + +const uploadState = reactive<{ + loading: boolean; + initialFetched: boolean; +}>({ + loading: true, + initialFetched: false, +}); + +function abortMultipartUpload( + bucketName: string, + key?: string, + uploadId?: string, +) { + if (uploadId && key) { + objectRepository.abortMultipartUpload(bucketName, key, uploadId).then(); + } +} + +const uploadsInOwnBucketsPresent = computed<boolean>(() => { + for (const entries of Object.entries( + objectRepository.multiPartUploadsMapping, + )) { + const bucketName = entries[0]; + const uploads = entries[1]; + if ( + uploads.length > 0 && + bucketRepository.bucketMapping[bucketName]?.owner_id == + userRepository.currentUID + ) { + return true; + } + } + return false; +}); + +function refreshMultipartUploads() { + clearTimeout(refreshTimeout); + refreshTimeout = setTimeout(() => { + fetchMultipartUploads(); + }, 500); +} + +function fetchMultipartUploads() { + Promise.all( + bucketRepository.ownBuckets.map((bucket) => + objectRepository.fetchMultipartUploads(bucket.name), + ), + ).finally(() => { + uploadState.loading = false; + }); +} + +onMounted(() => { + // if uploads where already fetched, then don't show a loading screen + if (Object.keys(objectRepository.multiPartUploadsMapping).length > 0) { + uploadState.loading = false; + } + // First fetch s3 keys and then fetch uploads + s3keyRepository.fetchS3Keys(() => { + if (!uploadState.initialFetched) { + uploadState.initialFetched = true; + if (bucketRepository.ownBuckets.length > 0) { + fetchMultipartUploads(); + } else { + bucketRepository.fetchOwnBuckets().finally(fetchMultipartUploads); + } + } + }); + Tooltip.getOrCreateInstance("#refreshUploadsButton"); +}); +</script> + +<template> + <div + class="row border-bottom mb-4 pb-2 justify-content-between align-items-center" + > + <h2 class="w-fit mb-0">Multipart Uploads</h2> + <div class="w-fit"> + <button + class="btn btn-light me-2 shadow-sm border" + @click="refreshMultipartUploads" + id="refreshUploadsButton" + data-bs-title="Refresh Multipart Uploads" + data-bs-toggle="tooltip" + > + <font-awesome-icon icon="fa-solid fa-arrow-rotate-right" /> + <span class="visually-hidden">Refresh Data Buckets</span> + </button> + </div> + </div> + <p> + When uploading large files to S3, a multipart upload process is utilized. + This involves breaking the file into smaller chunks, which are then uploaded + asynchronously. While these uploaded parts contribute to the bucket quotas, + they are not displayed when listing all objects in a bucket. If a multipart + upload is left incomplete or not properly aborted, these chunks can + accumulate in your bucket without your awareness. However, aborting the + erroneous multipart upload will release the occupied space. + </p> + <div v-if="uploadState.loading" class="text-center mt-5"> + <div class="spinner-border" role="status" style="width: 3rem; height: 3rem"> + <span class="visually-hidden">Loading...</span> + </div> + </div> + <div class="accordion mt-2" v-else-if="uploadsInOwnBucketsPresent"> + <template v-for="bucket in bucketRepository.ownBuckets" :key="bucket"> + <div + class="accordion-item" + v-if=" + objectRepository.multiPartUploadsMapping[bucket.name] && + objectRepository.multiPartUploadsMapping[bucket.name].length > 0 + " + > + <h2 class="accordion-header"> + <button + class="accordion-button" + type="button" + data-bs-toggle="collapse" + :data-bs-target="`#collapse-${bucket.name}`" + aria-expanded="true" + :aria-controls="`#collapse-${bucket.name}`" + > + Bucket: {{ bucket.name }} + </button> + </h2> + <div + :id="`collapse-${bucket.name}`" + class="accordion-collapse collapse show" + > + <div class="accordion-body"> + <table class="table table-striped align-middle"> + <thead> + <tr> + <th scope="col">Key</th> + <th scope="col">Initiator</th> + <th scope="col">Initiated</th> + <th></th> + </tr> + </thead> + <tbody> + <tr + v-for="upload in objectRepository.multiPartUploadsMapping[ + bucket.name + ]" + :key="upload.UploadId" + > + <th scope="row">{{ upload.Key }}</th> + <td>{{ nameRepository.getName(upload.Initiator?.ID) }}</td> + <td> + {{ dayjs(upload.Initiated).format("YYYY-MM-DD HH:mm:ss") }} + </td> + <th class="text-end"> + <button + type="button" + class="btn btn-danger btn-sm" + @click=" + abortMultipartUpload( + bucket.name, + upload.Key, + upload.UploadId, + ) + " + > + Abort + </button> + </th> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </template> + </div> + <div v-else class="text-center fst-italic fs-5 mt-5"> + No multipart uploads + </div> +</template> + +<style scoped></style> -- GitLab