| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| <template> | |
| <a-modal | |
| :visible="showUploadModal" | |
| :closable="true" | |
| :destroyOnClose="true" | |
| :title="$t('label.upload')" | |
| :maskClosable="false" | |
| :cancelText="$t('label.cancel')" | |
| @cancel="() => showUploadModal = false" | |
| :okText="$t('label.upload')" | |
| @ok="uploadFiles()" | |
| centered | |
| > | |
| <a-upload-dragger | |
| :multiple="true" | |
| :v-model:file-list="uploadFileList" | |
| listType="picture" | |
| :beforeUpload="beforeUpload"> | |
| <p class="ant-upload-drag-icon"> | |
| <cloud-upload-outlined /> | |
| </p> | |
| <p class="ant-upload-text"> | |
| {{ $t('label.volume.volumefileupload.description') }} | |
| </p> | |
| </a-upload-dragger> | |
| <a-divider dashed/> | |
| <tooltip-label bold :title="$t('label.upload.path')" :tooltip="$t('label.upload.description')"/> | |
| <br/> | |
| <a-input | |
| v-model:value="uploadDirectory" | |
| :placeholder="$t('label.upload.description')" | |
| :loading="loading" | |
| enter-button/> | |
| <a-divider dashed/> | |
| <tooltip-label bold :title="$t('label.metadata')" :tooltip="$t('label.metadata.upload.description')"/> | |
| <KeyValuePairInput :pairs="uploadMetaData" @update-pairs="(pairs) => uploadMetaData = pairs" /> | |
| </a-modal> | |
| <a-drawer | |
| :visible="showObjectDetails" | |
| :closable="true" | |
| :maskClosable="true" | |
| @close="() => showObjectDetails = false" | |
| :title="record.name" | |
| > | |
| <div> | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="$t('label.name')" bold/> | |
| </a-col> | |
| <a-col> | |
| {{ record.name.split('/').pop() }} | |
| </a-col> | |
| </a-row> | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="$t('label.size')" bold/> | |
| </a-col> | |
| <a-col> | |
| {{ convertBytes(record.size) }} | |
| </a-col> | |
| </a-row> | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="$t('label.last.updated')" bold/> | |
| </a-col> | |
| <a-col> | |
| {{ $toLocaleDate(record.lastModified) }} | |
| </a-col> | |
| </a-row> | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="$t('label.url')" :tooltip="$t('label.object.url.description')" bold/> | |
| </a-col> | |
| <a-col> | |
| <a :href="record.url">{{ $t('label.link') }}</a> | |
| </a-col> | |
| </a-row> | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="$t('label.object.presigned.url')" :tooltip="$t('label.object.presigned.url.description')" bold /> | |
| </a-col> | |
| <a-col> | |
| <a :href="record.presignedUrl">{{ $t('label.link') }}</a> | |
| </a-col> | |
| </a-row> | |
| <a-divider> | |
| <tooltip-label :title="$t('label.metadata')" :tooltip="$t('label.metadata.description')"/> | |
| </a-divider> | |
| <template | |
| v-for="(value,key) in record.metadata" | |
| :key="key" | |
| > | |
| <a-row justify="space-between"> | |
| <a-col> | |
| <tooltip-label :title="key" bold /> | |
| </a-col> | |
| <a-col> | |
| {{ value }} | |
| </a-col> | |
| </a-row> | |
| </template> | |
| </div> | |
| </a-drawer> | |
| <div> | |
| <a-card class="breadcrumb-card"> | |
| <a-row> | |
| <a-breadcrumb :routes="getRoutes()"> | |
| <template #itemRender="{ route }"> | |
| <span v-if="[''].includes(route.path) && route.breadcrumbName === 'root'"> | |
| <a @click="openDir('')"> | |
| <HomeOutlined/> | |
| </a> | |
| </span> | |
| <span v-else> | |
| <a @click="openDir(route.path)"> | |
| {{ route.breadcrumbName }} | |
| </a> | |
| </span> | |
| </template> | |
| </a-breadcrumb> | |
| </a-row> | |
| <a-divider/> | |
| <a-row :gutter="[10,10]" :wrap="true"> | |
| <a-col flex="75%"> | |
| <a-input-search | |
| allowClear | |
| size="medium" | |
| v-model:value="searchPrefix" | |
| :placeholder="$t('label.objectstore.search')" | |
| :loading="loading" | |
| @search="listObjects()" | |
| :enter-button="$t('label.search')"/> | |
| </a-col> | |
| <a-col flex="auto"> | |
| <a-button | |
| :loading="loading" | |
| style="margin-bottom: 5px" | |
| shape="round" | |
| size="medium" | |
| @click="listObjects()"> | |
| <reload-outlined /> | |
| {{ $t('label.refresh') }} | |
| </a-button> | |
| </a-col> | |
| <a-col flex="auto"> | |
| <a-button | |
| :loading="loading" | |
| style="margin-bottom: 5px" | |
| shape="round" | |
| size="medium" | |
| type="primary" | |
| @click="() => showUploadModal = true"> | |
| <upload-outlined /> | |
| {{ $t('label.upload') }} | |
| </a-button> | |
| </a-col> | |
| <a-col flex="auto"> | |
| <tooltip-button | |
| type="primary" | |
| size="medium" | |
| icon="delete-outlined" | |
| :tooltip="$t('label.delete')" | |
| v-if="selectedRows.length > 0" | |
| :danger="true" | |
| @onClick="removeObjects()"/> | |
| </a-col> | |
| </a-row> | |
| </a-card> | |
| <div> | |
| <a-table | |
| :columns="columns" | |
| :scroll="{ y: 300 }" | |
| :row-key="record => record" | |
| :data-source="records" | |
| :loading="loading" | |
| :pagination="{ current: page, pageSize: 1000, total: total, showSizeChanger: false }" | |
| :row-selection="{ selectedRowsKeys: selectedRows, onChange: onSelectChange }" | |
| @change="handleTableChange"> | |
| <template #bodyCell="{ column, record }"> | |
| <template v-if="column.key == 'name'"> | |
| <template v-if="record.name === undefined && record.prefix"> | |
| <a @click="openDir(record.prefix)"> | |
| <folder-outlined /> {{ record.prefix.replace(this.browserPath, '').replace('/', '') }} | |
| </a> | |
| </template> | |
| <template v-else> | |
| <a @click="showObjectDescription(record)"> | |
| {{ record.name.split('/').pop() }} | |
| </a> | |
| </template> | |
| </template> | |
| <template v-else-if="column.key == 'size'"> | |
| <template v-if="record.name !== undefined && !record.prefix"> | |
| {{ convertBytes(record.size) }} | |
| </template> | |
| </template> | |
| <template v-else-if="column.key == 'lastModified' && record.lastModified"> | |
| {{ $toLocaleDate(record.lastModified) }} | |
| </template> | |
| </template> | |
| </a-table> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { reactive } from 'vue' | |
| import * as Minio from 'minio' | |
| import { genericCompare } from '@/utils/sort.js' | |
| import InfoCard from '@/components/view/InfoCard' | |
| import TooltipButton from '@/components/widgets/TooltipButton' | |
| import TooltipLabel from '@/components/widgets/TooltipLabel' | |
| import KeyValuePairInput from '@/components/KeyValuePairInput' | |
| export default { | |
| name: 'ObjectStoreBrowser', | |
| components: { | |
| InfoCard, | |
| TooltipButton, | |
| TooltipLabel, | |
| KeyValuePairInput | |
| }, | |
| props: { | |
| resource: { | |
| type: Object, | |
| required: true | |
| }, | |
| resourceType: { | |
| type: String, | |
| required: true | |
| } | |
| }, | |
| data () { | |
| var columns = [ | |
| { | |
| key: 'name', | |
| title: this.$t('label.name'), | |
| sorter: (a, b) => genericCompare(a?.name || '', b?.name || '') | |
| }, | |
| { | |
| key: 'size', | |
| title: this.$t('label.size'), | |
| sorter: (a, b) => genericCompare(a?.size || '', b?.size || '') | |
| }, | |
| { | |
| key: 'lastModified', | |
| title: this.$t('label.last.updated'), | |
| sorter: (a, b) => genericCompare(a?.lastModified || '', b?.lastModified || '') | |
| } | |
| ] | |
| return { | |
| client: null, | |
| loading: false, | |
| records: [], | |
| browserPath: this.$route.query.browserPath || '', | |
| page: 1, | |
| pageStartAfterMap: { 1: '' }, | |
| total: 0, | |
| columns: columns, | |
| selectedRows: [], | |
| searchPrefix: '', | |
| showUploadModal: false, | |
| uploadFileList: reactive([]), | |
| uploadDirectory: this.$route.query.browserPath || '', | |
| uploadMetaData: {}, | |
| record: {}, | |
| showObjectDetails: false, | |
| fetching: false | |
| } | |
| }, | |
| created () { | |
| this.fetchData() | |
| }, | |
| methods: { | |
| handleTableChange (pagination, filters, sorter) { | |
| if (this.page !== pagination.current) { | |
| this.page = pagination.current | |
| this.fetchData() | |
| } | |
| }, | |
| fetchData () { | |
| this.loading = true | |
| this.records = [] | |
| this.$router.replace( | |
| { | |
| query: { | |
| ...this.$route.query, | |
| browserPath: this.browserPath | |
| } | |
| } | |
| ) | |
| if (!this.client) { | |
| this.initMinioClient() | |
| } else { | |
| this.listObjects() | |
| } | |
| }, | |
| getRoutes () { | |
| let path = '' | |
| const routeList = [{ | |
| path: path, | |
| breadcrumbName: 'root' | |
| }] | |
| for (const route of this.browserPath.split('/')) { | |
| if (route) { | |
| path = `${path}${route}/` | |
| routeList.push({ | |
| path: path, | |
| breadcrumbName: route | |
| }) | |
| } | |
| } | |
| return routeList | |
| }, | |
| convertBytes (val) { | |
| if (val < 1024 * 1024) return `${(val / 1024).toFixed(2)} KB` | |
| if (val < 1024 * 1024 * 1024) return `${(val / 1024 / 1024).toFixed(2)} MB` | |
| if (val < 1024 * 1024 * 1024 * 1024) return `${(val / 1024 / 1024 / 1024).toFixed(2)} GB` | |
| if (val < 1024 * 1024 * 1024 * 1024 * 1024) return `${(val / 1024 / 1024 / 1024 / 1024).toFixed(2)} TB` | |
| return val | |
| }, | |
| openDir (name) { | |
| if (name === '/') { | |
| name = '' | |
| } | |
| this.browserPath = name | |
| this.uploadDirectory = name | |
| this.page = 1 | |
| this.fetchData() | |
| }, | |
| listObjects () { | |
| while (this.fetching) { | |
| // sleep for 500ms | |
| setTimeout(() => { | |
| console.log('waiting for previous request to complete') | |
| }, 500) | |
| } | |
| this.fetching = true | |
| this.records = [] | |
| var stream = this.client.extensions.listObjectsV2WithMetadata(this.resource.name, this.browserPath + this.searchPrefix, false, this.pageStartAfterMap[this.page]) | |
| stream.on('data', obj => { | |
| this.records.push(obj) | |
| if (this.records.length >= 1000) { | |
| stream.destroy() | |
| } | |
| }) | |
| stream.on('end', obj => { | |
| var total = 0 | |
| if (this.records.length > 0) { | |
| if (this.records.length >= 1000) { | |
| total = (this.page + 1) * 1000 | |
| if (total > this.total) { | |
| this.total = total | |
| } | |
| } else { | |
| total = (this.page - 1) * 1000 + this.records.length | |
| } | |
| this.pageStartAfterMap[this.page + 1] = this.records[this.records.length - 1].name | |
| } | |
| if (total > this.total) { | |
| this.total = total | |
| } | |
| this.loading = false | |
| this.fetching = false | |
| }) | |
| stream.on('error', err => { | |
| console.log(err) | |
| this.loading = false | |
| this.fetching = false | |
| }) | |
| }, | |
| removeObjects () { | |
| this.loading = true | |
| this.page = 1 | |
| this.pageStartAfterMap = { 1: '' } | |
| const objectsToDelete = this.selectedRows.filter((row) => row.name).map((row) => row.name) | |
| const directoriesToDelete = this.selectedRows.filter((row) => row.prefix).map((row) => row.prefix) | |
| this.selectedRows = [] | |
| this.removeDirectories(directoriesToDelete) | |
| if (objectsToDelete.length > 0) { | |
| this.client.removeObjects(this.resource.name, objectsToDelete, err => { | |
| if (err) { | |
| return this.$notification.error({ | |
| message: this.$t('error.execute.api.failed'), | |
| description: err.message | |
| }) | |
| } | |
| this.$notification.success({ | |
| message: this.$t('label.delete'), | |
| description: this.$t('message.success.remove.objectstore.objects') + ' ' + objectsToDelete.length | |
| }) | |
| this.listObjects() | |
| }) | |
| } | |
| }, | |
| removeDirectories (directoriesToDelete) { | |
| for (const directory of directoriesToDelete) { | |
| var objectsList = [] | |
| const stream = this.client.listObjectsV2(this.resource.name, directory, true, '') | |
| stream.on('data', (obj) => { | |
| objectsList.push(obj.name) | |
| }) | |
| stream.on('error', (err) => { | |
| console.log(err) | |
| }) | |
| stream.on('end', (err) => { | |
| if (err) { | |
| return console.log(err) | |
| } | |
| this.client.removeObjects(this.resource.name, objectsList, err => { | |
| if (err) { | |
| return this.$notification.error({ | |
| message: this.$t('error.execute.api.failed'), | |
| description: err.message | |
| }) | |
| } | |
| this.$notification.success({ | |
| message: this.$t('label.delete'), | |
| description: this.$t('message.success.remove.objectstore.directory') + ' ' + directory | |
| }) | |
| console.log('Removed the objects successfully') | |
| this.listObjects() | |
| }) | |
| }) | |
| } | |
| }, | |
| initMinioClient () { | |
| if (!this.client) { | |
| const url = /https?:\/\/([^/]+)\/?/.exec(this.resource.url.split(this.resource.name)[0])[1] | |
| const isHttps = /^https/.test(this.resource.url) | |
| this.client = new Minio.Client({ | |
| endPoint: url.split(':')[0], | |
| port: url.split(':').length > 1 ? parseInt(url.split(':')[1]) : isHttps ? 443 : 80, | |
| useSSL: isHttps, | |
| accessKey: this.resource.accesskey, | |
| secretKey: this.resource.usersecretkey | |
| }) | |
| this.listObjects() | |
| } | |
| }, | |
| onSelectChange (selectedRow) { | |
| this.selectedRows = selectedRow | |
| }, | |
| beforeUpload (file) { | |
| this.uploadFileList.push(file) | |
| return false | |
| }, | |
| uploadFiles () { | |
| this.uploadDirectory = this.uploadDirectory.trim() | |
| if (this.uploadDirectory.length !== 0 && !this.uploadDirectory.endsWith('/')) { | |
| this.uploadDirectory = this.uploadDirectory + '/' | |
| } | |
| var promises = [] | |
| while (this.uploadFileList.length > 0) { | |
| const file = this.uploadFileList.pop() | |
| const objectName = this.uploadDirectory + file.name | |
| promises.push(this.asyncUploadFile(file, objectName)) | |
| } | |
| Promise.allSettled(promises).then(() => { | |
| this.uploadDirectory = this.browserPath | |
| this.uploadMetaData = {} | |
| this.uploadFileList = [] | |
| this.listObjects() | |
| }) | |
| this.showUploadModal = false | |
| }, | |
| asyncUploadFile (file, objectName) { | |
| return new Promise((resolve, reject) => { | |
| file.arrayBuffer().then((buffer) => { | |
| const metadata = { | |
| ...this.uploadMetaData, | |
| 'Content-Type': file.type || 'binary/octet-stream' | |
| } | |
| this.client.putObject(this.resource.name, objectName, Buffer.from(buffer), file.size, metadata, err => { | |
| if (err) { | |
| return reject(this.$notification.error({ | |
| message: this.$t('message.upload.failed'), | |
| description: err.message | |
| })) | |
| } | |
| return resolve(this.$notification.success({ | |
| message: this.$t('message.success.upload'), | |
| description: objectName.split('/').pop() | |
| })) | |
| }) | |
| }) | |
| }) | |
| }, | |
| showObjectDescription (record) { | |
| this.record = { ...record } | |
| this.record.url = this.resource.url + '/' + record.name | |
| this.client.presignedGetObject(this.resource.name, record.name, 24 * 60 * 60, (err, presignedUrl) => { | |
| if (err) { | |
| return this.$notification.error({ | |
| message: this.$t('error.execute.api.failed'), | |
| description: err.message | |
| }) | |
| } else { | |
| this.record.presignedUrl = presignedUrl | |
| } | |
| this.showObjectDetails = true | |
| }) | |
| }, | |
| updateMetadata () { | |
| this.client.copyObject( | |
| new Minio.CopySourceOptions({ Bucket: this.resource.name, Object: this.record.name }), | |
| new Minio.CopyDestinationOptions({ Bucket: this.resource.name, Object: this.record.name, MetadataDirective: 'REPLACE', UserMetadata: this.record.metadata }), | |
| err => { | |
| if (err) { | |
| this.$notification.error({ | |
| message: this.$t('error.execute.api.failed'), | |
| description: err.message | |
| }) | |
| } | |
| this.$notification.success({ | |
| message: this.$t('label.metadata'), | |
| description: this.$t('message.update.success') | |
| }) | |
| this.listObjects() | |
| }) | |
| } | |
| } | |
| } | |
| </script> |