feat(nacos): global content replacement with persistent rollback

This commit is contained in:
xiaochouwei authored and GitHub committed 2026-09-22 16:12:18 +08:00
1 parent cf36425b0a
commit 7ade5d2b6e
26 files changed
+2706 -13

No files matched your search

@@ -5,7 +5,7 @@ import { Compartment, StateEffect, StateField } from "@codemirror/state";
import { ensureSyntaxTree } from "@codemirror/language";
import { setDiagnostics } from "@codemirror/lint";
import { Decoration, EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, ReplaceAll, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
@@ -20,6 +20,7 @@ import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue"
import NacosConfigHistoryDialog from "@/components/nacos/NacosConfigHistoryDialog.vue";
import NacosConfigBatchDialog, { type NacosBatchDialogMode, type NacosConfigTransferDialogPayload, type NacosConfigTransferTarget } from "@/components/nacos/NacosConfigBatchDialog.vue";
import NacosContentSearchDialog from "@/components/nacos/NacosContentSearchDialog.vue";
import NacosContentReplaceDialog from "@/components/nacos/NacosContentReplaceDialog.vue";
import { useToast } from "@/composables/useToast";
import { useNacosConfigListColumnResize, type ToggleableNacosConfigListColumnKey } from "@/composables/useNacosConfigListColumnResize";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -221,6 +222,7 @@ const configEditorWheelZoomGestureGuard = createEditorWheelZoomGestureGuard();
const knownConfigFormats = ref<Record<string, string>>({});
const selectedConfigKeys = ref<string[]>([]);
const searchOpen = ref(false);
const contentReplaceOpen = ref(false);
const searchLoading = ref(false);
const searchError = ref("");
const searchResult = ref<NacosContentSearchResult | null>(null);
@@ -1465,6 +1467,11 @@ async function openSearchDialog() {
await loadBatchNamespaces();
}
async function refreshAfterContentReplace() {
await loadConfigsWithRetry(configPageNo.value);
if (selectedConfig.value) await selectConfig(selectedConfig.value);
}
async function openBatchDialog(mode: NacosBatchDialogMode) {
batchMode.value = mode;
resetBatchDialogState();
@@ -2803,6 +2810,10 @@ useUpdateBlocker(() =>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" :title="t('nacos.contentReplace')" @click="contentReplaceOpen = true">
<ReplaceAll class="h-3.5 w-3.5" />
{{ t("nacos.contentReplace") }}
</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" @click="openBatchDialog('export')">
<Archive class="h-3.5 w-3.5" />
{{ t("nacos.batchExport") }}
@@ -3443,6 +3454,8 @@ useUpdateBlocker(() =>
@clear="clearContentSearchSession"
/>
<NacosContentReplaceDialog v-model:open="contentReplaceOpen" :connection-id="connectionId" :current-namespace="namespace" :read-only="readOnly" @changed="refreshAfterContentReplace" />
<NacosConfigBatchDialog
v-model:open="batchOpen"
:mode="batchMode"
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { AlertTriangle, ArrowLeft, CheckCircle2, Eye, History, Loader2, RefreshCw, RotateCcw, Square, Trash2 } from "@lucide/vue";
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 { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import * as api from "@/lib/backend/api";
import { buildNacosInlineDiff, type NacosInlineDiffRow } from "@/lib/nacos/nacosAdmin";
import { buildNacosContentReplacePlan, type NacosContentReplacePlan, type NacosContentReplacePlanItem, type NacosContentReplaceReport, type NacosContentRollbackReport } from "@/lib/nacos/nacosContentReplace";
import { applyWithNacosHistory, rollbackFromNacosHistory, nacosHistoryTarget, nacosHistoryRollbackCandidates, withNacosHistoryLock, type NacosReplaceHistoryEntry } from "@/lib/nacos/nacosReplaceHistory";
import { deleteNacosReplaceHistory, listNacosReplaceHistory } from "@/lib/nacos/nacosReplaceHistoryStorage";
import type { NacosConfigItem, NacosContentMatch, NacosNamespaceScope, NacosSearchProgress } from "@/types/nacos";
const props = defineProps<{
open: boolean;
connectionId: string;
currentNamespace?: string;
readOnly?: boolean;
}>();
const emit = defineEmits<{ "update:open": [value: boolean]; changed: [] }>();
const { t } = useI18n();
const search = ref("");
const replacement = ref("");
const scope = ref<NacosNamespaceScope>("currentNamespace");
const group = ref("");
const dataId = ref("");
const plan = ref<NacosContentReplacePlan | null>(null);
const report = ref<NacosContentReplaceReport | null>(null);
const rollbackReport = ref<NacosContentRollbackReport | null>(null);
const previewing = ref(false);
const applying = ref(false);
const rollingBack = ref(false);
const error = ref("");
const progress = ref<NacosSearchProgress | null>(null);
const activeOperationId = ref("");
const diffItem = ref<NacosContentReplacePlanItem | null>(null);
const diffOpen = ref(false);
const selectedKeys = ref<Set<string>>(new Set());
const activeItemKey = ref("");
const tab = ref("replace");
const entries = ref<NacosReplaceHistoryEntry[]>([]);
const activeEntry = ref<NacosReplaceHistoryEntry | null>(null);
const selectedHistoryId = ref("");
const historyLoading = ref(false);
const confirmOpen = ref(false);
const confirmAction = ref<"rollback" | "delete">("rollback");
const confirmEntryId = ref("");
const deleting = ref(false);
let previewTarget = "";
let historySequence = 0;
let previewSequence = 0;
const busy = computed(() => previewing.value || applying.value || rollingBack.value || deleting.value);
const canPreview = computed(() => !!search.value && search.value !== replacement.value && !busy.value && !props.readOnly);
const selectedItems = computed(() => plan.value?.items.filter((item) => selectedKeys.value.has(item.key)) ?? []);
const selectedReplacements = computed(() => selectedItems.value.reduce((total, item) => total + item.replacements, 0));
const selectedPlan = computed<NacosContentReplacePlan | null>(() => (plan.value ? { ...plan.value, items: selectedItems.value, totalReplacements: selectedReplacements.value } : null));
const allSelected = computed(() => !!plan.value?.items.length && selectedItems.value.length === plan.value.items.length);
const someSelected = computed(() => selectedItems.value.length > 0 && !allSelected.value);
const canApply = computed(() => !!selectedItems.value.length && !report.value && !busy.value && !props.readOnly);
const activeDiffItem = computed(() => plan.value?.items.find((item) => item.key === activeItemKey.value) ?? plan.value?.items[0] ?? null);
const inlineDiffRows = computed(() => (activeDiffItem.value ? buildNacosInlineDiff(activeDiffItem.value.beforeContent, activeDiffItem.value.afterContent) : []));
const resultItemsByKey = computed(() => new Map(report.value?.items.map((item) => [item.key, item]) ?? []));
const rollbackItemsByKey = computed(() => new Map(rollbackReport.value?.items.map((item) => [item.key, item]) ?? []));
const canRollback = computed(() => !!activeEntry.value && nacosHistoryRollbackCandidates(activeEntry.value).length > 0 && !busy.value && !props.readOnly);
watch(
() => [props.open, props.connectionId] as const,
([open]) => {
if (!open || busy.value) return;
reset();
void loadHistory();
},
{ immediate: true },
);
watch(tab, (value) => {
if (value === "history") {
selectedHistoryId.value = "";
void loadHistory();
} else {
clearResults();
selectedHistoryId.value = "";
}
});
onBeforeUnmount(() => {
historySequence += 1;
void cancelPreview();
});
watch([search, replacement, scope, group, dataId, () => props.currentNamespace], () => {
if (previewing.value || applying.value || report.value) return;
plan.value = null;
selectedKeys.value = new Set();
activeItemKey.value = "";
error.value = "";
});
function reset() {
previewSequence += 1;
search.value = "";
replacement.value = "";
scope.value = "currentNamespace";
group.value = "";
dataId.value = "";
clearResults();
tab.value = "replace";
selectedHistoryId.value = "";
confirmOpen.value = false;
entries.value = [];
previewing.value = false;
applying.value = false;
rollingBack.value = false;
error.value = "";
progress.value = null;
activeOperationId.value = "";
}
function clearResults() {
plan.value = null;
report.value = null;
rollbackReport.value = null;
activeEntry.value = null;
selectedKeys.value = new Set();
activeItemKey.value = "";
}
function showError(cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
const keys: Record<string, string> = {
"nacos-history-storage-unavailable": "storageUnavailable",
"nacos-history-storage-failed": "storageFailed",
"nacos-history-target-mismatch": "targetMismatch",
"nacos-history-busy": "busy",
};
error.value = keys[message] ? t(`nacos.replaceHistory.${keys[message]}`) : message;
}
async function currentTarget(connectionId: string) {
const connection = (await api.loadConnections()).find((item) => item.id === connectionId);
if (!connection) throw new Error("nacos-history-target-mismatch");
return nacosHistoryTarget(connection);
}
async function loadHistory() {
const sequence = ++historySequence;
const connectionId = props.connectionId;
historyLoading.value = true;
try {
const loaded = await listNacosReplaceHistory(connectionId);
if (sequence === historySequence && connectionId === props.connectionId) entries.value = loaded;
} catch (cause) {
if (sequence === historySequence) showError(cause);
} finally {
if (sequence === historySequence) historyLoading.value = false;
}
}
function viewHistory(entry: NacosReplaceHistoryEntry) {
selectedHistoryId.value = entry.id;
activeEntry.value = entry;
plan.value = entry.plan;
report.value = entry.report;
rollbackReport.value = entry.rollback ?? null;
selectedKeys.value = new Set();
activeItemKey.value = entry.plan.items[0]?.key ?? "";
error.value = "";
}
function confirmOperation(action: "rollback" | "delete", id: string) {
if (busy.value || (action === "rollback" && !canRollback.value)) return;
confirmAction.value = action;
confirmEntryId.value = id;
confirmOpen.value = true;
}
async function confirmedOperation() {
if (busy.value) return;
if (confirmAction.value === "rollback") await rollback();
else {
deleting.value = true;
try {
const connectionId = props.connectionId;
await withNacosHistoryLock(connectionId, () => deleteNacosReplaceHistory(confirmEntryId.value, connectionId));
await loadHistory();
if (activeEntry.value?.id === confirmEntryId.value) {
clearResults();
selectedHistoryId.value = "";
}
} catch (cause) {
showError(cause);
} finally {
deleting.value = false;
}
}
confirmOpen.value = false;
}
function closeDialog(value: boolean) {
if (!value && busy.value) return;
emit("update:open", value);
}
function historyItemStatus(item: NacosContentReplacePlanItem) {
if (activeEntry.value?.inFlight?.key === item.key || activeEntry.value?.uncertainItems?.some((uncertain) => uncertain.key === item.key)) return t("nacos.replaceHistory.uncertain");
const rollback = rollbackItemsByKey.value.get(item.key);
if (rollback) return t(`nacos.replaceHistory.rollbackStatus.${rollback.status}`);
const applied = resultItemsByKey.value.get(item.key);
return applied ? t(`nacos.contentReplaceStatus.${applied.status}`) : t("nacos.replaceHistory.notExecuted");
}
function operationId() {
const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `nacos-replace-${suffix}`;
}
function matchIdentity(match: NacosContentMatch) {
return `${match.namespace}\u0000${match.group}\u0000${match.dataId}`;
}
async function mapConcurrent<T, R>(items: readonly T[], limit: number, mapper: (item: T) => Promise<R>): Promise<R[]> {
const results = Array.from({ length: items.length }) as R[];
let next = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await mapper(items[index]);
}
}),
);
return results;
}
async function preview() {
if (!canPreview.value) return;
const sequence = ++previewSequence;
const id = operationId();
activeOperationId.value = id;
previewing.value = true;
error.value = "";
plan.value = null;
report.value = null;
rollbackReport.value = null;
selectedKeys.value = new Set();
activeItemKey.value = "";
progress.value = null;
try {
const connectionId = props.connectionId;
previewTarget = await currentTarget(connectionId);
const result = await api.nacosSearchConfigContent(
connectionId,
{
operationId: id,
namespace: props.currentNamespace || undefined,
scope: scope.value,
query: search.value,
group: group.value.trim() || undefined,
dataId: dataId.value.trim() || undefined,
maxResults: 10_000,
},
(value) => {
if (sequence === previewSequence) progress.value = value;
},
);
if (sequence !== previewSequence) return;
if (result.incomplete || result.truncated || result.cancelled || result.failures.length) {
error.value = t("nacos.contentReplaceIncomplete");
return;
}
const matches = [...new Map(result.matches.map((match) => [matchIdentity(match), match])).values()];
const configs = await mapConcurrent(matches, 8, async (match): Promise<NacosConfigItem> => {
const config = await api.nacosGetConfig(connectionId, { namespace: match.namespace, group: match.group, dataId: match.dataId });
return { ...config, namespace: config.namespace || match.namespace, group: config.group || match.group, dataId: config.dataId || match.dataId };
});
if (sequence !== previewSequence) return;
const nextPlan = buildNacosContentReplacePlan(configs, search.value, replacement.value);
plan.value = nextPlan;
selectedKeys.value = new Set(nextPlan.items.map((item) => item.key));
activeItemKey.value = nextPlan.items[0]?.key ?? "";
} catch (cause) {
if (sequence === previewSequence) error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (sequence === previewSequence) {
previewing.value = false;
activeOperationId.value = "";
}
}
}
async function cancelPreview() {
previewSequence += 1;
const id = activeOperationId.value;
activeOperationId.value = "";
previewing.value = false;
if (id) await api.nacosCancelConfigContentSearch(id).catch(() => undefined);
}
async function apply() {
const currentPlan = selectedPlan.value;
if (!canApply.value || !currentPlan) return;
applying.value = true;
error.value = "";
try {
const connectionId = props.connectionId;
activeEntry.value = await withNacosHistoryLock(connectionId, async () => {
const target = await currentTarget(connectionId);
if (target !== previewTarget) throw new Error("nacos-history-target-mismatch");
return applyWithNacosHistory(connectionId, target, { scope: scope.value, namespace: props.currentNamespace || "public", group: group.value, dataId: dataId.value }, currentPlan, {
getConfig: (key) => api.nacosGetConfig(connectionId, key),
publishConfig: (request) => api.nacosPublishConfig(connectionId, request),
});
});
plan.value = activeEntry.value.plan;
selectedKeys.value = new Set(plan.value.items.map((item) => item.key));
if (!selectedKeys.value.has(activeItemKey.value)) activeItemKey.value = plan.value.items[0]?.key ?? "";
report.value = activeEntry.value.report;
if (report.value.replaced) emit("changed");
} catch (cause) {
showError(cause);
// The interrupted batch is already durable; do not allow a second apply
// using the same stale plan after a journal failure.
plan.value = null;
selectedKeys.value = new Set();
activeItemKey.value = "";
emit("changed");
} finally {
applying.value = false;
await loadHistory();
}
}
async function rollback() {
if (!canRollback.value || !activeEntry.value || activeEntry.value.id !== confirmEntryId.value) return;
rollingBack.value = true;
error.value = "";
try {
const connectionId = props.connectionId;
activeEntry.value = await withNacosHistoryLock(connectionId, async () => {
const target = await currentTarget(connectionId);
return rollbackFromNacosHistory(confirmEntryId.value, connectionId, target, {
getConfig: (key) => api.nacosGetConfig(connectionId, key),
publishConfig: (request) => api.nacosPublishConfig(connectionId, request),
});
});
rollbackReport.value = activeEntry.value.rollback ?? null;
if (rollbackReport.value?.restored) emit("changed");
} catch (cause) {
showError(cause);
emit("changed");
} finally {
rollingBack.value = false;
await loadHistory();
const refreshed = entries.value.find((entry) => entry.id === activeEntry.value?.id);
if (refreshed) {
activeEntry.value = refreshed;
rollbackReport.value = refreshed.rollback ?? null;
}
}
}
function showDiff(item: NacosContentReplacePlanItem) {
diffItem.value = item;
diffOpen.value = true;
}
function toggleAllSelection(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
selectedKeys.value = checked ? new Set(plan.value?.items.map((item) => item.key) ?? []) : new Set();
}
function toggleItemSelection(item: NacosContentReplacePlanItem, event: Event) {
const next = new Set(selectedKeys.value);
if ((event.target as HTMLInputElement).checked) next.add(item.key);
else next.delete(item.key);
selectedKeys.value = next;
activeItemKey.value = item.key;
}
function inlineRowClass(type: NacosInlineDiffRow["type"]) {
return {
"bg-red-500/20 text-red-700 dark:text-red-50": type === "delete",
"bg-emerald-500/18 text-emerald-700 dark:text-emerald-50": type === "insert",
"text-foreground": type === "equal",
};
}
function inlineGutterClass(type: NacosInlineDiffRow["type"]) {
return {
"text-red-600 dark:text-red-300": type === "delete",
"text-emerald-600 dark:text-emerald-300": type === "insert",
"text-muted-foreground": type === "equal",
};
}
function inlinePrefix(type: NacosInlineDiffRow["type"]) {
if (type === "delete") return "-";
if (type === "insert") return "+";
return "";
}
function inlineRowSegmentClass(type: NacosInlineDiffRow["type"], changed: boolean) {
if (!changed) return "";
if (type === "delete") return "rounded-[2px] bg-red-500/80 px-px text-red-50";
if (type === "insert") return "rounded-[2px] bg-emerald-500/75 px-px text-emerald-50";
return "";
}
</script>
<template>
<Dialog :open="open" @update:open="closeDialog">
<DialogContent class="nacos-content-replace-dialog flex max-h-[88vh] flex-col overflow-hidden" :show-close-button="!busy" @escape-key-down="busy && $event.preventDefault()" @pointer-down-outside="busy && $event.preventDefault()">
<DialogHeader>
<DialogTitle>{{ t("nacos.contentReplaceTitle") }}</DialogTitle>
<DialogDescription>{{ t("nacos.contentReplaceDescription") }}</DialogDescription>
</DialogHeader>
<Tabs v-model="tab">
<TabsList>
<TabsTrigger value="replace" :disabled="busy">{{ t("nacos.replaceHistory.replaceTab") }}</TabsTrigger>
<TabsTrigger value="history" data-testid="nacos-replace-history-tab" :disabled="busy"><History class="mr-1.5 h-4 w-4" />{{ t("nacos.replaceHistory.historyTab") }}</TabsTrigger>
</TabsList>
</Tabs>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<template v-if="tab === 'history' && !selectedHistoryId">
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-muted-foreground">{{ t("nacos.replaceHistory.batchCount", { count: entries.length }) }}</span>
<Button size="icon" variant="ghost" :disabled="historyLoading || busy" :title="t('nacos.replaceHistory.refresh')" :aria-label="t('nacos.replaceHistory.refresh')" @click="loadHistory"><RefreshCw class="h-4 w-4" :class="{ 'animate-spin': historyLoading }" /></Button>
</div>
<div v-if="!entries.length && !historyLoading" class="py-8 text-center text-sm text-muted-foreground">{{ t("nacos.replaceHistory.empty") }}</div>
<div class="divide-y">
<div v-for="entry in entries" :key="entry.id" class="flex min-w-0 items-start gap-2 py-3">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2 text-sm">
<time>{{ new Date(entry.createdAt).toLocaleString() }}</time
><Badge variant="outline">{{ t(`nacos.replaceHistory.state.${entry.state}`) }}</Badge>
</div>
<div class="break-all font-mono text-xs">{{ entry.plan.search }} → {{ entry.plan.replacement }}</div>
<div class="break-all text-xs text-muted-foreground">{{ entry.scope.scope === "allNamespaces" ? t("nacos.allNamespaces") : entry.scope.namespace }} / {{ entry.scope.group || t("nacos.contentReplaceAllGroups") }} / {{ entry.scope.dataId || t("nacos.contentReplaceAllDataIds") }}</div>
<div class="text-xs">{{ t("nacos.contentReplaceReport", entry.report) }}</div>
<div v-if="entry.rollback" class="text-xs text-muted-foreground">{{ t("nacos.contentReplaceRollbackReport", entry.rollback) }}</div>
</div>
<Button size="icon" variant="ghost" data-testid="nacos-replace-history-details" :disabled="busy" :title="t('nacos.replaceHistory.details')" :aria-label="t('nacos.replaceHistory.details')" @click="viewHistory(entry)"><Eye class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" :disabled="busy" :title="t('nacos.replaceHistory.delete')" :aria-label="t('nacos.replaceHistory.delete')" @click="confirmOperation('delete', entry.id)"><Trash2 class="h-4 w-4" /></Button>
</div>
</div>
</template>
<div v-if="tab === 'history' && selectedHistoryId" class="space-y-2">
<Button variant="ghost" size="sm" :disabled="busy" @click="selectedHistoryId = ''"><ArrowLeft class="h-4 w-4" />{{ t("nacos.replaceHistory.historyTab") }}</Button>
<div class="break-all text-sm">{{ new Date(activeEntry!.createdAt).toLocaleString() }} · {{ t(`nacos.replaceHistory.state.${activeEntry!.state}`) }}</div>
<div class="break-all font-mono text-sm">{{ plan?.search }} → {{ plan?.replacement }}</div>
<div v-if="activeEntry?.inFlight || activeEntry?.uncertainItems?.length" class="text-sm text-amber-700 dark:text-amber-300">{{ t("nacos.replaceHistory.interrupted") }}</div>
</div>
<template v-if="tab === 'replace'">
<div class="grid gap-3 md:grid-cols-2">
<label class="space-y-1.5 text-sm">
<span class="font-medium">{{ t("nacos.contentReplaceSearch") }}</span>
<Input v-model="search" data-testid="nacos-replace-search" autocomplete="off" :disabled="busy || !!report" />
</label>
<label class="space-y-1.5 text-sm">
<span class="font-medium">{{ t("nacos.contentReplaceValue") }}</span>
<Input v-model="replacement" data-testid="nacos-replace-value" autocomplete="off" :disabled="busy || !!report" />
</label>
</div>
<div class="grid gap-3 md:grid-cols-3">
<label class="space-y-1.5 text-sm">
<span class="font-medium">{{ t("nacos.contentReplaceScope") }}</span>
<select v-model="scope" data-testid="nacos-replace-scope" class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" :disabled="busy || !!report">
<option value="currentNamespace">{{ t("nacos.currentNamespace") }}</option>
<option value="allNamespaces">{{ t("nacos.allNamespaces") }}</option>
</select>
</label>
<label class="space-y-1.5 text-sm">
<span class="font-medium">Group</span>
<Input v-model="group" :placeholder="t('nacos.contentReplaceAllGroups')" :disabled="busy || !!report" />
</label>
<label class="space-y-1.5 text-sm">
<span class="font-medium">Data ID</span>
<Input v-model="dataId" :placeholder="t('nacos.contentReplaceAllDataIds')" :disabled="busy || !!report" />
</label>
</div>
<div v-if="scope === 'allNamespaces'" class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("nacos.contentReplaceAllNamespacesWarning") }}</span>
</div>
<div v-if="previewing && progress" class="text-sm text-muted-foreground">{{ t("nacos.contentReplaceScanning", { scanned: progress.scanned, matched: progress.matched }) }}</div>
</template>
<div v-if="error" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span class="min-w-0 break-words">{{ error }}</span>
</div>
<section v-if="plan && (tab === 'replace' || selectedHistoryId)" class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{{ t("nacos.contentReplaceConfigCount", { count: plan.items.length }) }}</Badge>
<Badge v-if="tab === 'replace' && !report" variant="outline">{{ t("nacos.contentReplaceSelectedCount", { selected: selectedItems.length, total: plan.items.length }) }}</Badge>
<Badge variant="outline">{{ t("nacos.contentReplaceOccurrenceCount", { count: tab === "replace" && !report ? selectedReplacements : plan.totalReplacements }) }}</Badge>
</div>
<div v-if="!plan.items.length" class="py-8 text-center text-sm text-muted-foreground">{{ t("nacos.contentReplaceNoMatches") }}</div>
<div v-else class="grid min-h-[360px] overflow-hidden rounded-md border lg:h-[min(48vh,520px)] lg:grid-cols-[minmax(280px,360px)_minmax(0,1fr)]">
<div class="flex min-h-0 flex-col border-b lg:border-b-0 lg:border-r">
<label v-if="tab === 'replace' && !report" class="flex shrink-0 items-center gap-2 border-b px-3 py-2.5 text-sm font-medium">
<input data-testid="nacos-replace-select-all" type="checkbox" class="h-4 w-4 rounded border-border" :checked="allSelected" :indeterminate="someSelected" :disabled="busy || readOnly" @change="toggleAllSelection" />
<span>{{ t("nacos.contentReplaceSelectAll") }}</span>
<span class="ml-auto text-xs font-normal text-muted-foreground">{{ selectedItems.length }} / {{ plan.items.length }}</span>
</label>
<div class="max-h-64 min-h-0 divide-y overflow-y-auto lg:max-h-none lg:flex-1">
<div v-for="(item, index) in plan.items" :key="item.key" class="flex min-w-0 items-center gap-2 px-2 py-1.5" :class="{ 'bg-muted/60': activeDiffItem?.key === item.key }">
<input
v-if="tab === 'replace' && !report"
:data-testid="`nacos-replace-select-${index}`"
type="checkbox"
class="h-4 w-4 shrink-0 rounded border-border"
:aria-label="t('nacos.contentReplaceSelectConfig', { dataId: item.dataId })"
:checked="selectedKeys.has(item.key)"
:disabled="busy || readOnly"
@change="toggleItemSelection(item, $event)"
/>
<button :data-testid="`nacos-replace-item-${index}`" type="button" class="min-w-0 flex-1 px-1 py-1 text-left" :aria-pressed="activeDiffItem?.key === item.key" @click="activeItemKey = item.key">
<span class="block break-all font-mono text-sm font-medium">{{ item.dataId }}</span>
<span class="block break-all text-xs text-muted-foreground">{{ item.namespace || "public" }} / {{ item.group }} · {{ t("nacos.contentReplaceOccurrenceCount", { count: item.replacements }) }}</span>
<span v-if="resultItemsByKey.get(item.key)?.message || rollbackItemsByKey.get(item.key)?.message" class="block break-words text-xs text-destructive">{{ rollbackItemsByKey.get(item.key)?.message || resultItemsByKey.get(item.key)?.message }}</span>
</button>
<Badge v-if="report" class="max-w-28 shrink-0 whitespace-normal text-center" :variant="resultItemsByKey.get(item.key)?.status === 'replaced' ? 'secondary' : 'destructive'">
{{ historyItemStatus(item) }}
</Badge>
<Button type="button" size="icon" variant="ghost" class="shrink-0" :title="t('nacos.contentReplaceViewDiff')" :aria-label="t('nacos.contentReplaceViewDiff')" @click="showDiff(item)"><Eye class="h-4 w-4" /></Button>
</div>
</div>
</div>
<section class="flex min-h-[280px] min-w-0 flex-col bg-background lg:min-h-0">
<div class="shrink-0 border-b px-4 py-2.5">
<div class="text-sm font-medium">{{ t("nacos.contentReplaceViewDiff") }}</div>
<div v-if="activeDiffItem" class="break-all text-xs text-muted-foreground">{{ activeDiffItem.namespace || "public" }} / {{ activeDiffItem.group }} / {{ activeDiffItem.dataId }}</div>
</div>
<div data-testid="nacos-replace-inline-diff" class="min-h-0 flex-1 overflow-auto font-mono text-[13px] leading-6">
<div v-for="row in inlineDiffRows" :key="row.id" class="grid min-w-max grid-cols-[48px_22px_minmax(40rem,1fr)]" :class="inlineRowClass(row.type)">
<span class="select-none border-r border-border pr-2 text-right" :class="inlineGutterClass(row.type)">{{ row.lineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="inlineGutterClass(row.type)">{{ inlinePrefix(row.type) }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, segmentIndex) in row.segments" :key="segmentIndex"><span :class="inlineRowSegmentClass(row.type, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
</section>
</div>
</section>
<div v-if="report && (tab === 'replace' || selectedHistoryId)" class="flex items-start gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
<CheckCircle2 class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("nacos.contentReplaceReport", { replaced: report.replaced, conflicts: report.conflicts, failed: report.failed }) }}</span>
</div>
<div v-if="rollbackReport && (tab === 'replace' || selectedHistoryId)" class="text-sm text-muted-foreground">{{ t("nacos.contentReplaceRollbackReport", rollbackReport) }}</div>
</div>
<DialogFooter class="gap-2 border-t pt-4">
<Button v-if="tab === 'replace' && !report" type="button" variant="outline" data-testid="nacos-replace-preview" :disabled="!canPreview" @click="preview">
<Loader2 v-if="previewing" class="h-4 w-4 animate-spin" />
<Eye v-else class="h-4 w-4" />
{{ t("nacos.contentReplacePreview") }}
</Button>
<Button v-if="previewing" type="button" variant="outline" @click="cancelPreview"><Square class="h-3.5 w-3.5" />{{ t("nacos.cancel") }}</Button>
<Button v-if="tab === 'replace' && plan?.items.length && !report" type="button" data-testid="nacos-replace-apply" :disabled="!canApply" @click="apply">
<Loader2 v-if="applying" class="h-4 w-4 animate-spin" />
{{ t("nacos.contentReplaceApply", { count: selectedItems.length }) }}
</Button>
<Button v-if="activeEntry && (tab === 'replace' || selectedHistoryId)" type="button" variant="outline" data-testid="nacos-replace-history-rollback" :disabled="!canRollback" @click="confirmOperation('rollback', activeEntry.id)">
<Loader2 v-if="rollingBack" class="h-4 w-4 animate-spin" />
<RotateCcw v-else class="h-4 w-4" />
{{ t("nacos.contentReplaceRollback") }}
</Button>
<Button type="button" variant="outline" :disabled="busy" @click="closeDialog(false)">{{ t("common.close") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<NacosConfigDiffDialog v-if="diffOpen" v-model:open="diffOpen" :before="diffItem?.beforeContent ?? ''" :after="diffItem?.afterContent ?? ''" :format="diffItem?.configType" :show-confirm="false" />
<DangerConfirmDialog
v-if="confirmOpen"
v-model:open="confirmOpen"
:title="t(`nacos.replaceHistory.${confirmAction}Title`)"
:message="t(`nacos.replaceHistory.${confirmAction}Confirm`)"
:confirm-label="t(confirmAction === 'rollback' ? 'nacos.contentReplaceRollback' : 'nacos.replaceHistory.delete')"
:close-on-confirm="false"
:loading="rollingBack || deleting"
@confirm="confirmedOperation"
/>
</template>
<style>
.nacos-content-replace-dialog {
width: min(96vw, 1440px) !important;
max-width: min(96vw, 1440px) !important;
}
</style>
@@ -0,0 +1,387 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import NacosContentReplaceDialog from "@/components/nacos/NacosContentReplaceDialog.vue";
import * as historyStorage from "@/lib/nacos/nacosReplaceHistoryStorage";
import { buildNacosContentReplacePlan } from "@/lib/nacos/nacosContentReplace";
import { nacosHistoryTarget, type NacosReplaceHistoryEntry } from "@/lib/nacos/nacosReplaceHistory";
const api = vi.hoisted(() => ({
nacosSearchConfigContent: vi.fn(),
nacosCancelConfigContentSearch: vi.fn(),
nacosGetConfig: vi.fn(),
nacosPublishConfig: vi.fn(),
loadConnections: vi.fn(),
}));
const history = vi.hoisted(() => new Map<string, unknown>());
vi.mock("@/lib/nacos/nacosReplaceHistoryStorage", () => ({
saveNacosReplaceHistory: vi.fn(async (entry) => {
history.set(entry.id, JSON.parse(JSON.stringify(entry)));
}),
listNacosReplaceHistory: vi.fn(async () => [...history.values()]),
getNacosReplaceHistory: vi.fn(async (id) => structuredClone(history.get(id))),
deleteNacosReplaceHistory: vi.fn(async (id) => {
history.delete(id);
}),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: defineComponent({ props: { open: Boolean }, emits: ["confirm"], template: '<button v-if="open" data-testid="history-confirm" @click="$emit(\'confirm\')">Confirm</button>' }),
}));
vi.mock("@/lib/backend/api", () => api);
vi.mock("@/components/nacos/NacosConfigDiffDialog.vue", () => ({
default: defineComponent({
props: { open: Boolean, before: String, after: String },
template: '<div v-if="open" data-testid="nacos-replace-diff" :data-before="before" :data-after="after" />',
}),
}));
const mountedApps: App[] = [];
beforeEach(() => {
history.clear();
api.loadConnections.mockResolvedValue([{ id: "nacos-main", host: "localhost", port: 8848, external_config: { serverAddr: "http://localhost:8848" } }]);
Object.defineProperty(navigator, "locks", { configurable: true, value: { request: async (_name: string, _options: unknown, cb: (lock: object) => unknown) => cb({}) } });
});
async function mountDialog(readOnly = false) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () => h(NacosContentReplaceDialog, { open: true, connectionId: "nacos-main", currentNamespace: "public", readOnly }),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
await nextTick();
}
function input(testId: string, value: string) {
const element = document.body.querySelector(`[data-testid=${testId}]`) as HTMLInputElement;
element.value = value;
element.dispatchEvent(new Event("input"));
}
async function click(testId: string) {
await nextTick();
const element = document.body.querySelector(`[data-testid=${testId}]`) as HTMLButtonElement;
if (element.getAttribute("role") === "tab") element.dispatchEvent(new MouseEvent("mousedown", { button: 0, ctrlKey: false, bubbles: true }));
else element.click();
await nextTick();
await nextTick();
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.clearAllMocks();
});
describe("NacosContentReplaceDialog", () => {
function historicalEntry(): NacosReplaceHistoryEntry {
const plan = buildNacosContentReplacePlan([{ namespace: "public", group: "test", dataId: "historical.yaml", content: "mysql-old" }], "mysql-old", "mysql-new");
return {
version: 1,
id: "batch",
connectionId: "nacos-main",
target: nacosHistoryTarget({ id: "nacos-main", host: "localhost", port: 8848, external_config: { serverAddr: "http://localhost:8848" } }),
createdAt: 1,
updatedAt: 1,
scope: { scope: "currentNamespace", namespace: "public", group: "test", dataId: "historical" },
state: "completed",
plan,
report: { ...plan, items: [{ ...plan.items[0], status: "replaced", appliedMd5: "after" }], replaced: 1, conflicts: 0, failed: 0, cancelled: false },
};
}
it("requires confirmation to delete only a local record without publishing configs", async () => {
history.set("batch", historicalEntry());
await mountDialog();
await click("nacos-replace-history-tab");
await vi.waitFor(() => expect(document.body.textContent).toContain("1 replaced"));
(document.body.querySelector('[aria-label="Delete record"]') as HTMLButtonElement).click();
await nextTick();
expect(history.has("batch")).toBe(true);
await click("history-confirm");
await vi.waitFor(() => expect(history.has("batch")).toBe(false));
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
});
it("allows read-only history and diff inspection but blocks historical rollback", async () => {
history.set("batch", historicalEntry());
await mountDialog(true);
await click("nacos-replace-history-tab");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-history-details]")).not.toBeNull());
await click("nacos-replace-history-details");
expect((document.body.querySelector("[data-testid=nacos-replace-history-rollback]") as HTMLButtonElement).disabled).toBe(true);
(document.body.querySelector('[aria-label="View replacement diff"]') as HTMLButtonElement).click();
await nextTick();
expect(document.body.querySelector("[data-testid=nacos-replace-diff]")?.getAttribute("data-before")).toBe("mysql-old");
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
});
it("renders unfinished batches, uncertain items, pending items and individual error messages", async () => {
const entry = historicalEntry();
entry.state = "applying";
entry.inFlight = { key: entry.plan.items[0].key, phase: "apply" };
entry.plan.items.push({ ...entry.plan.items[0], key: "pending", dataId: "pending.yaml" });
entry.report.items[0].message = "individual verification error";
history.set("batch", entry);
await mountDialog();
await click("nacos-replace-history-tab");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-history-details]")).not.toBeNull());
await click("nacos-replace-history-details");
expect(document.body.textContent).toContain("Uncertain");
expect(document.body.textContent).toContain("Not executed");
expect(document.body.textContent).toContain("individual verification error");
expect((document.body.querySelector("[data-testid=nacos-replace-history-rollback]") as HTMLButtonElement).disabled).toBe(true);
});
it("shows secure-storage read failures instead of silently pretending history is empty", async () => {
vi.mocked(historyStorage.listNacosReplaceHistory).mockRejectedValueOnce(new Error("nacos-history-storage-failed"));
await mountDialog();
await vi.waitFor(() => expect(document.body.textContent).toContain("Could not save or read batch history"));
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
});
it("blocks a historical rollback after the saved connection target changes", async () => {
history.set("batch", historicalEntry());
await mountDialog();
await click("nacos-replace-history-tab");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-history-details]")).not.toBeNull());
await click("nacos-replace-history-details");
api.loadConnections.mockResolvedValue([{ id: "nacos-main", host: "other", port: 8848 }]);
await click("nacos-replace-history-rollback");
await click("history-confirm");
await vi.waitFor(() => expect(document.body.textContent).toContain("different Nacos server"));
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
});
it("does not publish or keep an applicable stale preview after a backup-save error", async () => {
api.nacosSearchConfigContent.mockResolvedValue({ matches: [{ namespace: "public", group: "test", dataId: "historical.yaml" }], failures: [] });
api.nacosGetConfig.mockResolvedValue({ namespace: "public", group: "test", dataId: "historical.yaml", content: "mysql-old" });
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-apply]")).not.toBeNull());
vi.mocked(historyStorage.saveNacosReplaceHistory).mockRejectedValueOnce(new Error("nacos-history-storage-failed"));
await click("nacos-replace-apply");
await vi.waitFor(() => expect(document.body.textContent).toContain("Could not save or read batch history"));
expect(document.body.querySelector("[data-testid=nacos-replace-apply]")).toBeNull();
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
});
it("previews and applies one literal replacement across every matching Data ID and namespace", async () => {
const configs = new Map([
["public/DEFAULT_GROUP/application.yaml", { namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", content: "url: mysql-old:3306\nreplica: mysql-old:3306", configType: "yaml", md5: "app-before" }],
["tenant-a/orders/orders.yaml", { namespace: "tenant-a", group: "orders", dataId: "orders.yaml", content: "dsn=mysql-old:3306/orders", configType: "yaml", md5: "orders-before" }],
]);
api.nacosSearchConfigContent.mockResolvedValue({
operationId: "replace-search",
scanned: 2,
matches: [
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", lineNumber: 1, snippet: "mysql-old:3306" },
{ namespace: "tenant-a", group: "orders", dataId: "orders.yaml", lineNumber: 1, snippet: "mysql-old:3306" },
],
failures: [],
truncated: false,
cancelled: false,
incomplete: false,
});
api.nacosGetConfig.mockImplementation(async (_connectionId: string, key: { namespace: string; group: string; dataId: string }) => configs.get(`${key.namespace}/${key.group}/${key.dataId}`));
api.nacosPublishConfig.mockImplementation(async (_connectionId: string, request: { namespace: string; group: string; dataId: string; content: string }) => {
const key = `${request.namespace}/${request.group}/${request.dataId}`;
configs.set(key, { ...configs.get(key)!, ...request, md5: `${request.dataId}-after` });
});
await mountDialog();
input("nacos-replace-search", "mysql-old:3306");
input("nacos-replace-value", "mysql-new:3306");
const scope = document.body.querySelector("[data-testid=nacos-replace-scope]") as HTMLSelectElement;
scope.value = "allNamespaces";
scope.dispatchEvent(new Event("change"));
await click("nacos-replace-preview");
expect(api.nacosSearchConfigContent).toHaveBeenCalledWith("nacos-main", expect.objectContaining({ scope: "allNamespaces", query: "mysql-old:3306", maxResults: 10_000 }), expect.any(Function));
await vi.waitFor(() => expect(document.body.textContent).toContain("2 configs"));
expect(document.body.textContent).toContain("2 configs");
expect(document.body.textContent).toContain("3 replacements");
expect(document.body.textContent).toContain("application.yaml");
expect(document.body.textContent).toContain("orders.yaml");
await click("nacos-replace-apply");
await vi.waitFor(() => expect(api.nacosPublishConfig).toHaveBeenCalledTimes(2));
expect(api.nacosPublishConfig).toHaveBeenCalledTimes(2);
expect(api.nacosPublishConfig).toHaveBeenCalledWith("nacos-main", expect.objectContaining({ dataId: "application.yaml", content: "url: mysql-new:3306\nreplica: mysql-new:3306", casMd5: "app-before" }));
expect(api.nacosPublishConfig).toHaveBeenCalledWith("nacos-main", expect.objectContaining({ dataId: "orders.yaml", content: "dsn=mysql-new:3306/orders", casMd5: "orders-before" }));
expect(document.body.textContent).toContain("2 replaced");
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
await mountDialog();
await click("nacos-replace-history-tab");
await vi.waitFor(() => expect(document.body.textContent).toContain("2 replaced"));
await click("nacos-replace-history-details");
expect(document.body.textContent).toContain("application.yaml");
await click("nacos-replace-history-rollback");
expect(api.nacosPublishConfig).toHaveBeenCalledTimes(2);
await click("history-confirm");
await vi.waitFor(() => expect(api.nacosPublishConfig).toHaveBeenCalledTimes(4));
expect(configs.get("public/DEFAULT_GROUP/application.yaml")?.content).toContain("mysql-old:3306");
});
it("selects every preview result by default and switches the inline diff from the result list", async () => {
const configs = new Map([
["public/DEFAULT_GROUP/application.yaml", { namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", content: "primary=mysql-old", md5: "app-before" }],
["public/DEFAULT_GROUP/orders.yaml", { namespace: "public", group: "DEFAULT_GROUP", dataId: "orders.yaml", content: "replica=mysql-old", md5: "orders-before" }],
]);
api.nacosSearchConfigContent.mockResolvedValue({
matches: [
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml" },
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "orders.yaml" },
],
failures: [],
truncated: false,
cancelled: false,
incomplete: false,
});
api.nacosGetConfig.mockImplementation(async (_connectionId: string, key: { namespace: string; group: string; dataId: string }) => configs.get(`${key.namespace}/${key.group}/${key.dataId}`));
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-select-all]")).not.toBeNull());
expect((document.body.querySelector("[data-testid=nacos-replace-select-all]") as HTMLInputElement).checked).toBe(true);
expect((document.body.querySelector("[data-testid=nacos-replace-select-0]") as HTMLInputElement).checked).toBe(true);
expect((document.body.querySelector("[data-testid=nacos-replace-select-1]") as HTMLInputElement).checked).toBe(true);
expect(document.body.querySelector("[data-testid=nacos-replace-inline-diff]")?.textContent).toContain("primary=mysql-new");
await click("nacos-replace-item-1");
expect(document.body.querySelector("[data-testid=nacos-replace-inline-diff]")?.textContent).toContain("replica=mysql-new");
expect(document.body.querySelector("[data-testid=nacos-replace-inline-diff]")?.textContent).not.toContain("primary=mysql-new");
});
it("publishes and stores history for only the selected preview results", async () => {
const configs = new Map([
["public/DEFAULT_GROUP/application.yaml", { namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", content: "primary=mysql-old", md5: "app-before" }],
["public/DEFAULT_GROUP/orders.yaml", { namespace: "public", group: "DEFAULT_GROUP", dataId: "orders.yaml", content: "replica=mysql-old", md5: "orders-before" }],
]);
api.nacosSearchConfigContent.mockResolvedValue({
matches: [
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml" },
{ namespace: "public", group: "DEFAULT_GROUP", dataId: "orders.yaml" },
],
failures: [],
truncated: false,
cancelled: false,
incomplete: false,
});
api.nacosGetConfig.mockImplementation(async (_connectionId: string, key: { namespace: string; group: string; dataId: string }) => configs.get(`${key.namespace}/${key.group}/${key.dataId}`));
api.nacosPublishConfig.mockImplementation(async (_connectionId: string, request: { namespace: string; group: string; dataId: string; content: string }) => {
const key = `${request.namespace}/${request.group}/${request.dataId}`;
configs.set(key, { ...configs.get(key)!, ...request, md5: `${request.dataId}-after` });
});
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-select-1]")).not.toBeNull());
await click("nacos-replace-select-1");
expect(document.body.querySelector("[data-testid=nacos-replace-apply]")?.textContent).toContain("1");
expect((document.body.querySelector("[data-testid=nacos-replace-select-all]") as HTMLInputElement).indeterminate).toBe(true);
await click("nacos-replace-apply");
await vi.waitFor(() => expect(api.nacosPublishConfig).toHaveBeenCalledTimes(1));
expect(api.nacosPublishConfig).toHaveBeenCalledWith("nacos-main", expect.objectContaining({ dataId: "application.yaml", content: "primary=mysql-new" }));
expect(configs.get("public/DEFAULT_GROUP/orders.yaml")?.content).toBe("replica=mysql-old");
const saved = [...history.values()][0] as NacosReplaceHistoryEntry;
expect(saved.plan.items.map((item) => item.dataId)).toEqual(["application.yaml"]);
expect(saved.plan.totalReplacements).toBe(1);
expect(saved.report.items.map((item) => item.dataId)).toEqual(["application.yaml"]);
await click("nacos-replace-history-rollback");
await click("history-confirm");
await vi.waitFor(() => expect(api.nacosPublishConfig).toHaveBeenCalledTimes(2));
expect(configs.get("public/DEFAULT_GROUP/application.yaml")?.content).toBe("primary=mysql-old");
expect(configs.get("public/DEFAULT_GROUP/orders.yaml")?.content).toBe("replica=mysql-old");
});
it("disables replacement when every preview result is deselected", async () => {
api.nacosSearchConfigContent.mockResolvedValue({
matches: [{ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml" }],
failures: [],
truncated: false,
cancelled: false,
incomplete: false,
});
api.nacosGetConfig.mockResolvedValue({ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", content: "mysql-old", md5: "before" });
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-select-all]")).not.toBeNull());
await click("nacos-replace-select-all");
expect((document.body.querySelector("[data-testid=nacos-replace-apply]") as HTMLButtonElement).disabled).toBe(true);
expect((document.body.querySelector("[data-testid=nacos-replace-select-0]") as HTMLInputElement).checked).toBe(false);
await click("nacos-replace-apply");
expect(api.nacosPublishConfig).not.toHaveBeenCalled();
await click("nacos-replace-select-all");
expect((document.body.querySelector("[data-testid=nacos-replace-select-0]") as HTMLInputElement).checked).toBe(true);
expect((document.body.querySelector("[data-testid=nacos-replace-apply]") as HTMLButtonElement).disabled).toBe(false);
await click("nacos-replace-select-all");
await click("nacos-replace-preview");
await vi.waitFor(() => expect((document.body.querySelector("[data-testid=nacos-replace-select-0]") as HTMLInputElement).checked).toBe(true));
});
it("blocks apply when the global search result is incomplete", async () => {
api.nacosSearchConfigContent.mockResolvedValue({ operationId: "replace-search", scanned: 10_000, matches: [], failures: [], truncated: true, cancelled: false, incomplete: true });
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.textContent).toContain("incomplete"));
expect(document.body.textContent).toContain("incomplete");
expect(document.body.querySelector("[data-testid=nacos-replace-apply]")).toBeNull();
});
it("invalidates the preview when a replacement condition changes", async () => {
api.nacosSearchConfigContent.mockResolvedValue({
operationId: "replace-search",
scanned: 1,
matches: [{ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", lineNumber: 1, snippet: "mysql-old" }],
failures: [],
truncated: false,
cancelled: false,
incomplete: false,
});
api.nacosGetConfig.mockResolvedValue({ namespace: "public", group: "DEFAULT_GROUP", dataId: "application.yaml", content: "host=mysql-old", md5: "before" });
await mountDialog();
input("nacos-replace-search", "mysql-old");
input("nacos-replace-value", "mysql-new");
await click("nacos-replace-preview");
await vi.waitFor(() => expect(document.body.querySelector("[data-testid=nacos-replace-apply]")).not.toBeNull());
input("nacos-replace-value", "mysql-newer");
await nextTick();
expect(document.body.querySelector("[data-testid=nacos-replace-apply]")).toBeNull();
});
});