| // 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 () { |
| if (!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) => { |
| this.client.putObject(this.resource.name, objectName, Buffer.from(buffer), file.size, this.uploadMetaData, 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> |