Merge branch 'replication'

This commit is contained in:
马登山
2025-07-08 16:57:15 +08:00
12 changed files with 862 additions and 461 deletions
+22 -11
View File
@@ -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") : "";
},
+30 -25
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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,
+10 -5
View File
@@ -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);
});
}
});
};
+55
View File
@@ -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,
};
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
});
};