mirror of
https://github.com/rustfs/console.git
synced 2026-09-01 15:17:45 +08:00
Merge branch 'replication'
This commit is contained in:
+22
-11
@@ -2,7 +2,7 @@
|
||||
<n-page-header subtitle="" @back="router.back()">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<n-input placeholder="搜索" v-model:value="searchTerm" @update:value="handleSearch">
|
||||
<n-input :placeholder="t('Search')" v-model:value="searchTerm" @update:value="handleSearch">
|
||||
<template #prefix>
|
||||
<Icon name="ri:search-2-line" />
|
||||
</template>
|
||||
@@ -15,25 +15,25 @@
|
||||
<object-delete-stats />
|
||||
<n-button @click="() => handleNewObject(true)">
|
||||
<Icon name="ri:add-line" class="mr-2" />
|
||||
<span>新建目录</span>
|
||||
<span>{{ t("New Folder") }}</span>
|
||||
</n-button>
|
||||
<n-button @click="() => handleNewObject(false)">
|
||||
<Icon name="ri:add-line" class="mr-2" />
|
||||
<span>新建文件</span>
|
||||
<span>{{ t("New File") }}</span>
|
||||
</n-button>
|
||||
<n-button @click="() => (uploadPickerVisible = true)">
|
||||
<Icon name="ri:file-add-line" class="mr-2" />
|
||||
<span>上传文件/文件夹</span>
|
||||
<span>{{ t("Upload File") + "/" + t("Folder") }}</span>
|
||||
</n-button>
|
||||
<n-button :disabled="!checkedKeys.length" secondary @click="handleBatchDelete">
|
||||
<template #icon>
|
||||
<Icon name="ri:delete-bin-5-line"></Icon>
|
||||
</template>
|
||||
删除选中项
|
||||
{{ t("Delete Selected") }}
|
||||
</n-button>
|
||||
<n-button @click="() => refresh()">
|
||||
<Icon name="ri:refresh-line" class="mr-2" />
|
||||
<span>刷新</span>
|
||||
<span>{{ t("Refresh") }}</span>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,13 +48,23 @@
|
||||
:bordered="false" />
|
||||
<object-upload-picker
|
||||
:show="uploadPickerVisible"
|
||||
@update:show="(val: any) => (uploadPickerVisible = val && refresh())"
|
||||
@update:show="
|
||||
(val) => {
|
||||
uploadPickerVisible = val;
|
||||
refresh();
|
||||
}
|
||||
"
|
||||
:bucketName="bucketName"
|
||||
:prefix="prefix" />
|
||||
<object-new-form
|
||||
:show="newObjectFormVisible"
|
||||
:asPrefix="newObjectAsPrefix"
|
||||
@update:show="(val: any) => (newObjectFormVisible = val && refresh())"
|
||||
@update:show="
|
||||
(val) => {
|
||||
newObjectFormVisible = val;
|
||||
refresh();
|
||||
}
|
||||
"
|
||||
:bucketName="bucketName"
|
||||
:prefix="prefix" />
|
||||
<n-button-group class="ml-auto">
|
||||
@@ -72,6 +82,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { $s3Client } = useNuxtApp();
|
||||
const { t } = useI18n();
|
||||
import { useAsyncData, useRoute, useRouter } from "#app";
|
||||
import { NuxtLink } from "#components";
|
||||
import { ListObjectsV2Command, type _Object, type CommonPrefix } from "@aws-sdk/client-s3";
|
||||
@@ -171,7 +182,7 @@ const columns: DataTableColumns<RowData> = [
|
||||
},
|
||||
{
|
||||
key: "Key",
|
||||
title: "对象",
|
||||
title: t("Object"),
|
||||
render: (row: { Key: string; type: "prefix" | "object" }) => {
|
||||
const displayKey = prefix.value ? row.Key.substring(prefix.value.length) : row.Key;
|
||||
let label: string | VNode = displayKey || "/";
|
||||
@@ -196,10 +207,10 @@ const columns: DataTableColumns<RowData> = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "Size", title: "大小", render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : "") },
|
||||
{ key: "Size", title: t("Size"), render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : "") },
|
||||
{
|
||||
key: "LastModified",
|
||||
title: "更新时间",
|
||||
title: t("Update Time"),
|
||||
render: (row: { LastModified: string }) => {
|
||||
return row.LastModified ? dayjs(row.LastModified).format("YYYY-MM-DD HH:mm:ss") : "";
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<n-modal :show="show" @update:show="(val: boolean) => $emit('update:show', val)" size="huge">
|
||||
<n-card class="max-w-screen-md">
|
||||
<template #header>
|
||||
<div style="display:flex; justify-content: space-between; align-items:center;">
|
||||
<span>{{ t('New Form', { type: displayType }) }}</span>
|
||||
<n-button size="small" ghost @click="closeModal">{{ t('Close') }}</n-button>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>{{ t("New Form", { type: displayType }) }}</span>
|
||||
<n-button size="small" ghost @click="closeModal">{{ t("Close") }}</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -14,7 +14,7 @@
|
||||
</div> -->
|
||||
|
||||
<n-alert title="" type="info">
|
||||
{{ t('Overwrite Warning') }}
|
||||
{{ t("Overwrite Warning") }}
|
||||
</n-alert>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -22,7 +22,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<n-button type="primary" :disabled="objectKey.trim().length < 1" @click="handlePutObject">{{ t('Create') }}</n-button>
|
||||
<n-button type="primary" :disabled="objectKey.trim().length < 1" @click="handlePutObject">
|
||||
{{ t("Create") }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
@@ -30,33 +32,36 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { joinRelativeURL } from 'ufo'
|
||||
import { computed, defineEmits, defineProps } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { joinRelativeURL } from "ufo";
|
||||
import { computed, defineEmits, defineProps } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits(['update:show'])
|
||||
const { t } = useI18n();
|
||||
const emit = defineEmits(["update:show"]);
|
||||
|
||||
const props = defineProps<{ show: boolean; bucketName: string; prefix: string, asPrefix?: boolean }>()
|
||||
const props = defineProps<{ show: boolean; bucketName: string; prefix: string; asPrefix?: boolean }>();
|
||||
|
||||
const closeModal = () => emit('update:show', false)
|
||||
const closeModal = () => emit("update:show", false);
|
||||
|
||||
const displayType = computed(() => props.asPrefix ? t('New Folder') : t('New File'))
|
||||
const displayType = computed(() => (props.asPrefix ? t("New Folder") : t("New File")));
|
||||
|
||||
const objectKey = ref('')
|
||||
const objectKey = ref("");
|
||||
|
||||
const { putObject } = useObject({ bucket: props.bucketName })
|
||||
const { putObject } = useObject({ bucket: props.bucketName });
|
||||
|
||||
const $message = useMessage()
|
||||
const $message = useMessage();
|
||||
|
||||
const handlePutObject = () => {
|
||||
const suffix = props.asPrefix ? '/' : ''
|
||||
putObject(joinRelativeURL(props.prefix, objectKey.value, suffix), '').then(() => {
|
||||
emit('update:show', false)
|
||||
objectKey.value = ''
|
||||
$message.success(t('Create Success'))
|
||||
}).catch((e) => {
|
||||
$message.error(e.message)
|
||||
})
|
||||
}
|
||||
const suffix = props.asPrefix ? "/" : "";
|
||||
const cleanedKey = objectKey.value.replace(/^\/+|\/+$/g, ""); // 移除开头和结尾的 `/`
|
||||
putObject(joinRelativeURL(props.prefix, cleanedKey, suffix), "")
|
||||
.then(() => {
|
||||
emit("update:show", false);
|
||||
objectKey.value = "";
|
||||
$message.success(t("Create Success"));
|
||||
})
|
||||
.catch((e) => {
|
||||
$message.error(e.message);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
+248
-104
@@ -1,45 +1,61 @@
|
||||
<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,
|
||||
}">
|
||||
<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,
|
||||
}">
|
||||
<n-card>
|
||||
<n-form label-placement="left" :label-width='100' ref="formRef" :model="formData">
|
||||
<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="type">
|
||||
<n-form-item :label="t('Target Address')" path="endpoint">
|
||||
<n-input v-model:value="formData.endpoint" :placeholder="t('Please enter target address')" />
|
||||
</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="type">
|
||||
<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="type">
|
||||
<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="type">
|
||||
<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="type">
|
||||
<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-select
|
||||
v-model:value="formData.modeType"
|
||||
:placeholder="t('Please select replication mode')"
|
||||
filterable
|
||||
:options="modes" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="t('Bandwidth')" path="type">
|
||||
<n-form-item v-if="formData.modeType === 'async'" :label="t('Bandwidth')" path="bandwidth">
|
||||
<n-input-group>
|
||||
<n-input v-model="formData.daikuan" :placeholder="t('Please enter bandwidth')" />
|
||||
<n-select v-model:value="formData.unit" :placeholder="t('Please select unit')" filterable :options="units" />
|
||||
<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')" />
|
||||
<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="type">
|
||||
<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>
|
||||
|
||||
@@ -49,49 +65,51 @@
|
||||
<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-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">
|
||||
<n-form-item :label="t('Existing Objects')">
|
||||
<n-space>
|
||||
<n-switch v-model:value="formData.expiredDeleteMark" :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>
|
||||
<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>
|
||||
<n-switch v-model:value="formData.delete" :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>
|
||||
<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 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>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
|
||||
<n-space justify="center">
|
||||
<n-button @click="handleCancel">{{ t('Cancel') }}</n-button>
|
||||
<n-button type="primary" @click="handleSave">{{ t('Save') }}</n-button>
|
||||
<n-button @click="handleCancel">{{ t("Cancel") }}</n-button>
|
||||
<n-button type="primary" @click="handleSave">{{ t("Save") }}</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
@@ -99,97 +117,223 @@
|
||||
</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 { 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";
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t } = useI18n();
|
||||
|
||||
const modes = [
|
||||
{
|
||||
label: t('Sync'),
|
||||
value: 'sync',
|
||||
label: t("Sync"),
|
||||
value: "sync",
|
||||
},
|
||||
{
|
||||
label: t('Async'),
|
||||
value: 'async',
|
||||
label: t("Async"),
|
||||
value: "async",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const units = [
|
||||
{
|
||||
label: 'Mi',
|
||||
value: 'Mi',
|
||||
label: "Mi",
|
||||
value: "Mi",
|
||||
},
|
||||
{
|
||||
label: 'Gi',
|
||||
value: 'Gi',
|
||||
label: "Gi",
|
||||
value: "Gi",
|
||||
},
|
||||
{
|
||||
label: 'Ti',
|
||||
value: 'Ti',
|
||||
label: "Ti",
|
||||
value: "Ti",
|
||||
},
|
||||
{
|
||||
label: 'Pi',
|
||||
value: 'Pi',
|
||||
label: "Pi",
|
||||
value: "Pi",
|
||||
},
|
||||
{
|
||||
label: 'Ei',
|
||||
value: 'Ei',
|
||||
label: "Ei",
|
||||
value: "Ei",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const formRef = ref(null)
|
||||
const formRef = ref(null);
|
||||
const formData = ref({
|
||||
level: '1',
|
||||
timecheck: '60',
|
||||
modeType: 'sync',
|
||||
type: null,
|
||||
unit: 'Mi',
|
||||
tags: [{
|
||||
key: '',
|
||||
value: ''
|
||||
}],
|
||||
expiredDeleteMark: false,
|
||||
deleteAllExpired: false,
|
||||
level: "1",
|
||||
endpoint: "",
|
||||
tls: false,
|
||||
accesskey: "",
|
||||
secrretkey: "",
|
||||
bucket: "",
|
||||
region: "",
|
||||
modeType: "async",
|
||||
timecheck: "60",
|
||||
unit: "Gi",
|
||||
bandwidth: 100,
|
||||
storageType: "",
|
||||
prefix: "",
|
||||
tags: [
|
||||
{
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
],
|
||||
existingObject: true,
|
||||
expiredDeleteMark: true,
|
||||
// deleteAllExpired: false,
|
||||
delete: false,
|
||||
deleteforever: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
bucketName: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const visible = ref(false)
|
||||
const visible = ref(false);
|
||||
const open = () => {
|
||||
visible.value = true
|
||||
}
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
const handleSave = () => {
|
||||
formRef.value?.validate((errors) => {
|
||||
open,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["success"]);
|
||||
// 创建远程复制目标
|
||||
const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket({});
|
||||
const handleSave = async () => {
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (!errors) {
|
||||
console.log(t('Submit data'), formData.value)
|
||||
// 调用保存接口
|
||||
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,
|
||||
path: "auto",
|
||||
api: "s3v4",
|
||||
type: "replication",
|
||||
replicationSync: formData.value.modeType === "sync" ? true : false,
|
||||
healthCheckDuration: formData.value.timecheck * 1000000000 - 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 (formData.value.modeType === "async") {
|
||||
// 根据单位转化为字节
|
||||
config.bandwidth = Number(getBytes(formData.value.bandwidth, formData.value.unit, true)) || 0;
|
||||
}
|
||||
const targetRESP = await setRemoteReplicationTarget(props.bucketName, config);
|
||||
if (!targetRESP) return;
|
||||
|
||||
// 获取已有的 replication 配置
|
||||
let oldConfig = null;
|
||||
try {
|
||||
oldConfig = await getBucketReplication(props.bucketName);
|
||||
console.log(oldConfig);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// 没有配置时会报错,忽略即可
|
||||
}
|
||||
|
||||
// 构造新规则
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
// 合并规则
|
||||
let rules = [];
|
||||
if (
|
||||
oldConfig &&
|
||||
oldConfig.ReplicationConfiguration &&
|
||||
Array.isArray(oldConfig.ReplicationConfiguration.Rules)
|
||||
) {
|
||||
rules = [...oldConfig.ReplicationConfiguration.Rules, newRule];
|
||||
} else {
|
||||
rules = [newRule];
|
||||
}
|
||||
|
||||
const params = {
|
||||
Role: targetRESP,
|
||||
Rules: rules,
|
||||
};
|
||||
|
||||
// 创建复制规则
|
||||
await putBucketReplication(props.bucketName, params);
|
||||
|
||||
emit("success");
|
||||
visible.value = false;
|
||||
} catch (e) {
|
||||
window.$message?.error?.(t("Save failed"));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// 取消逻辑
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model:show="modalVisible"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
:title="t('Update Key')"
|
||||
:title="t('Update Key') + ':' + name"
|
||||
class="max-w-screen-md"
|
||||
:segmented="{
|
||||
content: true,
|
||||
|
||||
@@ -147,11 +147,16 @@ const handleSave = () => {
|
||||
storageClass: formData.value.storageclass, // 新增字段
|
||||
},
|
||||
};
|
||||
usetier.addTiers(data).then((res) => {
|
||||
visible.value = false;
|
||||
emmit("search");
|
||||
message.success(t("Create Success"));
|
||||
});
|
||||
usetier
|
||||
.addTiers(data)
|
||||
.then((res) => {
|
||||
visible.value = false;
|
||||
emmit("search");
|
||||
message.success(t("Create Success"));
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error(err.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
GetBucketLifecycleConfigurationCommand,
|
||||
PutBucketLifecycleConfigurationCommand,
|
||||
DeleteBucketLifecycleCommand,
|
||||
GetBucketReplicationCommand,
|
||||
PutBucketReplicationCommand,
|
||||
DeleteBucketReplicationCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
export function useBucket({ region }: { region?: string }) {
|
||||
@@ -195,6 +198,52 @@ export function useBucket({ region }: { region?: string }) {
|
||||
return await $client.send(new DeleteBucketEncryptionCommand(params));
|
||||
};
|
||||
|
||||
/********************S3 Replication********************/
|
||||
const getBucketReplication = async (bucket: string) => {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
};
|
||||
return await $client.send(new GetBucketReplicationCommand(params));
|
||||
};
|
||||
const putBucketReplication = async (bucket: string, replication: any) => {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
ReplicationConfiguration: replication,
|
||||
};
|
||||
return await $client.send(new PutBucketReplicationCommand(params));
|
||||
};
|
||||
const deleteBucketReplication = async (bucket: string) => {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
};
|
||||
return await $client.send(new DeleteBucketReplicationCommand(params));
|
||||
};
|
||||
|
||||
/********************S3 Replication********************/
|
||||
|
||||
/********************rustfs Replication target********************/
|
||||
const { $api } = useNuxtApp();
|
||||
const message = useMessage();
|
||||
const setRemoteReplicationTarget = async (bucket: string, data: any) => {
|
||||
try {
|
||||
return await $api.put(`/set-remote-target?bucket=${bucket}`, data);
|
||||
} catch (error: any) {
|
||||
message.error(error.message as string);
|
||||
console.log(error);
|
||||
}
|
||||
// return await $api.put(`/set-remote-target?bucket=${bucket}`, data);
|
||||
};
|
||||
|
||||
const listRemoteReplicationTarget = async (bucket: string) => {
|
||||
return await $api.get(`/list-remote-targets?bucket=${bucket}&type=`);
|
||||
};
|
||||
|
||||
const deleteRemoteReplicationTarget = async (bucket: string, arn: string) => {
|
||||
return await $api.delete(`/remove-remote-target?bucket=${bucket}&arn=${arn}`);
|
||||
};
|
||||
|
||||
/********************rustfs Replication target end********************/
|
||||
|
||||
return {
|
||||
listBuckets,
|
||||
createBucket,
|
||||
@@ -218,5 +267,11 @@ export function useBucket({ region }: { region?: string }) {
|
||||
getBucketEncryption,
|
||||
putBucketEncryption,
|
||||
deleteBucketEncryption,
|
||||
getBucketReplication,
|
||||
putBucketReplication,
|
||||
deleteBucketReplication,
|
||||
setRemoteReplicationTarget,
|
||||
listRemoteReplicationTarget,
|
||||
deleteRemoteReplicationTarget,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
"Clear Configuration": "Clear Configuration",
|
||||
"Generated Configuration": "Generated Configuration",
|
||||
"Upload File": "Upload File",
|
||||
"Folder": "Folder",
|
||||
"Update Time": "Update Time",
|
||||
"Object": "Object",
|
||||
"Close": "Close",
|
||||
"Select File": "Select File",
|
||||
"Or": "Or",
|
||||
@@ -164,6 +167,7 @@
|
||||
"Start Upload": "Start Upload",
|
||||
"Task Management": "Task Management",
|
||||
"Task In Progress": "Task In Progress",
|
||||
"Task Completed": "Task Completed",
|
||||
"Upload Completed": "Upload Completed",
|
||||
"Browser Warning": "Browser Warning",
|
||||
"Cache Warning": "Cache Warning",
|
||||
@@ -358,6 +362,15 @@
|
||||
"Expire": "Expire",
|
||||
"On": "On",
|
||||
"Off": "Off",
|
||||
"Replicate existing objects": "Replicate existing objects",
|
||||
"Sync metadata": "Sync metadata",
|
||||
"Replicate soft delete": "Replicate soft delete",
|
||||
"Replicate version delete": "Replicate version delete",
|
||||
"Health Check Duration": "Health Check Duration",
|
||||
"Please enter health check duration": "Please enter health check duration",
|
||||
"Please enter bandwidth": "Please enter bandwidth",
|
||||
"Please enter storage type": "Please enter storage type",
|
||||
"Add Replication Rule": "Add Replication Rule",
|
||||
"No valid server configuration detected, please check server configuration": "No valid server configuration detected, please check server configuration",
|
||||
"Login failed, please check server configuration": "Login failed, please check server configuration",
|
||||
"Click to modify": "Click to modify"
|
||||
|
||||
+14
-1
@@ -172,7 +172,10 @@
|
||||
"Creation Date": "创建时间",
|
||||
"Bucket Not Empty": "当前桶不为空,请先删除桶内容",
|
||||
"Upload File": "上传文件",
|
||||
"Folder": "文件夹",
|
||||
"Close": "关闭",
|
||||
"Update Time": "更新时间",
|
||||
"Object": "对象",
|
||||
"Select File": "选择文件",
|
||||
"Or": "或",
|
||||
"Select Folder": "选择文件夹",
|
||||
@@ -188,6 +191,7 @@
|
||||
"Start Upload": "开始上传",
|
||||
"Task Management": "任务管理",
|
||||
"Task In Progress": "任务进行中(进行中 {uploading} 个,已成功 {completed} 个)",
|
||||
"Task Completed": "任务完成",
|
||||
"Upload Completed": "上传完成(成功 {completed} 个,失败 {failed} 个)",
|
||||
"Browser Warning": "刷新/关闭浏览器 将取消当前所有任务。",
|
||||
"Cache Warning": "清空浏览器缓存、session过期等情况会导致任务中断或丢失,请谨慎操作。",
|
||||
@@ -551,5 +555,14 @@
|
||||
"Off": "关闭",
|
||||
"No valid server configuration detected, please check server configuration": "未检测到有效服务器配置,请检查服务器配置",
|
||||
"Login failed, please check server configuration": "登录失败,请检查服务器配置",
|
||||
"Click to modify": "点击修改"
|
||||
"Click to modify": "点击修改",
|
||||
"Replicate existing objects": "复制现有对象",
|
||||
"Sync metadata": "同步元数据",
|
||||
"Replicate soft delete": "复制软删除",
|
||||
"Replicate version delete": "复制版本删除",
|
||||
"Health Check Duration": "健康检查时长",
|
||||
"Please enter health check duration": "请输入健康检查时长",
|
||||
"Please enter bandwidth": "请输入带宽",
|
||||
"Please enter storage type": "请输入存储类型",
|
||||
"Add Replication Rule": "添加复制规则"
|
||||
}
|
||||
|
||||
+24
-1
@@ -30,7 +30,30 @@ class ApiClient {
|
||||
console.log("[request] response:", response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
let errorMsg = response.statusText;
|
||||
try {
|
||||
// 优先尝试解析为 JSON
|
||||
const errorData = await response.clone().json();
|
||||
errorMsg = errorData.message || JSON.stringify(errorData) || errorMsg;
|
||||
} catch (e) {
|
||||
try {
|
||||
// 如果不是 JSON,尝试解析为文本
|
||||
const text = await response.clone().text();
|
||||
if (text) {
|
||||
// 检查是否为 XML
|
||||
if (text.trim().startsWith("<")) {
|
||||
// 简单提取 <Message> 或 <Error> 标签内容
|
||||
const match = text.match(/<Message>(.*?)<\/Message>/i) || text.match(/<Error>(.*?)<\/Error>/i);
|
||||
errorMsg = match ? match[1] : text;
|
||||
} else {
|
||||
errorMsg = text;
|
||||
}
|
||||
}
|
||||
} catch (e2) {
|
||||
// 保持原有 statusText
|
||||
}
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 处理401
|
||||
|
||||
+249
-212
@@ -3,49 +3,49 @@
|
||||
// use forge to create HMAC and SHA256 hashes
|
||||
// because crypto.subtle is not available in non-secure contexts
|
||||
// https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto
|
||||
import forge from 'node-forge'
|
||||
import forge from "node-forge";
|
||||
|
||||
/**
|
||||
* @license MIT <https://opensource.org/licenses/MIT>
|
||||
* @copyright Michael Hart 2024
|
||||
*/
|
||||
const encoder = new TextEncoder()
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const HOST_SERVICES: Record<string, string> = {
|
||||
appstream2: 'appstream',
|
||||
cloudhsmv2: 'cloudhsm',
|
||||
email: 'ses',
|
||||
marketplace: 'aws-marketplace',
|
||||
mobile: 'AWSMobileHubService',
|
||||
pinpoint: 'mobiletargeting',
|
||||
queue: 'sqs',
|
||||
'git-codecommit': 'codecommit',
|
||||
'mturk-requester-sandbox': 'mturk-requester',
|
||||
'personalize-runtime': 'personalize',
|
||||
}
|
||||
appstream2: "appstream",
|
||||
cloudhsmv2: "cloudhsm",
|
||||
email: "ses",
|
||||
marketplace: "aws-marketplace",
|
||||
mobile: "AWSMobileHubService",
|
||||
pinpoint: "mobiletargeting",
|
||||
queue: "sqs",
|
||||
"git-codecommit": "codecommit",
|
||||
"mturk-requester-sandbox": "mturk-requester",
|
||||
"personalize-runtime": "personalize",
|
||||
};
|
||||
|
||||
// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198
|
||||
const UNSIGNABLE_HEADERS = new Set([
|
||||
'authorization',
|
||||
'content-type',
|
||||
'content-length',
|
||||
'user-agent',
|
||||
'presigned-expires',
|
||||
'expect',
|
||||
'x-amzn-trace-id',
|
||||
'range',
|
||||
'connection',
|
||||
])
|
||||
"authorization",
|
||||
"content-type",
|
||||
"content-length",
|
||||
"user-agent",
|
||||
"presigned-expires",
|
||||
"expect",
|
||||
"x-amzn-trace-id",
|
||||
"range",
|
||||
"connection",
|
||||
]);
|
||||
|
||||
export class AwsClient {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
sessionToken?: string
|
||||
service?: string
|
||||
region?: string
|
||||
cache: Map<string, ArrayBuffer>
|
||||
retries: number
|
||||
initRetryMs: number
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
service?: string;
|
||||
region?: string;
|
||||
cache: Map<string, ArrayBuffer>;
|
||||
retries: number;
|
||||
initRetryMs: number;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -59,7 +59,16 @@ export class AwsClient {
|
||||
* initRetryMs?: number
|
||||
* }} options
|
||||
*/
|
||||
constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }: {
|
||||
constructor({
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
service,
|
||||
region,
|
||||
cache,
|
||||
retries,
|
||||
initRetryMs,
|
||||
}: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
@@ -69,17 +78,17 @@ export class AwsClient {
|
||||
retries?: number;
|
||||
initRetryMs?: number;
|
||||
}) {
|
||||
if (accessKeyId == null) throw new TypeError('accessKeyId is a required option')
|
||||
if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option')
|
||||
this.accessKeyId = accessKeyId
|
||||
this.secretAccessKey = secretAccessKey
|
||||
this.sessionToken = sessionToken
|
||||
this.service = service
|
||||
this.region = region
|
||||
if (accessKeyId == null) throw new TypeError("accessKeyId is a required option");
|
||||
if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option");
|
||||
this.accessKeyId = accessKeyId;
|
||||
this.secretAccessKey = secretAccessKey;
|
||||
this.sessionToken = sessionToken;
|
||||
this.service = service;
|
||||
this.region = region;
|
||||
/** @type {Map<string, ArrayBuffer>} */
|
||||
this.cache = cache || new Map()
|
||||
this.retries = retries != null ? retries : 10 // Up to 25.6 secs
|
||||
this.initRetryMs = initRetryMs || 50
|
||||
this.cache = cache || new Map();
|
||||
this.retries = retries != null ? retries : 2; // Up to 25.6 secs
|
||||
this.initRetryMs = initRetryMs || 50;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,26 +112,29 @@ export class AwsClient {
|
||||
* @param {?AwsRequestInit} [init]
|
||||
* @returns {Promise<Request>}
|
||||
*/
|
||||
async sign(input: string | Request, init: { body: ArrayBuffer | ReadableStream<Uint8Array<ArrayBufferLike>> | null; aws: any }) {
|
||||
async sign(
|
||||
input: string | Request,
|
||||
init: { body: ArrayBuffer | ReadableStream<Uint8Array<ArrayBufferLike>> | null; aws: any }
|
||||
) {
|
||||
if (input instanceof Request) {
|
||||
const { method, url, headers, body } = input
|
||||
init = Object.assign({ method, url, headers }, init)
|
||||
if (init.body == null && headers.has('Content-Type')) {
|
||||
init.body = body != null && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer()
|
||||
const { method, url, headers, body } = input;
|
||||
init = Object.assign({ method, url, headers }, init);
|
||||
if (init.body == null && headers.has("Content-Type")) {
|
||||
init.body = body != null && headers.has("X-Amz-Content-Sha256") ? body : await input.clone().arrayBuffer();
|
||||
}
|
||||
input = url
|
||||
input = url;
|
||||
}
|
||||
const signer = new AwsV4Signer(Object.assign({ url: input.toString() }, init, this, init && init.aws))
|
||||
const signed = Object.assign({}, init, await signer.sign())
|
||||
delete signed.aws
|
||||
const signer = new AwsV4Signer(Object.assign({ url: input.toString() }, init, this, init && init.aws));
|
||||
const signed = Object.assign({}, init, await signer.sign());
|
||||
delete signed.aws;
|
||||
try {
|
||||
return new Request(signed.url.toString(), signed)
|
||||
return new Request(signed.url.toString(), signed);
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError) {
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
|
||||
return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed))
|
||||
return new Request(signed.url.toString(), Object.assign({ duplex: "half" }, signed));
|
||||
}
|
||||
throw e
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,40 +145,40 @@ export class AwsClient {
|
||||
*/
|
||||
async fetch(input: any, init: any) {
|
||||
for (let i = 0; i <= this.retries; i++) {
|
||||
const fetched = fetch(await this.sign(input, init))
|
||||
const fetched = fetch(await this.sign(input, init));
|
||||
if (i === this.retries) {
|
||||
return fetched // No need to await if we're returning anyway
|
||||
return fetched; // No need to await if we're returning anyway
|
||||
}
|
||||
const res = await fetched
|
||||
const res = await fetched;
|
||||
if (res.status < 500 && res.status !== 429) {
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i)))
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i)));
|
||||
}
|
||||
throw new Error('An unknown error occurred, ensure retries is not negative')
|
||||
throw new Error("An unknown error occurred, ensure retries is not negative");
|
||||
}
|
||||
}
|
||||
|
||||
export class AwsV4Signer {
|
||||
method: string
|
||||
url: URL
|
||||
headers: Headers
|
||||
body: BodyInit | null | undefined
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
sessionToken: string | undefined
|
||||
service: any
|
||||
region: any
|
||||
cache: Map<any, any>
|
||||
datetime: string
|
||||
signQuery: boolean | undefined
|
||||
appendSessionToken: boolean
|
||||
signableHeaders: any[]
|
||||
signedHeaders: any
|
||||
canonicalHeaders: any
|
||||
credentialString: string
|
||||
encodedPath: string
|
||||
encodedSearch: string
|
||||
method: string;
|
||||
url: URL;
|
||||
headers: Headers;
|
||||
body: BodyInit | null | undefined;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken: string | undefined;
|
||||
service: any;
|
||||
region: any;
|
||||
cache: Map<any, any>;
|
||||
datetime: string;
|
||||
signQuery: boolean | undefined;
|
||||
appendSessionToken: boolean;
|
||||
signableHeaders: any[];
|
||||
signedHeaders: any;
|
||||
canonicalHeaders: any;
|
||||
credentialString: string;
|
||||
encodedPath: string;
|
||||
encodedSearch: string;
|
||||
/**
|
||||
* @param {{
|
||||
* method?: string
|
||||
@@ -186,7 +198,23 @@ export class AwsV4Signer {
|
||||
* singleEncode?: boolean
|
||||
* }} options
|
||||
*/
|
||||
constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }: {
|
||||
constructor({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
service,
|
||||
region,
|
||||
cache,
|
||||
datetime,
|
||||
signQuery,
|
||||
appendSessionToken,
|
||||
allHeaders,
|
||||
singleEncode,
|
||||
}: {
|
||||
method?: string;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
@@ -203,98 +231,101 @@ export class AwsV4Signer {
|
||||
allHeaders?: boolean;
|
||||
singleEncode?: boolean;
|
||||
}) {
|
||||
if (url == null) throw new TypeError('url is a required option')
|
||||
if (accessKeyId == null) throw new TypeError('accessKeyId is a required option')
|
||||
if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option')
|
||||
if (url == null) throw new TypeError("url is a required option");
|
||||
if (accessKeyId == null) throw new TypeError("accessKeyId is a required option");
|
||||
if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option");
|
||||
|
||||
this.method = method || (body ? 'POST' : 'GET')
|
||||
this.url = new URL(url)
|
||||
this.headers = new Headers(headers || {})
|
||||
this.body = body
|
||||
this.method = method || (body ? "POST" : "GET");
|
||||
this.url = new URL(url);
|
||||
this.headers = new Headers(headers || {});
|
||||
this.body = body;
|
||||
|
||||
this.accessKeyId = accessKeyId
|
||||
this.secretAccessKey = secretAccessKey
|
||||
this.sessionToken = sessionToken
|
||||
this.accessKeyId = accessKeyId;
|
||||
this.secretAccessKey = secretAccessKey;
|
||||
this.sessionToken = sessionToken;
|
||||
|
||||
let guessedService, guessedRegion
|
||||
let guessedService, guessedRegion;
|
||||
if (!service || !region) {
|
||||
;[guessedService, guessedRegion] = guessServiceRegion(this.url, this.headers)
|
||||
[guessedService, guessedRegion] = guessServiceRegion(this.url, this.headers);
|
||||
}
|
||||
this.service = service || guessedService || ''
|
||||
this.region = region || guessedRegion || 'us-east-1'
|
||||
this.service = service || guessedService || "";
|
||||
this.region = region || guessedRegion || "us-east-1";
|
||||
|
||||
/** @type {Map<string, ArrayBuffer>} */
|
||||
this.cache = cache || new Map()
|
||||
this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
this.signQuery = signQuery
|
||||
this.appendSessionToken = appendSessionToken || this.service === 'iotdevicegateway'
|
||||
this.cache = cache || new Map();
|
||||
this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
this.signQuery = signQuery;
|
||||
this.appendSessionToken = appendSessionToken || this.service === "iotdevicegateway";
|
||||
|
||||
this.headers.delete('Host') // Can't be set in insecure env anyway
|
||||
this.headers.delete("Host"); // Can't be set in insecure env anyway
|
||||
|
||||
if (this.service === 's3' && !this.signQuery && !this.headers.has('X-Amz-Content-Sha256')) {
|
||||
this.headers.set('X-Amz-Content-Sha256', 'UNSIGNED-PAYLOAD')
|
||||
if (this.service === "s3" && !this.signQuery && !this.headers.has("X-Amz-Content-Sha256")) {
|
||||
this.headers.set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD");
|
||||
}
|
||||
|
||||
const params = this.signQuery ? this.url.searchParams : this.headers
|
||||
const params = this.signQuery ? this.url.searchParams : this.headers;
|
||||
|
||||
params.set('X-Amz-Date', this.datetime)
|
||||
params.set("X-Amz-Date", this.datetime);
|
||||
if (this.sessionToken && !this.appendSessionToken) {
|
||||
params.set('X-Amz-Security-Token', this.sessionToken)
|
||||
params.set("X-Amz-Security-Token", this.sessionToken);
|
||||
}
|
||||
|
||||
// headers are always lowercase in keys()
|
||||
this.signableHeaders = ['host', ...this.headers.keys()]
|
||||
.filter(header => allHeaders || !UNSIGNABLE_HEADERS.has(header))
|
||||
.sort()
|
||||
this.signableHeaders = ["host", ...this.headers.keys()]
|
||||
.filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))
|
||||
.sort();
|
||||
|
||||
this.signedHeaders = this.signableHeaders.join(';')
|
||||
this.signedHeaders = this.signableHeaders.join(";");
|
||||
|
||||
// headers are always trimmed:
|
||||
// https://fetch.spec.whatwg.org/#concept-header-value-normalize
|
||||
this.canonicalHeaders = this.signableHeaders
|
||||
.map(header => header + ':' + (header === 'host' ? this.url.host : (this.headers.get(header) || '').replace(/\s+/g, ' ')))
|
||||
.join('\n')
|
||||
.map(
|
||||
(header) =>
|
||||
header + ":" + (header === "host" ? this.url.host : (this.headers.get(header) || "").replace(/\s+/g, " "))
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, 'aws4_request'].join('/')
|
||||
this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, "aws4_request"].join("/");
|
||||
|
||||
if (this.signQuery) {
|
||||
if (this.service === 's3' && !params.has('X-Amz-Expires')) {
|
||||
params.set('X-Amz-Expires', '86400') // 24 hours
|
||||
if (this.service === "s3" && !params.has("X-Amz-Expires")) {
|
||||
params.set("X-Amz-Expires", "86400"); // 24 hours
|
||||
}
|
||||
params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256')
|
||||
params.set('X-Amz-Credential', this.accessKeyId + '/' + this.credentialString)
|
||||
params.set('X-Amz-SignedHeaders', this.signedHeaders)
|
||||
params.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
|
||||
params.set("X-Amz-Credential", this.accessKeyId + "/" + this.credentialString);
|
||||
params.set("X-Amz-SignedHeaders", this.signedHeaders);
|
||||
}
|
||||
|
||||
if (this.service === 's3') {
|
||||
if (this.service === "s3") {
|
||||
try {
|
||||
this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, ' '))
|
||||
this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, " "));
|
||||
} catch (e) {
|
||||
this.encodedPath = this.url.pathname
|
||||
this.encodedPath = this.url.pathname;
|
||||
}
|
||||
} else {
|
||||
this.encodedPath = this.url.pathname.replace(/\/+/g, '/')
|
||||
this.encodedPath = this.url.pathname.replace(/\/+/g, "/");
|
||||
}
|
||||
if (!singleEncode) {
|
||||
this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, '/')
|
||||
this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/");
|
||||
}
|
||||
this.encodedPath = encodeRfc3986(this.encodedPath)
|
||||
this.encodedPath = encodeRfc3986(this.encodedPath);
|
||||
|
||||
const seenKeys = new Set()
|
||||
const seenKeys = new Set();
|
||||
this.encodedSearch = [...this.url.searchParams]
|
||||
.filter(([k]) => {
|
||||
if (!k) return false // no empty keys
|
||||
if (this.service === 's3') {
|
||||
if (seenKeys.has(k)) return false // first val only for S3
|
||||
seenKeys.add(k)
|
||||
if (!k) return false; // no empty keys
|
||||
if (this.service === "s3") {
|
||||
if (seenKeys.has(k)) return false; // first val only for S3
|
||||
seenKeys.add(k);
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
})
|
||||
.map(pair => pair.map(p => encodeRfc3986(encodeURIComponent(p))))
|
||||
.map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p))))
|
||||
// NOTE: Previously there was a TypeScript error about "k1 is possibly undefined" due to noUncheckedIndexedAccess
|
||||
.sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0)
|
||||
.map(pair => pair.join('='))
|
||||
.join('&')
|
||||
.sort(([k1, v1], [k2, v2]) => (k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0))
|
||||
.map((pair) => pair.join("="))
|
||||
.join("&");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,12 +338,12 @@ export class AwsV4Signer {
|
||||
*/
|
||||
async sign() {
|
||||
if (this.signQuery) {
|
||||
this.url.searchParams.set('X-Amz-Signature', await this.signature())
|
||||
this.url.searchParams.set("X-Amz-Signature", await this.signature());
|
||||
if (this.sessionToken && this.appendSessionToken) {
|
||||
this.url.searchParams.set('X-Amz-Security-Token', this.sessionToken)
|
||||
this.url.searchParams.set("X-Amz-Security-Token", this.sessionToken);
|
||||
}
|
||||
} else {
|
||||
this.headers.set('Authorization', await this.authHeader())
|
||||
this.headers.set("Authorization", await this.authHeader());
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -320,7 +351,7 @@ export class AwsV4Signer {
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
body: this.body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,27 +359,27 @@ export class AwsV4Signer {
|
||||
*/
|
||||
async authHeader() {
|
||||
return [
|
||||
'AWS4-HMAC-SHA256 Credential=' + this.accessKeyId + '/' + this.credentialString,
|
||||
'SignedHeaders=' + this.signedHeaders,
|
||||
'Signature=' + (await this.signature()),
|
||||
].join(', ')
|
||||
"AWS4-HMAC-SHA256 Credential=" + this.accessKeyId + "/" + this.credentialString,
|
||||
"SignedHeaders=" + this.signedHeaders,
|
||||
"Signature=" + (await this.signature()),
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async signature() {
|
||||
const date = this.datetime.slice(0, 8)
|
||||
const cacheKey = [this.secretAccessKey, date, this.region, this.service].join()
|
||||
let kCredentials = this.cache.get(cacheKey)
|
||||
const date = this.datetime.slice(0, 8);
|
||||
const cacheKey = [this.secretAccessKey, date, this.region, this.service].join();
|
||||
let kCredentials = this.cache.get(cacheKey);
|
||||
if (!kCredentials) {
|
||||
const kDate = await hmac('AWS4' + this.secretAccessKey, date)
|
||||
const kRegion = await hmac(kDate, this.region)
|
||||
const kService = await hmac(kRegion, this.service)
|
||||
kCredentials = await hmac(kService, 'aws4_request')
|
||||
this.cache.set(cacheKey, kCredentials)
|
||||
const kDate = await hmac("AWS4" + this.secretAccessKey, date);
|
||||
const kRegion = await hmac(kDate, this.region);
|
||||
const kService = await hmac(kRegion, this.service);
|
||||
kCredentials = await hmac(kService, "aws4_request");
|
||||
this.cache.set(cacheKey, kCredentials);
|
||||
}
|
||||
return buf2hex(await hmac(kCredentials, await this.stringToSign()))
|
||||
return buf2hex(await hmac(kCredentials, await this.stringToSign()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,11 +387,11 @@ export class AwsV4Signer {
|
||||
*/
|
||||
async stringToSign() {
|
||||
return [
|
||||
'AWS4-HMAC-SHA256',
|
||||
"AWS4-HMAC-SHA256",
|
||||
this.datetime,
|
||||
this.credentialString,
|
||||
buf2hex(await hash(await this.canonicalString())),
|
||||
].join('\n')
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,24 +402,27 @@ export class AwsV4Signer {
|
||||
this.method.toUpperCase(),
|
||||
this.encodedPath,
|
||||
this.encodedSearch,
|
||||
this.canonicalHeaders + '\n',
|
||||
this.canonicalHeaders + "\n",
|
||||
this.signedHeaders,
|
||||
await this.hexBodyHash(),
|
||||
].join('\n')
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async hexBodyHash() {
|
||||
let hashHeader = this.headers.get('X-Amz-Content-Sha256') || (this.service === 's3' && this.signQuery ? 'UNSIGNED-PAYLOAD' : null)
|
||||
let hashHeader =
|
||||
this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null);
|
||||
if (hashHeader == null) {
|
||||
if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) {
|
||||
throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header')
|
||||
if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) {
|
||||
throw new Error(
|
||||
"body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header"
|
||||
);
|
||||
}
|
||||
hashHeader = buf2hex(await hash(this.body || ''))
|
||||
hashHeader = buf2hex(await hash(this.body || ""));
|
||||
}
|
||||
return hashHeader
|
||||
return hashHeader;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,31 +431,34 @@ export class AwsV4Signer {
|
||||
* @param {string} string
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
async function hmac(key: string | ArrayBuffer | ArrayBufferView | forge.util.ByteStringBuffer | null, string: string | undefined) {
|
||||
async function hmac(
|
||||
key: string | ArrayBuffer | ArrayBufferView | forge.util.ByteStringBuffer | null,
|
||||
string: string | undefined
|
||||
) {
|
||||
const hmac = forge.hmac.create();
|
||||
|
||||
// Handle key conversion
|
||||
if (typeof key === 'string') {
|
||||
hmac.start('sha256', key);
|
||||
if (typeof key === "string") {
|
||||
hmac.start("sha256", key);
|
||||
} else if (key instanceof ArrayBuffer) {
|
||||
const keyArray = new Uint8Array(key);
|
||||
const keyString = String.fromCharCode.apply(null, [...keyArray]);
|
||||
hmac.start('sha256', keyString);
|
||||
} else if (key && 'getBytes' in key) {
|
||||
hmac.start("sha256", keyString);
|
||||
} else if (key && "getBytes" in key) {
|
||||
// Forge ByteStringBuffer object
|
||||
hmac.start('sha256', key.getBytes());
|
||||
hmac.start("sha256", key.getBytes());
|
||||
} else if (key) {
|
||||
// ArrayBufferView
|
||||
const keyArray = new Uint8Array(key.buffer);
|
||||
const keyString = String.fromCharCode.apply(null, [...keyArray]);
|
||||
hmac.start('sha256', keyString);
|
||||
hmac.start("sha256", keyString);
|
||||
} else {
|
||||
// Handle null
|
||||
hmac.start('sha256', '');
|
||||
hmac.start("sha256", "");
|
||||
}
|
||||
|
||||
// Handle input string
|
||||
if (typeof string === 'string') {
|
||||
if (typeof string === "string") {
|
||||
hmac.update(string);
|
||||
} else {
|
||||
const encoded = encoder.encode(string);
|
||||
@@ -446,7 +483,7 @@ async function hmac(key: string | ArrayBuffer | ArrayBufferView | forge.util.Byt
|
||||
async function hash(content: string | ArrayBufferView<ArrayBufferLike> | ArrayBuffer) {
|
||||
const md = forge.md.sha256.create();
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (typeof content === "string") {
|
||||
md.update(content);
|
||||
} else {
|
||||
// Convert BufferSource to forge format
|
||||
@@ -465,23 +502,23 @@ async function hash(content: string | ArrayBufferView<ArrayBufferLike> | ArrayBu
|
||||
return buffer.buffer;
|
||||
}
|
||||
|
||||
const HEX_CHARS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
|
||||
const HEX_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"];
|
||||
|
||||
/**
|
||||
* @param {ArrayBufferLike} arrayBuffer
|
||||
* @returns {string}
|
||||
*/
|
||||
function buf2hex(arrayBuffer: ArrayBuffer) {
|
||||
const buffer = new Uint8Array(arrayBuffer)
|
||||
let out = ''
|
||||
const buffer = new Uint8Array(arrayBuffer);
|
||||
let out = "";
|
||||
for (let idx = 0; idx < buffer.length; idx++) {
|
||||
const n = buffer[idx]
|
||||
const n = buffer[idx];
|
||||
// NOTE: "n is possibly undefined" due to overzealous noUncheckedIndexedAccess
|
||||
out += HEX_CHARS[(n >>> 4) & 0xF]
|
||||
out += HEX_CHARS[(n >>> 4) & 0xf];
|
||||
// NOTE: "n is possibly undefined" due to overzealous noUncheckedIndexedAccess
|
||||
out += HEX_CHARS[n & 0xF]
|
||||
out += HEX_CHARS[n & 0xf];
|
||||
}
|
||||
return out
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +526,7 @@ function buf2hex(arrayBuffer: ArrayBuffer) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeRfc3986(urlEncodedStr: string) {
|
||||
return urlEncodedStr.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
return urlEncodedStr.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,51 +535,51 @@ function encodeRfc3986(urlEncodedStr: string) {
|
||||
* @returns {[string, string]} [service, region]
|
||||
*/
|
||||
function guessServiceRegion(url: URL, headers: Headers) {
|
||||
const { hostname, pathname } = url
|
||||
const { hostname, pathname } = url;
|
||||
|
||||
if (hostname.endsWith('.on.aws')) {
|
||||
const match = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/)
|
||||
return match != null ? ['lambda', match[1] || ''] : ['', '']
|
||||
if (hostname.endsWith(".on.aws")) {
|
||||
const match = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/);
|
||||
return match != null ? ["lambda", match[1] || ""] : ["", ""];
|
||||
}
|
||||
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
|
||||
return ['s3', 'auto']
|
||||
if (hostname.endsWith(".r2.cloudflarestorage.com")) {
|
||||
return ["s3", "auto"];
|
||||
}
|
||||
if (hostname.endsWith('.backblazeb2.com')) {
|
||||
const match = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/)
|
||||
return match != null ? ['s3', match[1] || ''] : ['', '']
|
||||
if (hostname.endsWith(".backblazeb2.com")) {
|
||||
const match = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/);
|
||||
return match != null ? ["s3", match[1] || ""] : ["", ""];
|
||||
}
|
||||
const match = hostname.replace('dualstack.', '').match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/)
|
||||
let service = (match && match[1]) || ''
|
||||
let region = match && match[2]
|
||||
const match = hostname.replace("dualstack.", "").match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/);
|
||||
let service = (match && match[1]) || "";
|
||||
let region = match && match[2];
|
||||
|
||||
if (region === 'us-gov') {
|
||||
region = 'us-gov-west-1'
|
||||
} else if (region === 's3' || region === 's3-accelerate') {
|
||||
region = 'us-east-1'
|
||||
service = 's3'
|
||||
} else if (service === 'iot') {
|
||||
if (hostname.startsWith('iot.')) {
|
||||
service = 'execute-api'
|
||||
} else if (hostname.startsWith('data.jobs.iot.')) {
|
||||
service = 'iot-jobs-data'
|
||||
if (region === "us-gov") {
|
||||
region = "us-gov-west-1";
|
||||
} else if (region === "s3" || region === "s3-accelerate") {
|
||||
region = "us-east-1";
|
||||
service = "s3";
|
||||
} else if (service === "iot") {
|
||||
if (hostname.startsWith("iot.")) {
|
||||
service = "execute-api";
|
||||
} else if (hostname.startsWith("data.jobs.iot.")) {
|
||||
service = "iot-jobs-data";
|
||||
} else {
|
||||
service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata'
|
||||
service = pathname === "/mqtt" ? "iotdevicegateway" : "iotdata";
|
||||
}
|
||||
} else if (service === 'autoscaling') {
|
||||
const targetPrefix = (headers.get('X-Amz-Target') || '').split('.')[0]
|
||||
if (targetPrefix === 'AnyScaleFrontendService') {
|
||||
service = 'application-autoscaling'
|
||||
} else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') {
|
||||
service = 'autoscaling-plans'
|
||||
} else if (service === "autoscaling") {
|
||||
const targetPrefix = (headers.get("X-Amz-Target") || "").split(".")[0];
|
||||
if (targetPrefix === "AnyScaleFrontendService") {
|
||||
service = "application-autoscaling";
|
||||
} else if (targetPrefix === "AnyScaleScalingPlannerFrontendService") {
|
||||
service = "autoscaling-plans";
|
||||
}
|
||||
} else if (region == null && service.startsWith('s3-')) {
|
||||
region = service.slice(3).replace(/^fips-|^external-1/, '')
|
||||
service = 's3'
|
||||
} else if (service.endsWith('-fips')) {
|
||||
service = service.slice(0, -5)
|
||||
} else if (region == null && service.startsWith("s3-")) {
|
||||
region = service.slice(3).replace(/^fips-|^external-1/, "");
|
||||
service = "s3";
|
||||
} else if (service.endsWith("-fips")) {
|
||||
service = service.slice(0, -5);
|
||||
} else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
|
||||
;[service, region] = [region, service]
|
||||
[service, region] = [region, service];
|
||||
}
|
||||
|
||||
return [HOST_SERVICES[service] || service, region || '']
|
||||
return [HOST_SERVICES[service] || service, region || ""];
|
||||
}
|
||||
|
||||
+158
-63
@@ -2,107 +2,133 @@
|
||||
<div>
|
||||
<page-header>
|
||||
<template #title>
|
||||
<h1 class="text-2xl font-bold">{{ t('Bucket Replication') }}</h1>
|
||||
<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">
|
||||
<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-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" />
|
||||
<span>{{ t('Add Replication Rule') }}</span>
|
||||
<span>{{ t("Add Replication Rule") }}</span>
|
||||
</n-button>
|
||||
<n-button @click="">
|
||||
<n-button @click="loadReplication">
|
||||
<Icon name="ri:refresh-line" class="mr-2" />
|
||||
<span>{{ t('Refresh') }}</span>
|
||||
<span>{{ t("Refresh") }}</span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card class="flex flex-center" style="height:400px">
|
||||
<n-empty :description="t('No Data')" ></n-empty>
|
||||
<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>
|
||||
<!-- <n-data-table class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="pageData" :pagination="false" :bordered="false" /> -->
|
||||
<replication-new-form :bucketName="bucketName" ref="addFromRef"></replication-new-form>
|
||||
<replication-new-form :bucketName="bucketName" ref="addFromRef" @success="onAddSuccess"></replication-new-form>
|
||||
</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 { 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";
|
||||
|
||||
const { t } = useI18n()
|
||||
const { listBuckets } = useBucket({});
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
listBuckets,
|
||||
getBucketReplication,
|
||||
putBucketReplication,
|
||||
deleteBucketReplication,
|
||||
deleteRemoteReplicationTarget,
|
||||
} = useBucket({});
|
||||
const formVisible = ref(false);
|
||||
const searchTerm = ref('');
|
||||
const searchTerm = ref("");
|
||||
const message = useMessage();
|
||||
|
||||
interface RowData {
|
||||
Name: string;
|
||||
CreationDate: string;
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<RowData> = [
|
||||
const columns: DataTableColumns<ReplicationRule> = [
|
||||
{
|
||||
title: t('Type'),
|
||||
key: '',
|
||||
|
||||
title: t("Rule ID"),
|
||||
key: "ID",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: t('Version'),
|
||||
key: '',
|
||||
},
|
||||
{
|
||||
title: t('Expiration Delete Mark'),
|
||||
key: '',
|
||||
},
|
||||
{
|
||||
title: t('Tier'),
|
||||
key: '',
|
||||
},
|
||||
{
|
||||
title: t('Prefix'),
|
||||
key: '',
|
||||
},
|
||||
{
|
||||
title: t('Time'),
|
||||
key: '',
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
key: '',
|
||||
title: t("Status"),
|
||||
key: "Status",
|
||||
align: "center",
|
||||
render: (row) => (row.Status === "Enabled" ? t("Enabled") : t("Disabled")),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
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: RowData) => {
|
||||
render: (row) => {
|
||||
return h(
|
||||
NSpace,
|
||||
{
|
||||
justify: 'center',
|
||||
},
|
||||
{ justify: "center" },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
size: "small",
|
||||
secondary: true,
|
||||
onClick: (e) => handleRowDelete(row,e),
|
||||
onClick: (e) => handleRowDelete(row, e),
|
||||
},
|
||||
{
|
||||
default: () => '',
|
||||
icon: () => h(Icon, { name: 'ri:delete-bin-7-line' }),
|
||||
default: () => "",
|
||||
icon: () => h(Icon, { name: "ri:delete-bin-7-line" }),
|
||||
}
|
||||
),
|
||||
],
|
||||
@@ -114,10 +140,14 @@ const columns: DataTableColumns<RowData> = [
|
||||
|
||||
// 获取桶列表
|
||||
const { data } = await useAsyncData(
|
||||
'buckets',
|
||||
"buckets",
|
||||
async () => {
|
||||
const response = await listBuckets();
|
||||
return response.Buckets?.sort((a:any, b:any) => {return a.Name.localeCompare(b.Name) }) || [];
|
||||
return (
|
||||
response.Buckets?.sort((a: any, b: any) => {
|
||||
return a.Name.localeCompare(b.Name);
|
||||
}) || []
|
||||
);
|
||||
},
|
||||
{ default: () => [] }
|
||||
);
|
||||
@@ -129,19 +159,84 @@ const bucketList = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const bucketName = ref<string>(
|
||||
bucketList.value.length > 0 ? bucketList.value[0]?.value ?? '' : ''
|
||||
const bucketName = ref<string>(bucketList.value.length > 0 ? bucketList.value[0]?.value ?? "" : "");
|
||||
|
||||
// 复制规则类型
|
||||
interface ReplicationRule {
|
||||
[key: string]: any;
|
||||
}
|
||||
const pageData = ref<ReplicationRule[]>([]);
|
||||
|
||||
// 加载复制规则
|
||||
const loadReplication = async () => {
|
||||
if (!bucketName.value) {
|
||||
pageData.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getBucketReplication(bucketName.value);
|
||||
if (!res) {
|
||||
pageData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 兼容无规则时返回空对象
|
||||
pageData.value = res?.ReplicationConfiguration?.Rules || [];
|
||||
} catch (e) {
|
||||
pageData.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 bucketName 变化自动加载
|
||||
watch(
|
||||
bucketName,
|
||||
() => {
|
||||
loadReplication();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const pageData = ref([])
|
||||
|
||||
const handleRowDelete = (row: RowData, e: Event) => {
|
||||
// 删除规则
|
||||
const handleRowDelete = async (row: any, e: Event) => {
|
||||
e.stopPropagation();
|
||||
console.log(row);
|
||||
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);
|
||||
} else {
|
||||
// 否则 put 新规则集
|
||||
await putBucketReplication(bucketName.value, {
|
||||
Role: res?.ReplicationConfiguration?.Role,
|
||||
Rules: newRules,
|
||||
});
|
||||
// 4. 删除远程目标
|
||||
if (arn) {
|
||||
deleteRemoteReplicationTarget(bucketName.value, arn);
|
||||
}
|
||||
}
|
||||
|
||||
message.success(t("Delete success"));
|
||||
loadReplication();
|
||||
} catch (err) {
|
||||
message.error(t("Delete failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const addFromRef = ref();
|
||||
const openForm = () => {
|
||||
addFromRef.value.open();
|
||||
}
|
||||
};
|
||||
|
||||
// 新增成功后刷新
|
||||
const onAddSuccess = () => {
|
||||
loadReplication();
|
||||
};
|
||||
</script>
|
||||
|
||||
+38
-38
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<page-header>
|
||||
<template #title>
|
||||
<h1 class="text-2xl font-bold">{{ t('Tiers') }}</h1>
|
||||
<h1 class="text-2xl font-bold">{{ t("Tiers") }}</h1>
|
||||
</template>
|
||||
</page-header>
|
||||
<page-content class="flex flex-col gap-4">
|
||||
@@ -18,11 +18,11 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<n-button @click="() => addForm()">
|
||||
<Icon name="ri:add-line" class="mr-2" />
|
||||
<span>{{ t('Add Tier') }}</span>
|
||||
<span>{{ t("Add Tier") }}</span>
|
||||
</n-button>
|
||||
<n-button @click="async () => refresh()">
|
||||
<Icon name="ri:refresh-line" class="mr-2" />
|
||||
<span>{{ t('Refresh') }}</span>
|
||||
<span>{{ t("Refresh") }}</span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,13 +43,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '#components';
|
||||
import { NButton, NSpace, type DataTableColumns, NPopconfirm } from 'naive-ui';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Icon } from "#components";
|
||||
import { NButton, NSpace, type DataTableColumns, NPopconfirm } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const usetier = useTiers();
|
||||
|
||||
const { t } = useI18n();
|
||||
const searchTerm = ref('');
|
||||
const searchTerm = ref("");
|
||||
|
||||
interface S3Config {
|
||||
name: string;
|
||||
@@ -74,44 +74,44 @@ interface RowData {
|
||||
|
||||
const getConfig = (row: RowData): S3Config | undefined => {
|
||||
switch (row.type) {
|
||||
case 'rustfs':
|
||||
case "rustfs":
|
||||
return row.rustfs;
|
||||
case 'minio':
|
||||
case "minio":
|
||||
return row.minio;
|
||||
case 's3':
|
||||
case "s3":
|
||||
return row.s3;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: DataTableColumns<RowData> = [
|
||||
{
|
||||
title: t('Tier Type'),
|
||||
key: 'type',
|
||||
title: t("Tier Type"),
|
||||
key: "type",
|
||||
render: (row) => row.type,
|
||||
},
|
||||
{
|
||||
title: t('Name'),
|
||||
key: 'name',
|
||||
title: t("Name"),
|
||||
key: "name",
|
||||
render: (row) => getConfig(row)?.name,
|
||||
},
|
||||
{
|
||||
title: t('Endpoint'),
|
||||
key: 'endpoint',
|
||||
title: t("Endpoint"),
|
||||
key: "endpoint",
|
||||
render: (row) => getConfig(row)?.endpoint,
|
||||
},
|
||||
{
|
||||
title: t('Bucket'),
|
||||
key: 'bucket',
|
||||
title: t("Bucket"),
|
||||
key: "bucket",
|
||||
render: (row) => getConfig(row)?.bucket,
|
||||
},
|
||||
{
|
||||
title: t('Prefix'),
|
||||
key: 'prefix',
|
||||
title: t("Prefix"),
|
||||
key: "prefix",
|
||||
render: (row) => getConfig(row)?.prefix,
|
||||
},
|
||||
{
|
||||
title: t('Region'),
|
||||
key: 'region',
|
||||
title: t("Region"),
|
||||
key: "region",
|
||||
render: (row) => getConfig(row)?.region,
|
||||
},
|
||||
// {
|
||||
@@ -141,15 +141,15 @@ const columns: DataTableColumns<RowData> = [
|
||||
// render: (row) => getConfig(row)?.versions,
|
||||
// },
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
title: t("Actions"),
|
||||
key: "actions",
|
||||
align: "center",
|
||||
width: 140,
|
||||
render: (row: RowData) => {
|
||||
return h(
|
||||
NSpace,
|
||||
{
|
||||
justify: 'center',
|
||||
justify: "center",
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
@@ -157,14 +157,14 @@ const columns: DataTableColumns<RowData> = [
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => deleteItem(row) },
|
||||
{
|
||||
default: () => t('Confirm Delete'),
|
||||
default: () => t("Confirm Delete"),
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', secondary: true },
|
||||
{ size: "small", secondary: true },
|
||||
{
|
||||
default: () => '',
|
||||
icon: () => h(Icon, { name: 'ri:delete-bin-5-line' }),
|
||||
default: () => "",
|
||||
icon: () => h(Icon, { name: "ri:delete-bin-5-line" }),
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -172,13 +172,13 @@ const columns: DataTableColumns<RowData> = [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
size: "small",
|
||||
secondary: true,
|
||||
onClick: (e) => handleRowClick(row, e),
|
||||
},
|
||||
{
|
||||
default: () => '',
|
||||
icon: () => h(Icon, { name: 'ri:edit-2-line' }),
|
||||
default: () => "",
|
||||
icon: () => h(Icon, { name: "ri:edit-2-line" }),
|
||||
}
|
||||
),
|
||||
],
|
||||
@@ -189,7 +189,7 @@ const columns: DataTableColumns<RowData> = [
|
||||
];
|
||||
|
||||
const { data, refresh } = await useAsyncData(
|
||||
'tier',
|
||||
"tier",
|
||||
async () => {
|
||||
const response = await usetier.listTiers();
|
||||
return response;
|
||||
@@ -213,7 +213,7 @@ const filteredData = computed(() => {
|
||||
|
||||
const infoRef = ref();
|
||||
const changeKeyVisible = ref(false);
|
||||
const editName = ref();
|
||||
const editName: any = ref("");
|
||||
const handleRowClick = (row: RowData, e: Event) => {
|
||||
e.stopPropagation();
|
||||
changeKeyVisible.value = true;
|
||||
@@ -222,16 +222,16 @@ const handleRowClick = (row: RowData, e: Event) => {
|
||||
|
||||
const message = useMessage();
|
||||
const deleteItem = async (row: RowData) => {
|
||||
const config = getConfig(row) || { name: '' };
|
||||
const config = getConfig(row) || { name: "" };
|
||||
if (!config.name) return;
|
||||
usetier
|
||||
.removeTiers(config.name)
|
||||
.then(() => {
|
||||
message.success(t('Delete Success'));
|
||||
message.success(t("Delete Success"));
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(t('Delete Failed'));
|
||||
message.error(t("Delete Failed"));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user