mirror of
https://github.com/rustfs/console.git
synced 2026-08-31 01:37:52 +08:00
refactor: migrate replication ui to shadcn components
This commit is contained in:
+305
-347
@@ -1,173 +1,163 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="t('Add Bucket Replication Rule', { bucket: bucketName })"
|
||||
class="max-w-screen-md"
|
||||
:segmented="{
|
||||
content: true,
|
||||
action: true,
|
||||
}"
|
||||
<AppModal
|
||||
v-model="visible"
|
||||
:title="t('Add Replication Rule') + ` (${t('Bucket')}: ${bucketName})`"
|
||||
size="xl"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<n-card>
|
||||
<n-form label-placement="left" :label-width="100" ref="formRef" :model="formData">
|
||||
<n-form-item :label="t('Priority')" path="level">
|
||||
<n-input v-model:value="formData.level" :placeholder="t('Please enter priority')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Target Address')" path="endpoint">
|
||||
<n-input
|
||||
v-model:value="formData.endpoint"
|
||||
:placeholder="t('Please enter target address') + ':127.0.0.1:9000'"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Use TLS')" path="tls">
|
||||
<n-switch v-model:value="formData.tls" :round="false" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Access Key')" path="accesskey">
|
||||
<n-input v-model:value="formData.accesskey" :placeholder="t('Please enter Access Key')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Secret Key')" path="secrretkey">
|
||||
<n-input v-model:value="formData.secrretkey" :placeholder="t('Please enter Secret Key')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Target Bucket')" path="bucket">
|
||||
<n-input v-model:value="formData.bucket" :placeholder="t('Please enter target bucket')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Region')" path="region">
|
||||
<n-input v-model:value="formData.region" :placeholder="t('Please enter region')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Replication Mode')" path="modeType">
|
||||
<n-select
|
||||
v-model:value="formData.modeType"
|
||||
:placeholder="t('Please select replication mode')"
|
||||
filterable
|
||||
:options="modes"
|
||||
/>
|
||||
</n-form-item>
|
||||
<div class="space-y-6">
|
||||
<AppCard padded class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Priority') }}</Label>
|
||||
<AppInput v-model="formData.level" type="number" min="1" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Mode') }}</Label>
|
||||
<AppSelect v-model="formData.modeType" :options="modeOptions" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Endpoint') }}</Label>
|
||||
<AppInput v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Bucket') }}</Label>
|
||||
<AppInput v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Access Key') }}</Label>
|
||||
<AppInput v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Secret Key') }}</Label>
|
||||
<AppInput v-model="formData.secrretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Region') }}</Label>
|
||||
<AppInput v-model="formData.region" :placeholder="t('Please enter region')" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Storage Class') }}</Label>
|
||||
<AppInput v-model="formData.storageType" :placeholder="t('Please enter storage class')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form-item v-if="formData.modeType === 'async'" :label="t('Bandwidth')" path="bandwidth">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="formData.bandwidth" :placeholder="t('Please enter bandwidth')" />
|
||||
<n-select
|
||||
v-model:value="formData.unit"
|
||||
:placeholder="t('Please select unit')"
|
||||
filterable
|
||||
:options="units"
|
||||
/>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Health Check Duration')" path="timecheck">
|
||||
<n-input v-model:value="formData.timecheck" :placeholder="t('Please enter health check duration')">
|
||||
<template #suffix>s</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Storage Type')" path="storageType">
|
||||
<n-input v-model:value="formData.storageType" :placeholder="t('Please enter storage type')" />
|
||||
</n-form-item>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Prefix') }}</Label>
|
||||
<AppInput v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
|
||||
</div>
|
||||
|
||||
<!-- 对象搜索 -->
|
||||
<n-card :title="t('Object Search')">
|
||||
<n-form-item :label="t('Prefix')">
|
||||
<n-input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Tags')">
|
||||
<n-dynamic-input
|
||||
v-model:value="formData.tags"
|
||||
preset="pair"
|
||||
:key-placeholder="t('Tag Name')"
|
||||
:value-placeholder="t('Tag Value')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<!-- 复制选项 -->
|
||||
<n-card class="my-4">
|
||||
<n-collapse>
|
||||
<n-collapse-item :title="t('Replication Options')" name="advanced">
|
||||
<div style="text-align: right">
|
||||
<n-form-item :label="t('Existing Objects')">
|
||||
<n-space align="center" justify="end">
|
||||
<n-switch v-model:value="formData.existingObject" :round="false" />
|
||||
<span class="ml-4 text-gray-500">{{ t('Replicate existing objects') }}</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<!-- <n-form-item :label="t('Metadata Sync')">
|
||||
<n-space align="center" justify="end">
|
||||
<n-switch v-model:value="formData.deleteAllExpired" :round="false" />
|
||||
<span class="ml-4 text-gray-500">{{ t("Sync metadata") }}</span>
|
||||
</n-space>
|
||||
</n-form-item> -->
|
||||
<n-form-item :label="t('Delete Marker')">
|
||||
<n-space align="center" justify="end">
|
||||
<n-switch v-model:value="formData.expiredDeleteMark" :round="false" />
|
||||
<span class="ml-4 text-gray-500">{{ t('Replicate soft delete') }}</span>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<!-- <n-form-item :label="t('Delete')">
|
||||
<n-space align="center" justify="end">
|
||||
<n-switch v-model:value="formData.deleteforever" :round="false" />
|
||||
<span class="ml-4 text-gray-500">{{ t("Replicate version delete") }}</span>
|
||||
</n-space>
|
||||
</n-form-item> -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
|
||||
<AppButton variant="outline" size="sm" @click="addTag">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
{{ t('Add Tag') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<div v-if="formData.tags.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(tag, index) in formData.tags"
|
||||
:key="index"
|
||||
class="grid gap-2 rounded-md border border-border/60 p-3 md:grid-cols-2 md:items-center md:gap-4"
|
||||
>
|
||||
<AppInput v-model="tag.key" :placeholder="t('Tag Name')" />
|
||||
<div class="flex items-center gap-2">
|
||||
<AppInput v-model="tag.value" :placeholder="t('Tag Value')" class="flex-1" />
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive"
|
||||
:disabled="formData.tags.length === 1"
|
||||
@click="removeTag(index)"
|
||||
>
|
||||
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||
</AppButton>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<n-space justify="center">
|
||||
<n-button @click="handleCancel">{{ t('Cancel') }}</n-button>
|
||||
<n-button type="primary" @click="handleSave">{{ t('Save') }}</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<AppCard padded class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Use TLS') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('Enable secure transport when connecting to endpoint.') }}</p>
|
||||
</div>
|
||||
<AppSwitch v-model="formData.tls" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Replicate Existing Objects') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('Include objects that already exist in the source bucket.') }}</p>
|
||||
</div>
|
||||
<AppSwitch v-model="formData.existingObject" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Replicate Delete Markers') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('Sync delete markers to destination bucket.') }}</p>
|
||||
</div>
|
||||
<AppSwitch v-model="formData.expiredDeleteMark" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" v-if="formData.modeType === 'async'">
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Health Check Interval (seconds)') }}</Label>
|
||||
<AppInput
|
||||
v-model="formData.timecheck"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ t('Bandwidth Limit') }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppInput v-model="formData.bandwidth" type="number" min="0" class="w-32" />
|
||||
<AppSelect v-model="formData.unit" :options="unitOptions" class="w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="outline" @click="handleCancel">{{ t('Cancel') }}</AppButton>
|
||||
<AppButton variant="primary" :loading="submitting" @click="handleSave">{{ t('Save') }}</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { NForm, NFormItem, NInput, NDynamicInput, NCollapse, NCollapseItem, NSwitch, NButton } from 'naive-ui';
|
||||
import { useBucket } from '@/composables/useBucket';
|
||||
import { getBytes } from '@/utils/functions';
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '#components'
|
||||
import { AppButton, AppCard, AppInput, AppModal, AppSelect, AppSwitch } from '@/components/app'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getBytes } from '@/utils/functions'
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket({})
|
||||
|
||||
const modes = [
|
||||
{
|
||||
label: t('Sync'),
|
||||
value: 'sync',
|
||||
},
|
||||
{
|
||||
label: t('Async'),
|
||||
value: 'async',
|
||||
},
|
||||
];
|
||||
const props = defineProps<{
|
||||
bucketName: string
|
||||
}>()
|
||||
|
||||
const units = [
|
||||
{
|
||||
label: 'Mi',
|
||||
value: 'Mi',
|
||||
},
|
||||
{
|
||||
label: 'Gi',
|
||||
value: 'Gi',
|
||||
},
|
||||
{
|
||||
label: 'Ti',
|
||||
value: 'Ti',
|
||||
},
|
||||
{
|
||||
label: 'Pi',
|
||||
value: 'Pi',
|
||||
},
|
||||
{
|
||||
label: 'Ei',
|
||||
value: 'Ei',
|
||||
},
|
||||
];
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref(null);
|
||||
const formData = ref({
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
level: '1',
|
||||
endpoint: '',
|
||||
tls: false,
|
||||
@@ -181,210 +171,178 @@ const formData = ref({
|
||||
bandwidth: 100,
|
||||
storageType: 'STANDARD',
|
||||
prefix: '',
|
||||
tags: [
|
||||
{
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
tags: [{ key: '', value: '' }],
|
||||
existingObject: true,
|
||||
expiredDeleteMark: true,
|
||||
// deleteAllExpired: false,
|
||||
delete: false,
|
||||
deleteforever: false,
|
||||
});
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
bucketName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const modeOptions = computed(() => [
|
||||
{ label: t('Asynchronous'), value: 'async' },
|
||||
{ label: t('Synchronous'), value: 'sync' },
|
||||
])
|
||||
|
||||
const visible = ref(false);
|
||||
const open = () => {
|
||||
visible.value = true;
|
||||
};
|
||||
const unitOptions = computed(() => [
|
||||
{ label: 'KiB/s', value: 'Ki' },
|
||||
{ label: 'MiB/s', value: 'Mi' },
|
||||
{ label: 'GiB/s', value: 'Gi' },
|
||||
])
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
const addTag = () => {
|
||||
formData.tags.push({ key: '', value: '' })
|
||||
}
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
if (formData.tags.length === 1) return
|
||||
formData.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.level = '1'
|
||||
formData.endpoint = ''
|
||||
formData.tls = false
|
||||
formData.accesskey = ''
|
||||
formData.secrretkey = ''
|
||||
formData.bucket = ''
|
||||
formData.region = 'us-east-1'
|
||||
formData.modeType = 'async'
|
||||
formData.timecheck = '60'
|
||||
formData.unit = 'Gi'
|
||||
formData.bandwidth = 100
|
||||
formData.storageType = 'STANDARD'
|
||||
formData.prefix = ''
|
||||
formData.tags = [{ key: '', value: '' }]
|
||||
formData.existingObject = true
|
||||
formData.expiredDeleteMark = true
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (!formData.endpoint) {
|
||||
message.error(t('Please enter endpoint'))
|
||||
return false
|
||||
}
|
||||
if (!formData.bucket) {
|
||||
message.error(t('Please enter bucket'))
|
||||
return false
|
||||
}
|
||||
if (!formData.accesskey || !formData.secrretkey) {
|
||||
message.error(t('Please provide credentials'))
|
||||
return false
|
||||
}
|
||||
if (formData.modeType === 'async' && Number(formData.timecheck) < 1) {
|
||||
message.error(t('Please enter valid health check interval'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
// 创建远程复制目标
|
||||
const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket({});
|
||||
const handleSave = async () => {
|
||||
formRef.value?.validate(async errors => {
|
||||
if (!errors) {
|
||||
try {
|
||||
let config = {
|
||||
sourcebucket: props.bucketName,
|
||||
endpoint: formData.value.endpoint,
|
||||
credentials: {
|
||||
accessKey: formData.value.accesskey,
|
||||
secretKey: formData.value.secrretkey,
|
||||
expiration: '0001-01-01T00:00:00Z',
|
||||
},
|
||||
targetbucket: formData.value.bucket,
|
||||
secure: formData.value.tls,
|
||||
region: formData.value.region,
|
||||
path: 'auto',
|
||||
api: 's3v4',
|
||||
type: 'replication',
|
||||
replicationSync: formData.value.modeType === 'sync' ? true : false,
|
||||
healthCheckDuration: formData.value.timecheck - 0,
|
||||
disableProxy: false,
|
||||
resetBeforeDate: '0001-01-01T00:00:00Z',
|
||||
totalDowntime: 0,
|
||||
lastOnline: '0001-01-01T00:00:00Z',
|
||||
isOnline: false,
|
||||
latency: {
|
||||
curr: 0,
|
||||
avg: 0,
|
||||
max: 0,
|
||||
},
|
||||
edge: false,
|
||||
edgeSyncBeforeExpiry: false,
|
||||
};
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const config: any = {
|
||||
sourcebucket: props.bucketName,
|
||||
endpoint: formData.endpoint,
|
||||
credentials: {
|
||||
accessKey: formData.accesskey,
|
||||
secretKey: formData.secrretkey,
|
||||
expiration: '0001-01-01T00:00:00Z',
|
||||
},
|
||||
targetbucket: formData.bucket,
|
||||
secure: formData.tls,
|
||||
region: formData.region,
|
||||
path: 'auto',
|
||||
api: 's3v4',
|
||||
type: 'replication',
|
||||
replicationSync: formData.modeType === 'sync',
|
||||
healthCheckDuration: Number(formData.timecheck) || 60,
|
||||
disableProxy: false,
|
||||
resetBeforeDate: '0001-01-01T00:00:00Z',
|
||||
totalDowntime: 0,
|
||||
lastOnline: '0001-01-01T00:00:00Z',
|
||||
isOnline: false,
|
||||
latency: { curr: 0, avg: 0, max: 0 },
|
||||
edge: false,
|
||||
edgeSyncBeforeExpiry: false,
|
||||
}
|
||||
|
||||
// 添加带宽
|
||||
if (formData.value.modeType === 'async') {
|
||||
// 根据单位转化为字节
|
||||
config.bandwidth = Number(getBytes(formData.value.bandwidth, formData.value.unit, true)) || 0;
|
||||
}
|
||||
let targetRESP = await setRemoteReplicationTarget(props.bucketName, config);
|
||||
if (formData.modeType === 'async') {
|
||||
config.bandwidth = Number(getBytes(String(formData.bandwidth), formData.unit, true)) || 0
|
||||
}
|
||||
|
||||
if (!targetRESP) {
|
||||
return;
|
||||
}
|
||||
const targetResponse = await setRemoteReplicationTarget(props.bucketName, config)
|
||||
if (!targetResponse) {
|
||||
throw new Error('Failed to create replication target')
|
||||
}
|
||||
|
||||
// 获取已有的 replication 配置
|
||||
let oldConfig = null;
|
||||
try {
|
||||
oldConfig = await getBucketReplication(props.bucketName);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// 没有配置时会报错,忽略即可
|
||||
}
|
||||
let oldConfig: any = null
|
||||
try {
|
||||
oldConfig = await getBucketReplication(props.bucketName)
|
||||
} catch (error) {
|
||||
oldConfig = null
|
||||
}
|
||||
|
||||
// 构造新规则
|
||||
const newRule = {
|
||||
ID: `replication-rule-${Date.now()}`,
|
||||
Status: 'Enabled',
|
||||
Priority: parseInt(formData.value.level) || 1,
|
||||
Filter: (() => {
|
||||
const filter = {};
|
||||
if (formData.value.prefix) {
|
||||
filter.Prefix = formData.value.prefix;
|
||||
}
|
||||
const validTags = formData.value.tags.filter(tag => tag.key && tag.value);
|
||||
if (validTags.length > 0) {
|
||||
if (validTags.length === 1) {
|
||||
filter.Tag = {
|
||||
Key: validTags[0].key,
|
||||
Value: validTags[0].value,
|
||||
};
|
||||
} else {
|
||||
filter.And = {
|
||||
Prefix: formData.value.prefix || '',
|
||||
Tags: validTags.map(tag => ({
|
||||
Key: tag.key,
|
||||
Value: tag.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
return filter;
|
||||
})(),
|
||||
SourceSelectionCriteria: {
|
||||
SseKmsEncryptedObjects: {
|
||||
Status: 'Enabled',
|
||||
},
|
||||
},
|
||||
ExistingObjectReplication: {
|
||||
Status: formData.value.existingObject ? 'Enabled' : 'Disabled',
|
||||
},
|
||||
DeleteMarkerReplication: {
|
||||
Status: formData.value.expiredDeleteMark ? 'Enabled' : 'Disabled',
|
||||
},
|
||||
Destination: {
|
||||
Bucket: targetRESP,
|
||||
StorageClass: formData.value.storageType ? formData.value.storageType.toUpperCase() : 'STANDARD',
|
||||
// ReplicateDelete: formData.value.deleteforever ? "Enabled" : "Disabled",
|
||||
},
|
||||
};
|
||||
const newRule: any = {
|
||||
ID: `replication-rule-${Date.now()}`,
|
||||
Status: 'Enabled',
|
||||
Priority: Number.parseInt(formData.level) || 1,
|
||||
Filter: {},
|
||||
SourceSelectionCriteria: {
|
||||
SseKmsEncryptedObjects: { Status: 'Enabled' },
|
||||
},
|
||||
ExistingObjectReplication: {
|
||||
Status: formData.existingObject ? 'Enabled' : 'Disabled',
|
||||
},
|
||||
DeleteMarkerReplication: {
|
||||
Status: formData.expiredDeleteMark ? 'Enabled' : 'Disabled',
|
||||
},
|
||||
Destination: {
|
||||
Bucket: targetResponse,
|
||||
StorageClass: formData.storageType || 'STANDARD',
|
||||
},
|
||||
}
|
||||
|
||||
// 合并规则
|
||||
let rules = [];
|
||||
if (
|
||||
oldConfig &&
|
||||
oldConfig.ReplicationConfiguration &&
|
||||
Array.isArray(oldConfig.ReplicationConfiguration.Rules)
|
||||
) {
|
||||
// 如果有 oldConfig.ReplicationConfiguration.Rules
|
||||
targetRESP = null;
|
||||
// 查找是否有存在的Destination Bucket,如果存在则替换整个规则
|
||||
const findOne = oldConfig.ReplicationConfiguration.Rules.find(rule => rule.Destination.Bucket === targetRESP);
|
||||
if (findOne) {
|
||||
// 如果有则替换
|
||||
rules = [...oldConfig.ReplicationConfiguration.Rules].splice(
|
||||
oldConfig.ReplicationConfiguration.Rules.indexOf(findOne),
|
||||
1,
|
||||
newRule
|
||||
);
|
||||
} else {
|
||||
rules = [...oldConfig.ReplicationConfiguration.Rules, newRule];
|
||||
}
|
||||
} else {
|
||||
rules = [newRule];
|
||||
}
|
||||
if (formData.prefix) {
|
||||
newRule.Filter.Prefix = formData.prefix
|
||||
}
|
||||
|
||||
const params = {
|
||||
Role: targetRESP,
|
||||
Rules: rules,
|
||||
};
|
||||
|
||||
// 创建复制规则
|
||||
await putBucketReplication(props.bucketName, params);
|
||||
|
||||
emit('success');
|
||||
handleCancel();
|
||||
} catch (e) {
|
||||
console.log('🚀 ~ handleSave ~ e:', e);
|
||||
window.$message?.error?.(t('Save failed'));
|
||||
const validTags = formData.tags.filter(tag => tag.key && tag.value)
|
||||
if (validTags.length === 1) {
|
||||
newRule.Filter.Tag = { Key: validTags[0].key, Value: validTags[0].value }
|
||||
} else if (validTags.length > 1) {
|
||||
newRule.Filter.And = {
|
||||
Prefix: formData.prefix || '',
|
||||
Tags: validTags.map(tag => ({ Key: tag.key, Value: tag.value })),
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const existingRules = oldConfig?.ReplicationConfiguration?.Rules ?? []
|
||||
const payload = {
|
||||
Role: targetResponse,
|
||||
Rules: [...existingRules.filter((rule: any) => rule.Destination?.Bucket !== targetResponse), newRule],
|
||||
}
|
||||
|
||||
await putBucketReplication(props.bucketName, payload)
|
||||
message.success(t('Create Success'))
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
message.error(error?.message || t('Save failed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// 取消逻辑
|
||||
visible.value = false;
|
||||
formData = ref({
|
||||
level: '1',
|
||||
endpoint: '',
|
||||
tls: false,
|
||||
accesskey: '',
|
||||
secrretkey: '',
|
||||
bucket: '',
|
||||
region: 'us-east-1',
|
||||
modeType: 'async',
|
||||
timecheck: '60',
|
||||
unit: 'Gi',
|
||||
bandwidth: 100,
|
||||
storageType: 'STANDARD',
|
||||
prefix: '',
|
||||
tags: [
|
||||
{
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
existingObject: true,
|
||||
expiredDeleteMark: true,
|
||||
// deleteAllExpired: false,
|
||||
delete: false,
|
||||
deleteforever: false,
|
||||
});
|
||||
};
|
||||
visible.value = false
|
||||
submitting.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: () => {
|
||||
resetForm()
|
||||
visible.value = true
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
+170
-188
@@ -1,244 +1,226 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<page-header>
|
||||
<template #title>
|
||||
<h1 class="text-2xl font-bold">{{ t('Bucket Replication') }}</h1>
|
||||
</template>
|
||||
</page-header>
|
||||
<page-content class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div style="width: 300px">
|
||||
<n-form-item :label="t('Bucket')" path="" class="flex-auto" label-placement="left">
|
||||
<n-select
|
||||
filterable
|
||||
v-model:value="bucketName"
|
||||
:placeholder="t('Please select bucket')"
|
||||
:options="bucketList"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<n-button @click="() => openForm()">
|
||||
<Icon name="ri:add-line" class="mr-2" />
|
||||
<page-content class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm md:flex-row md:items-end md:justify-between"
|
||||
>
|
||||
<div class="w-full max-w-sm">
|
||||
<Label class="mb-2 block text-sm font-medium text-muted-foreground">{{ t('Bucket') }}</Label>
|
||||
<AppSelect
|
||||
v-model="bucketName"
|
||||
:options="bucketList"
|
||||
:placeholder="t('Please select bucket')"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<AppButton variant="secondary" @click="openForm">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Replication Rule') }}</span>
|
||||
</n-button>
|
||||
<n-button @click="loadReplication">
|
||||
<Icon name="ri:refresh-line" class="mr-2" />
|
||||
</AppButton>
|
||||
<AppButton variant="outline" @click="loadReplication">
|
||||
<Icon name="ri:refresh-line" class="size-4" />
|
||||
<span>{{ t('Refresh') }}</span>
|
||||
</n-button>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
class="border dark:border-neutral-700 rounded overflow-hidden"
|
||||
:columns="columns"
|
||||
:data="pageData"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
v-if="pageData.length > 0"
|
||||
/>
|
||||
<n-card class="flex flex-center" style="height: 400px" v-else>
|
||||
<n-empty :description="t('No Data')"></n-empty>
|
||||
</n-card>
|
||||
<replication-new-form :bucketName="bucketName" ref="addFromRef" @success="onAddSuccess"></replication-new-form>
|
||||
<AppCard padded class="border border-border/60">
|
||||
<AppDataTable
|
||||
:table="table"
|
||||
:is-loading="loading"
|
||||
:empty-title="t('No Data')"
|
||||
:empty-description="t('Add replication rules to sync objects across buckets.')"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<replication-new-form ref="addFormRef" :bucketName="bucketName" @success="loadReplication" />
|
||||
</page-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '#components';
|
||||
import { NButton, NSpace, type DataTableColumns } from 'naive-ui';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBucket } from '@/composables/useBucket';
|
||||
import { h, ref, computed, watch } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { Icon } from '#components'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { AppButton, AppCard, AppSelect, AppTag } from '@/components/app'
|
||||
import { AppDataTable, useDataTable } from '@/components/app/data-table'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBucket } from '@/composables/useBucket'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
listBuckets,
|
||||
getBucketReplication,
|
||||
putBucketReplication,
|
||||
deleteBucketReplication,
|
||||
deleteRemoteReplicationTarget,
|
||||
} = useBucket({});
|
||||
const formVisible = ref(false);
|
||||
const searchTerm = ref('');
|
||||
const message = useMessage();
|
||||
} = useBucket({})
|
||||
|
||||
interface RowData {
|
||||
Name: string;
|
||||
CreationDate: string;
|
||||
interface ReplicationRule {
|
||||
ID?: string
|
||||
Status?: string
|
||||
Priority?: number
|
||||
Filter?: {
|
||||
Prefix?: string
|
||||
}
|
||||
Destination?: {
|
||||
Bucket?: string
|
||||
StorageClass?: string
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<ReplicationRule> = [
|
||||
{
|
||||
title: t('Rule ID'),
|
||||
key: 'ID',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
key: 'Status',
|
||||
align: 'center',
|
||||
render: row => (row.Status === 'Enabled' ? t('Enabled') : t('Disabled')),
|
||||
},
|
||||
{
|
||||
title: t('Priority'),
|
||||
key: 'Priority',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('Prefix'),
|
||||
key: 'Filter',
|
||||
align: 'center',
|
||||
render: row => row.Filter?.Prefix || '-',
|
||||
},
|
||||
{
|
||||
title: t('Destination Bucket'),
|
||||
key: 'Destination',
|
||||
align: 'center',
|
||||
render: row => {
|
||||
// MinIO/标准S3结构:arn:aws:s3:::bucketname
|
||||
const bucketArn = row.Destination?.Bucket || '';
|
||||
return bucketArn.replace(/^arn:aws:s3:::/, '');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Storage Class'),
|
||||
key: 'Destination',
|
||||
align: 'center',
|
||||
render: row => row.Destination?.StorageClass || '-',
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: row => {
|
||||
return h(
|
||||
NSpace,
|
||||
{ justify: 'center' },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
secondary: true,
|
||||
onClick: e => handleRowDelete(row, e),
|
||||
},
|
||||
{
|
||||
default: () => '',
|
||||
icon: () => h(Icon, { name: 'ri:delete-bin-7-line' }),
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 获取桶列表
|
||||
const { data } = await useAsyncData(
|
||||
'buckets',
|
||||
const { data: bucketData } = await useAsyncData(
|
||||
'replication-buckets',
|
||||
async () => {
|
||||
const response = await listBuckets();
|
||||
const response = await listBuckets()
|
||||
return (
|
||||
response.Buckets?.sort((a: any, b: any) => {
|
||||
return a.Name.localeCompare(b.Name);
|
||||
}) || []
|
||||
);
|
||||
response.Buckets?.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) ?? []
|
||||
)
|
||||
},
|
||||
{ default: () => [] }
|
||||
);
|
||||
)
|
||||
|
||||
const bucketList = computed(() => {
|
||||
return data.value.map(bucket => ({
|
||||
const bucketList = computed(() =>
|
||||
bucketData.value.map(bucket => ({
|
||||
label: bucket.Name,
|
||||
value: bucket.Name,
|
||||
}));
|
||||
});
|
||||
}))
|
||||
)
|
||||
|
||||
const bucketName = ref<string>(bucketList.value.length > 0 ? (bucketList.value[0]?.value ?? '') : '');
|
||||
const bucketName = ref<string>(bucketList.value[0]?.value ?? '')
|
||||
const rules = ref<ReplicationRule[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 复制规则类型
|
||||
interface ReplicationRule {
|
||||
[key: string]: any;
|
||||
}
|
||||
const pageData = ref<ReplicationRule[]>([]);
|
||||
const columns: ColumnDef<ReplicationRule>[] = [
|
||||
{
|
||||
accessorKey: 'ID',
|
||||
header: () => t('Rule ID'),
|
||||
cell: ({ row }) => h('span', row.original.ID || '-'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'Status',
|
||||
header: () => t('Status'),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
AppTag,
|
||||
{ tone: row.original.Status === 'Enabled' ? 'success' : 'warning' },
|
||||
() => (row.original.Status === 'Enabled' ? t('Enabled') : t('Disabled'))
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'Priority',
|
||||
header: () => t('Priority'),
|
||||
cell: ({ row }) => h('span', String(row.original.Priority ?? '-')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'Filter',
|
||||
header: () => t('Prefix'),
|
||||
cell: ({ row }) => h('span', row.original.Filter?.Prefix || '-'),
|
||||
},
|
||||
{
|
||||
id: 'destination-bucket',
|
||||
header: () => t('Destination Bucket'),
|
||||
cell: ({ row }) => {
|
||||
const bucketArn = row.original.Destination?.Bucket || ''
|
||||
return h('span', bucketArn.replace(/^arn:aws:s3:::/, '') || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'destination-storage',
|
||||
header: () => t('Storage Class'),
|
||||
cell: ({ row }) => h('span', row.original.Destination?.StorageClass || '-'),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
h('div', { class: 'flex justify-center gap-2' }, [
|
||||
h(
|
||||
AppButton,
|
||||
{
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
onClick: () => confirmDelete(row.original),
|
||||
},
|
||||
() => [h(Icon, { name: 'ri:delete-bin-7-line', class: 'size-4' }), h('span', t('Delete'))]
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
const { table } = useDataTable<ReplicationRule>({
|
||||
data: rules,
|
||||
columns,
|
||||
getRowId: row => row.ID ?? JSON.stringify(row),
|
||||
})
|
||||
|
||||
// 加载复制规则
|
||||
const loadReplication = async () => {
|
||||
if (!bucketName.value) {
|
||||
pageData.value = [];
|
||||
return;
|
||||
rules.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBucketReplication(bucketName.value);
|
||||
if (!res) {
|
||||
pageData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容无规则时返回空对象
|
||||
pageData.value = res?.ReplicationConfiguration?.Rules || [];
|
||||
} catch (e) {
|
||||
pageData.value = [];
|
||||
const res = await getBucketReplication(bucketName.value)
|
||||
rules.value = res?.ReplicationConfiguration?.Rules ?? []
|
||||
} catch (error) {
|
||||
rules.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 监听 bucketName 变化自动加载
|
||||
watch(
|
||||
bucketName,
|
||||
() => bucketName.value,
|
||||
() => {
|
||||
loadReplication();
|
||||
loadReplication()
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
)
|
||||
|
||||
const confirmDelete = (rule: ReplicationRule) => {
|
||||
dialog.error({
|
||||
title: t('Warning'),
|
||||
content: t('Are you sure you want to delete this replication rule?'),
|
||||
positiveText: t('Confirm'),
|
||||
negativeText: t('Cancel'),
|
||||
onPositiveClick: () => handleRowDelete(rule),
|
||||
})
|
||||
}
|
||||
|
||||
const handleRowDelete = async (rule: ReplicationRule) => {
|
||||
const remaining = rules.value.filter(item => item !== rule)
|
||||
|
||||
// 删除规则
|
||||
const handleRowDelete = async (row: any, e: Event) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
// 1. 获取当前所有规则
|
||||
const res: any = await getBucketReplication(bucketName.value);
|
||||
let rules = res?.ReplicationConfiguration?.Rules || [];
|
||||
const arn: string = res?.ReplicationConfiguration?.Role || ''; // 例如 arn:aws:s3:::bucketname
|
||||
console.log(arn);
|
||||
|
||||
// 2. 过滤掉要删除的规则
|
||||
const newRules = rules.filter((r: any) => r.ID !== row.ID);
|
||||
if (newRules.length === 0) {
|
||||
// 如果删除后没有规则,直接删除整个配置
|
||||
await deleteBucketReplication(bucketName.value);
|
||||
if (remaining.length === 0) {
|
||||
await deleteBucketReplication(bucketName.value)
|
||||
await deleteRemoteReplicationTarget(bucketName.value, rule.Destination?.Bucket ?? '')
|
||||
} else {
|
||||
// 否则 put 新规则集
|
||||
await putBucketReplication(bucketName.value, {
|
||||
Role: res?.ReplicationConfiguration?.Role,
|
||||
Rules: newRules,
|
||||
});
|
||||
// 4. 删除远程目标
|
||||
if (arn) {
|
||||
deleteRemoteReplicationTarget(bucketName.value, arn);
|
||||
}
|
||||
Rules: remaining,
|
||||
})
|
||||
}
|
||||
|
||||
message.success(t('Delete success'));
|
||||
loadReplication();
|
||||
} catch (err) {
|
||||
message.error(t('Delete failed'));
|
||||
message.success(t('Delete Success'))
|
||||
loadReplication()
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('Delete Failed'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const addFromRef = ref();
|
||||
const addFormRef = ref<{ open: () => void }>()
|
||||
const openForm = () => {
|
||||
addFromRef.value.open();
|
||||
};
|
||||
|
||||
// 新增成功后刷新
|
||||
const onAddSuccess = () => {
|
||||
loadReplication();
|
||||
};
|
||||
addFormRef.value?.open()
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user