feat(nacos): support DataId mapping in config sync

Closes #6439
This commit is contained in:
t8y2 committed 2026-08-23 10:00:41 +00:00
1 parent d616e6b1a8
commit 6a06a12455
14 files changed
+388 -20

No files matched your search

@@ -16,7 +16,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import NacosConfigHistoryDialog from "@/components/nacos/NacosConfigHistoryDialog.vue";
import NacosConfigBatchDialog, { type NacosBatchDialogMode, type NacosConfigTransferTarget } from "@/components/nacos/NacosConfigBatchDialog.vue";
import NacosConfigBatchDialog, { type NacosBatchDialogMode, type NacosConfigTransferDialogPayload, type NacosConfigTransferTarget } from "@/components/nacos/NacosConfigBatchDialog.vue";
import NacosContentSearchDialog from "@/components/nacos/NacosContentSearchDialog.vue";
import { useToast } from "@/composables/useToast";
import { useNacosConfigListColumnResize, type ToggleableNacosConfigListColumnKey } from "@/composables/useNacosConfigListColumnResize";
@@ -67,7 +67,6 @@ import type {
NacosConnectionInfo,
NacosContentMatch,
NacosContentSearchResult,
NacosConflictPolicy,
NacosInstanceInfo,
NacosInstancePatch,
NacosInstanceRef,
@@ -435,6 +434,8 @@ function selectedKeys(): NacosConfigKey[] {
});
}
const selectedConfigTransferKeys = computed(() => selectedKeys());
function isBatchDeleteSnapshotInScope(snapshot: NacosBatchDeleteSnapshot) {
return snapshot.connectionId === props.connectionId && snapshot.namespace === namespace.value;
}
@@ -1245,7 +1246,7 @@ async function exportConfigArchive(scope: NacosConfigSelectionScope) {
const batchTransferRequest = shallowRef<NacosConfigTransferRequest | null>(null);
async function previewBatch(payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; targetGroup: string; policy: NacosConflictPolicy }) {
async function previewBatch(payload: NacosConfigTransferDialogPayload) {
batchLoading.value = true;
batchError.value = "";
batchPreview.value = null;
@@ -1262,6 +1263,7 @@ async function previewBatch(payload: { scope: NacosConfigSelectionScope; targetC
source: buildConfigSelector(payload.scope),
targetNamespace: payload.targetNamespace,
targetGroup: payload.targetGroup || undefined,
dataIdMappings: payload.dataIdMappings,
conflictPolicy: payload.policy,
};
batchTransferRequest.value = req;
@@ -1274,7 +1276,7 @@ async function previewBatch(payload: { scope: NacosConfigSelectionScope; targetC
}
}
async function applyBatch(payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; targetGroup: string; policy: NacosConflictPolicy }) {
async function applyBatch(payload: NacosConfigTransferDialogPayload) {
if (batchLoading.value || batchReport.value || !batchPreview.value) return;
if (payload.policy === "OVERWRITE" && !window.confirm(t("nacos.overwriteConfirm"))) return;
const targetConnectionId = batchMode.value === "import" ? props.connectionId : payload.targetConnectionId;
@@ -3030,6 +3032,7 @@ onBeforeUnmount(() => {
:mode="batchMode"
:loading="batchLoading"
:selected-count="selectedConfigCount"
:selected-keys="selectedConfigTransferKeys"
:filtered-count="configTotal"
:target-connections="batchTargetConnections"
:target-connection-id="batchTargetConnectionId"
@@ -5,7 +5,7 @@ import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { NacosBatchPreview, NacosBatchReport, NacosConfigSelectionScope, NacosConflictPolicy, NacosNamespaceInfo } from "@/types/nacos";
import type { NacosBatchPreview, NacosBatchReport, NacosConfigDataIdMapping, NacosConfigKey, NacosConfigSelectionScope, NacosConflictPolicy, NacosNamespaceInfo } from "@/types/nacos";
import { nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
export type NacosBatchDialogMode = "export" | "import" | "copy";
@@ -15,11 +15,21 @@ export interface NacosConfigTransferTarget {
label: string;
}
export interface NacosConfigTransferDialogPayload {
scope: NacosConfigSelectionScope;
targetConnectionId: string;
targetNamespace: string;
targetGroup: string;
dataIdMappings?: NacosConfigDataIdMapping[];
policy: NacosConflictPolicy;
}
const props = defineProps<{
open: boolean;
mode: NacosBatchDialogMode;
loading: boolean;
selectedCount: number;
selectedKeys: NacosConfigKey[];
filteredCount: number;
targetConnections: NacosConfigTransferTarget[];
targetConnectionId: string;
@@ -37,8 +47,8 @@ const emit = defineEmits<{
chooseFile: [];
reset: [];
targetConnectionChange: [connectionId: string];
preview: [payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; targetGroup: string; policy: NacosConflictPolicy }];
apply: [payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; targetGroup: string; policy: NacosConflictPolicy }];
preview: [payload: NacosConfigTransferDialogPayload];
apply: [payload: NacosConfigTransferDialogPayload];
export: [scope: NacosConfigSelectionScope];
}>();
@@ -47,6 +57,7 @@ const scope = ref<NacosConfigSelectionScope>("selected");
const policy = ref<NacosConflictPolicy>("ABORT");
const targetNamespace = ref("");
const targetGroup = ref("");
const targetDataIdDrafts = ref<NacosConfigDataIdMapping[]>([]);
const titleKey = computed(() => `nacos.batch${props.mode[0].toUpperCase()}${props.mode.slice(1)}Title`);
const descriptionKey = computed(() => `nacos.batch${props.mode[0].toUpperCase()}${props.mode.slice(1)}Description`);
@@ -111,6 +122,32 @@ function resetTargetNamespace() {
targetNamespace.value = targetNamespaces.value[0] ? JSON.stringify(targetNamespaces.value[0].namespace) : "";
}
function resetTargetDataIdDrafts() {
targetDataIdDrafts.value = props.selectedKeys.map((key) => ({
sourceGroup: key.group,
sourceDataId: key.dataId,
targetDataId: "",
}));
}
function transferPayload(): NacosConfigTransferDialogPayload {
const dataIdMappings =
scope.value === "selected"
? targetDataIdDrafts.value.flatMap((mapping) => {
const targetDataId = mapping.targetDataId.trim();
return targetDataId && targetDataId !== mapping.sourceDataId ? [{ ...mapping, targetDataId }] : [];
})
: [];
return {
scope: scope.value,
targetConnectionId: props.targetConnectionId,
targetNamespace: selectedTargetNamespace.value,
targetGroup: targetGroup.value.trim(),
...(dataIdMappings.length ? { dataIdMappings } : {}),
policy: policy.value,
};
}
watch(
() => props.open,
(open) => {
@@ -118,6 +155,7 @@ watch(
scope.value = props.selectedCount ? "selected" : "filtered";
policy.value = "ABORT";
targetGroup.value = "";
resetTargetDataIdDrafts();
resetTargetNamespace();
},
{ immediate: true },
@@ -206,7 +244,29 @@ watch(targetNamespaces, () => {
</div>
<div class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.targetGroup") }}</div>
<input v-model="targetGroup" type="text" class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" :disabled="loading" :placeholder="t('nacos.targetGroupPlaceholder')" @change="emit('reset')" />
<input v-model="targetGroup" data-testid="nacos-target-group" type="text" class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" :disabled="loading" :placeholder="t('nacos.targetGroupPlaceholder')" @change="emit('reset')" />
</div>
<div v-if="scope === 'selected' && targetDataIdDrafts.length" class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.targetDataIds") }}</div>
<p class="text-xs text-muted-foreground">{{ t("nacos.targetDataIdsHint") }}</p>
<div class="max-h-52 space-y-2 overflow-auto rounded-md border p-2">
<label v-for="(mapping, index) in targetDataIdDrafts" :key="`${mapping.sourceGroup}\u0000${mapping.sourceDataId}`" :for="`nacos-target-data-id-${index}`" class="grid gap-2 rounded-md bg-muted/30 p-2 text-xs sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-center">
<span class="min-w-0">
<span class="block truncate font-medium text-foreground" :title="mapping.sourceDataId">{{ mapping.sourceDataId }}</span>
<span class="block truncate text-muted-foreground" :title="mapping.sourceGroup">{{ mapping.sourceGroup }}</span>
</span>
<input
:id="`nacos-target-data-id-${index}`"
v-model="mapping.targetDataId"
data-testid="nacos-target-data-id"
type="text"
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
:disabled="loading"
:placeholder="mapping.sourceDataId"
@input="emit('reset')"
/>
</label>
</div>
</div>
<p class="text-xs text-muted-foreground">{{ t("nacos.copyKeepsSource") }}</p>
</div>
@@ -285,11 +345,11 @@ watch(targetNamespaces, () => {
{{ t("nacos.exportZip") }}
</Button>
<template v-else>
<Button v-if="!preview" :disabled="loading || !canContinue" @click="emit('preview', { scope, targetConnectionId, targetNamespace: selectedTargetNamespace, targetGroup: targetGroup.trim(), policy })">
<Button v-if="!preview" :disabled="loading || !canContinue" @click="emit('preview', transferPayload())">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.preview") }}
</Button>
<Button v-else :variant="policy === 'OVERWRITE' ? 'destructive' : 'default'" :disabled="loading || hasPreviewBlockingErrors" @click="emit('apply', { scope, targetConnectionId, targetNamespace: selectedTargetNamespace, targetGroup: targetGroup.trim(), policy })">
<Button v-else :variant="policy === 'OVERWRITE' ? 'destructive' : 'default'" :disabled="loading || hasPreviewBlockingErrors" @click="emit('apply', transferPayload())">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.apply") }}
</Button>
@@ -4,7 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import NacosConfigBatchDialog from "@/components/nacos/NacosConfigBatchDialog.vue";
import type { NacosBatchReport } from "@/types/nacos";
import type { NacosBatchReport, NacosConfigKey } from "@/types/nacos";
const mountedApps: App[] = [];
@@ -15,6 +15,7 @@ async function mountDialog(
{ namespace: "remote-only", namespaceShowName: "Remote only" },
],
report: NacosBatchReport | null = null,
selectedKeys: NacosConfigKey[] = [{ namespace: "shared", group: "DEFAULT_GROUP", dataId: "app.yaml" }],
) {
const onTargetConnectionChange = vi.fn();
const onPreview = vi.fn();
@@ -27,7 +28,8 @@ async function mountDialog(
open: true,
mode: "copy",
loading: false,
selectedCount: 1,
selectedCount: selectedKeys.length,
selectedKeys,
filteredCount: 1,
targetConnections: [
{ id: "source", label: "Source" },
@@ -80,7 +82,7 @@ describe("NacosConfigBatchDialog cross-connection sync", () => {
it("includes a trimmed target group override in the preview payload when provided", async () => {
const { onPreview } = await mountDialog("remote");
const groupInput = document.body.querySelector("input[type=text]") as HTMLInputElement;
const groupInput = document.body.querySelector("[data-testid=nacos-target-group]") as HTMLInputElement;
groupInput.value = " TARGET_GROUP ";
groupInput.dispatchEvent(new Event("input"));
await nextTick();
@@ -98,6 +100,85 @@ describe("NacosConfigBatchDialog cross-connection sync", () => {
});
});
it("emits a trimmed target Data ID mapping for one selected config", async () => {
const { onPreview } = await mountDialog("remote");
const dataIdInput = document.body.querySelector("[data-testid=nacos-target-data-id]") as HTMLInputElement;
dataIdInput.value = " app-prod.yaml ";
dataIdInput.dispatchEvent(new Event("input"));
await nextTick();
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview).toHaveBeenCalledWith({
scope: "selected",
targetConnectionId: "remote",
targetNamespace: "shared",
targetGroup: "",
dataIdMappings: [{ sourceGroup: "DEFAULT_GROUP", sourceDataId: "app.yaml", targetDataId: "app-prod.yaml" }],
policy: "ABORT",
});
});
it("lets multiple selected configs independently retain or edit their target Data IDs", async () => {
const selectedKeys = [
{ namespace: "shared", group: "DEFAULT_GROUP", dataId: "app.yaml" },
{ namespace: "shared", group: "WORKER_GROUP", dataId: "worker.yaml" },
];
const { onPreview } = await mountDialog("remote", undefined, null, selectedKeys);
const dataIdInputs = Array.from(document.body.querySelectorAll("[data-testid=nacos-target-data-id]")) as HTMLInputElement[];
expect(dataIdInputs).toHaveLength(2);
dataIdInputs[1].value = "worker-prod.yaml";
dataIdInputs[1].dispatchEvent(new Event("input"));
await nextTick();
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview).toHaveBeenCalledWith({
scope: "selected",
targetConnectionId: "remote",
targetNamespace: "shared",
targetGroup: "",
dataIdMappings: [{ sourceGroup: "WORKER_GROUP", sourceDataId: "worker.yaml", targetDataId: "worker-prod.yaml" }],
policy: "ABORT",
});
});
it("treats blank or unchanged target Data IDs as source-preserving defaults", async () => {
const { onPreview } = await mountDialog("remote");
const dataIdInput = document.body.querySelector("[data-testid=nacos-target-data-id]") as HTMLInputElement;
dataIdInput.value = " app.yaml ";
dataIdInput.dispatchEvent(new Event("input"));
await nextTick();
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview.mock.calls[0][0]).not.toHaveProperty("dataIdMappings");
});
it("hides target Data ID editing and omits mappings for non-selected scopes", async () => {
const { onPreview } = await mountDialog("remote");
const filteredScope = document.body.querySelector("input[value=filtered]") as HTMLInputElement;
filteredScope.click();
await nextTick();
expect(document.body.querySelector("[data-testid=nacos-target-data-id]")).toBeNull();
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview.mock.calls[0][0]).not.toHaveProperty("dataIdMappings");
});
it("excludes the source namespace only for same-connection sync and emits connection changes", async () => {
const { onTargetConnectionChange } = await mountDialog("source");
const selects = Array.from(document.body.querySelectorAll("select")) as HTMLSelectElement[];
+2
View File
@@ -8353,6 +8353,8 @@ export default {
chooseTargetNamespace: "Choose another namespace",
targetGroup: "Target group (optional)",
targetGroupPlaceholder: "Same as source group",
targetDataIds: "Target Data IDs (optional)",
targetDataIdsHint: "Selected configs can be renamed individually. Leave a field blank to keep its source Data ID.",
copyKeepsSource: "Sync uses one-way copy semantics. Source namespace configs stay unchanged.",
conflictPolicy: "Conflict policy",
policyABORT: "Abort on conflict",
+2
View File
@@ -6809,6 +6809,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "Seleccione otro namespace",
targetGroup: "Group de destino (opcional)",
targetGroupPlaceholder: "Igual que el group de origen",
targetDataIds: "Data ID de destino (opcional)",
targetDataIdsHint: "Las configuraciones seleccionadas se pueden renombrar individualmente. Deja un campo vacío para conservar el Data ID de origen.",
copyKeepsSource: "La sincronización usa una copia unidireccional. Las configuraciones del namespace de origen no cambian.",
conflictPolicy: "Política de conflictos",
policyABORT: "Abortar al encontrar conflictos",
+2
View File
@@ -6810,6 +6810,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "Scegli un altro namespace",
targetGroup: "Group di destinazione (facoltativo)",
targetGroupPlaceholder: "Uguale al group di origine",
targetDataIds: "Data ID di destinazione (facoltativi)",
targetDataIdsHint: "Le configurazioni selezionate possono essere rinominate singolarmente. Lascia un campo vuoto per mantenere il Data ID di origine.",
copyKeepsSource: "La sincronizzazione usa una copia unidirezionale. Le configurazioni del namespace di origine restano invariate.",
conflictPolicy: "Strategia conflitti",
policyABORT: "Interrompi in caso di conflitto",
+2
View File
@@ -6849,6 +6849,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "別の名前空間を選択",
targetGroup: "宛先グループ(任意)",
targetGroupPlaceholder: "元のグループと同じ",
targetDataIds: "宛先 Data ID(任意)",
targetDataIdsHint: "選択した設定ごとに Data ID を変更できます。空欄の場合は元の Data ID を保持します。",
copyKeepsSource: "同期は一方向コピーです。元の名前空間の設定は変更されません。",
conflictPolicy: "競合ポリシー",
policyABORT: "競合時に中止",
+2
View File
@@ -7549,6 +7549,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "다른 네임스페이스 선택",
targetGroup: "대상 그룹(선택 사항)",
targetGroupPlaceholder: "원본 그룹과 동일",
targetDataIds: "대상 Data ID(선택 사항)",
targetDataIdsHint: "선택한 구성을 개별적으로 이름 변경할 수 있습니다. 비워 두면 원본 Data ID를 유지합니다.",
copyKeepsSource: "동기화는 단방향 복사 의미를 사용합니다. 소스 네임스페이스 구성은 변경되지 않습니다.",
conflictPolicy: "충돌 정책",
policyABORT: "충돌 시 중단",
+2
View File
@@ -6811,6 +6811,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "Escolha outro namespace",
targetGroup: "Group de destino (opcional)",
targetGroupPlaceholder: "Igual ao group de origem",
targetDataIds: "Data IDs de destino (opcional)",
targetDataIdsHint: "As configurações selecionadas podem ser renomeadas individualmente. Deixe um campo vazio para manter o Data ID de origem.",
copyKeepsSource: "A sincronização usa cópia unidirecional. As configurações do namespace de origem permanecem inalteradas.",
conflictPolicy: "Política de conflitos",
policyABORT: "Abortar ao encontrar conflito",
+2
View File
@@ -8334,6 +8334,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "请选择其他命名空间",
targetGroup: "目标 Group(可选)",
targetGroupPlaceholder: "与源 Group 相同",
targetDataIds: "目标 Data ID(可选)",
targetDataIdsHint: "可逐项修改已选配置的 Data ID;留空则保留源 Data ID。",
copyKeepsSource: "同步采用单向复制语义,源命名空间中的配置保持不变。",
conflictPolicy: "冲突策略",
policyABORT: "遇冲突终止",
+2
View File
@@ -6119,6 +6119,8 @@ export default withEnglishFallback({
chooseTargetNamespace: "請選擇其他命名空間",
targetGroup: "目標 Group(選填)",
targetGroupPlaceholder: "與來源 Group 相同",
targetDataIds: "目標 Data ID(選填)",
targetDataIdsHint: "可逐項修改已選設定的 Data ID;留空則保留來源 Data ID。",
copyKeepsSource: "同步採用單向複製,來源命名空間中的設定保持不變。",
conflictPolicy: "衝突策略",
policyABORT: "遇到衝突時終止",
+7
View File
@@ -397,6 +397,12 @@ export interface NacosBatchReport {
items: NacosBatchItemResult[];
}
export interface NacosConfigDataIdMapping {
sourceGroup: string;
sourceDataId: string;
targetDataId: string;
}
export interface NacosConfigTransferRequest {
operationId: string;
sourceConnectionId: string;
@@ -404,6 +410,7 @@ export interface NacosConfigTransferRequest {
source: NacosConfigSelector;
targetNamespace: string;
targetGroup?: string;
dataIdMappings?: NacosConfigDataIdMapping[];
conflictPolicy: NacosConflictPolicy;
}