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();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import en from "../locales/en";
import es from "../locales/es";
import itLocale from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
function entries(value: unknown, prefix = ""): Array<[string, string]> {
if (typeof value === "string") return [[prefix, value]];
if (value === null || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => entries(child, prefix ? `${prefix}.${key}` : key));
}
function placeholders(message: string): string[] {
return [...message.matchAll(/\{[^{}]+\}/g)].map(([match]) => match).sort();
}
const source = new Map(entries(zhCN.nacos).filter(([key]) => key.startsWith("contentReplace") || key.startsWith("replaceHistory.")));
const english = new Map(entries(en.nacos));
const locales = { en, es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-TW": zhTW };
describe("Nacos global replacement translations", () => {
it("declares the new source messages", () => {
expect(source.size).toBe(51);
});
it.each(Object.entries(locales))("%s has localized messages with matching placeholders", (name, locale) => {
const translated = new Map(entries(locale.nacos));
for (const [key, text] of source) {
expect(translated.get(key), key).toBeTruthy();
expect(placeholders(translated.get(key) ?? ""), key).toEqual(placeholders(text));
if (name !== "en") expect(translated.get(key), `${name}:${key} must not fall back to English`).not.toBe(english.get(key));
}
});
});
+50
View File
@@ -10318,6 +10318,56 @@ export default {
clearSearchResults: "Clear search results",
searchResultsExported: "Search results exported",
searchResultsExportedTo: "Search results exported to {path}",
contentReplace: "Global replace",
contentReplaceTitle: "Replace content across Nacos configs",
contentReplaceDescription: "Preview an exact, case-sensitive replacement across every matching Data ID before publishing it.",
contentReplaceSearch: "Find value",
contentReplaceValue: "Replace with",
contentReplaceScope: "Scope",
contentReplaceAllGroups: "All groups",
contentReplaceAllDataIds: "All Data IDs",
contentReplaceAllNamespacesWarning: "This scans and may publish every matching config in every namespace on this connection. Review the full plan before applying.",
contentReplaceScanning: "Scanning {scanned} configs, {matched} matches found",
contentReplaceIncomplete: "The global search result is incomplete, so no replacement plan was created. Narrow the filters or retry.",
contentReplaceConfigCount: "{count} configs",
contentReplaceSelectAll: "Select all configs",
contentReplaceSelectConfig: "Select {dataId}",
contentReplaceSelectedCount: "{selected} of {total} configs selected",
contentReplaceOccurrenceCount: "{count} replacements",
contentReplaceNoMatches: "No published config contains this exact value.",
contentReplaceViewDiff: "View replacement diff",
contentReplacePreview: "Preview replacements",
contentReplaceApply: "Replace in {count} selected configs",
contentReplaceRollback: "Rollback replaced configs",
contentReplaceReport: "{replaced} replaced, {conflicts} conflicts, {failed} failed",
contentReplaceRollbackReport: "Rollback: {restored} restored, {conflicts} conflicts, {failed} failed",
contentReplaceStatus: {
replaced: "Replaced",
conflict: "Conflict",
failed: "Failed",
},
replaceHistory: {
replaceTab: "Replace",
historyTab: "History",
batchCount: "{count} batches",
empty: "No replacement batches recorded yet.",
refresh: "Refresh history",
details: "View batch details",
delete: "Delete record",
deleteTitle: "Delete batch history",
deleteConfirm: "Delete this local record and its rollback snapshots? Published Nacos configs will not change. You will no longer be able to roll back this batch here.",
rollbackTitle: "Roll back this replacement batch",
rollbackConfirm: "Publish the saved original content for confirmed replacements? Configs changed since replacement will be skipped. Already restored configs will not be published again.",
storageUnavailable: "Secure local history storage is unavailable. Use HTTPS or localhost with IndexedDB, Web Crypto and Web Locks enabled. Nothing further will be published.",
storageFailed: "Could not save or read batch history. Further publishing has stopped. Check local storage space and review history before retrying; uncertain items may require checking Nacos config history.",
targetMismatch: "This connection now points to a different Nacos server, or no longer exists. Preview again; historical rollback is blocked to protect the other server.",
busy: "Another tab is executing an operation on this connection. Retry after it finishes.",
uncertain: "Uncertain",
notExecuted: "Not executed",
interrupted: "Some writes were interrupted before their results could be saved. Uncertain items are excluded from automatic rollback. Check their individual Nacos config history.",
state: { applying: "Unfinished replacement", completed: "Replacement completed", rollingBack: "Unfinished rollback", rollbackCompleted: "Rollback processed" },
rollbackStatus: { restored: "Restored", conflict: "Rollback conflict", failed: "Rollback failed" },
},
batchExport: "Batch export",
batchImport: "Batch import",
copyToNamespace: "Config sync",
+50
View File
@@ -8394,6 +8394,56 @@ export default withEnglishFallback({
clearSearchResults: "Borrar resultados",
searchResultsExported: "Resultados de búsqueda exportados",
searchResultsExportedTo: "Resultados de búsqueda exportados a {path}",
contentReplace: "Reemplazo global",
contentReplaceTitle: "Reemplazar contenido en configuraciones Nacos",
contentReplaceDescription: "Previsualice el reemplazo exacto, que distingue mayúsculas y minúsculas, en todos los Data ID coincidentes antes de publicar.",
contentReplaceSearch: "Valor a buscar",
contentReplaceValue: "Reemplazar por",
contentReplaceScope: "Ámbito",
contentReplaceAllGroups: "Todos los Group",
contentReplaceAllDataIds: "Todos los Data ID",
contentReplaceAllNamespacesWarning: "Se analizarán y podrían publicarse todas las configuraciones coincidentes de todos los espacios de nombres de esta conexión. Revise la lista completa antes de aplicar los cambios.",
contentReplaceScanning: "Configuraciones analizadas: {scanned}; coincidencias encontradas: {matched}",
contentReplaceIncomplete: "La búsqueda global está incompleta, por lo que no se creó un plan de reemplazo. Reduzca los filtros o inténtelo de nuevo.",
contentReplaceConfigCount: "{count} configuraciones",
contentReplaceSelectAll: "Seleccionar todas las configuraciones",
contentReplaceSelectConfig: "Seleccionar {dataId}",
contentReplaceSelectedCount: "{selected} de {total} configuraciones seleccionadas",
contentReplaceOccurrenceCount: "{count} reemplazos",
contentReplaceNoMatches: "Ninguna configuración publicada contiene este valor exacto.",
contentReplaceViewDiff: "Ver diferencias del reemplazo",
contentReplacePreview: "Previsualizar reemplazos",
contentReplaceApply: "Reemplazar en {count} configuraciones seleccionadas",
contentReplaceRollback: "Revertir configuraciones reemplazadas",
contentReplaceReport: "Reemplazadas: {replaced}; conflictos: {conflicts}; fallidas: {failed}",
contentReplaceRollbackReport: "Reversión: {restored} restauradas; {conflicts} conflictos; {failed} fallidas",
contentReplaceStatus: {
replaced: "Reemplazada",
conflict: "Conflicto",
failed: "Fallida",
},
replaceHistory: {
replaceTab: "Reemplazar",
historyTab: "Historial",
batchCount: "{count} lotes",
empty: "Todavía no hay lotes de reemplazo registrados.",
refresh: "Actualizar historial",
details: "Ver detalles del lote",
delete: "Eliminar registro",
deleteTitle: "Eliminar historial del lote",
deleteConfirm: "¿Eliminar este registro local y sus copias para reversión? Las configuraciones publicadas en Nacos no cambiarán, pero ya no podrá revertir este lote desde aquí.",
rollbackTitle: "Revertir este lote de reemplazo",
rollbackConfirm: "¿Publicar el contenido original guardado de los reemplazos confirmados? Se omitirán las configuraciones modificadas después del reemplazo y no se volverán a publicar las ya restauradas.",
storageUnavailable: "No se puede usar el almacenamiento local seguro del historial. Use HTTPS o localhost con IndexedDB, Web Crypto y Web Locks habilitados. No se publicará nada más.",
storageFailed: "No se pudo guardar o leer el historial del lote. Se detuvo la publicación. Compruebe el espacio de almacenamiento local y revise el historial antes de reintentar; los elementos con estado incierto pueden requerir una revisión del historial de Nacos.",
targetMismatch: "Esta conexión apunta a otro servidor Nacos o ya no existe. Previsualice de nuevo; se bloqueó la reversión histórica para proteger el otro servidor.",
busy: "Otra pestaña está operando en esta conexión. Inténtelo de nuevo cuando termine.",
uncertain: "Estado incierto",
notExecuted: "No ejecutado",
interrupted: "Algunas escrituras se interrumpieron antes de guardar sus resultados. Los elementos con estado incierto no se revertirán automáticamente. Revise su historial individual en Nacos.",
state: { applying: "Reemplazo incompleto", completed: "Reemplazo completado", rollingBack: "Reversión incompleta", rollbackCompleted: "Reversión procesada" },
rollbackStatus: { restored: "Restaurada", conflict: "Conflicto de reversión", failed: "Reversión fallida" },
},
batchExport: "Exportación por lotes",
batchImport: "Importación por lotes",
copyToNamespace: "Sincronizar configuraciones",
+50
View File
@@ -8394,6 +8394,56 @@ export default withEnglishFallback({
clearSearchResults: "Cancella risultati",
searchResultsExported: "Risultati della ricerca esportati",
searchResultsExportedTo: "Risultati della ricerca esportati in {path}",
contentReplace: "Sostituzione globale",
contentReplaceTitle: "Sostituisci il contenuto delle configurazioni Nacos",
contentReplaceDescription: "Visualizza in anteprima una sostituzione esatta, che distingue maiuscole e minuscole, in tutti i Data ID corrispondenti prima della pubblicazione.",
contentReplaceSearch: "Valore da cercare",
contentReplaceValue: "Sostituisci con",
contentReplaceScope: "Ambito",
contentReplaceAllGroups: "Tutti i Group",
contentReplaceAllDataIds: "Tutti i Data ID",
contentReplaceAllNamespacesWarning: "Verranno analizzate e potrebbero essere pubblicate tutte le configurazioni corrispondenti in ogni namespace di questa connessione. Controlla l'elenco completo prima di applicare le modifiche.",
contentReplaceScanning: "Configurazioni analizzate: {scanned}; corrispondenze trovate: {matched}",
contentReplaceIncomplete: "La ricerca globale è incompleta, quindi non è stato creato un piano di sostituzione. Restringi i filtri o riprova.",
contentReplaceConfigCount: "{count} configurazioni",
contentReplaceSelectAll: "Seleziona tutte le configurazioni",
contentReplaceSelectConfig: "Seleziona {dataId}",
contentReplaceSelectedCount: "{selected} configurazioni su {total} selezionate",
contentReplaceOccurrenceCount: "{count} sostituzioni",
contentReplaceNoMatches: "Nessuna configurazione pubblicata contiene questo valore esatto.",
contentReplaceViewDiff: "Visualizza le differenze della sostituzione",
contentReplacePreview: "Anteprima delle sostituzioni",
contentReplaceApply: "Sostituisci in {count} configurazioni selezionate",
contentReplaceRollback: "Ripristina le configurazioni sostituite",
contentReplaceReport: "Sostituite: {replaced}; conflitti: {conflicts}; non riuscite: {failed}",
contentReplaceRollbackReport: "Ripristino: {restored} ripristinate; {conflicts} conflitti; {failed} non riuscite",
contentReplaceStatus: {
replaced: "Sostituita",
conflict: "Conflitto",
failed: "Non riuscita",
},
replaceHistory: {
replaceTab: "Sostituzione",
historyTab: "Cronologia",
batchCount: "{count} batch",
empty: "Nessun batch di sostituzione registrato.",
refresh: "Aggiorna cronologia",
details: "Visualizza dettagli del batch",
delete: "Elimina record",
deleteTitle: "Elimina cronologia del batch",
deleteConfirm: "Eliminare questo record locale e le relative istantanee per il ripristino? Le configurazioni Nacos pubblicate non verranno modificate, ma non sarà più possibile ripristinare questo batch da qui.",
rollbackTitle: "Ripristina questo batch di sostituzione",
rollbackConfirm: "Pubblicare il contenuto originale salvato per le sostituzioni confermate? Le configurazioni modificate dopo la sostituzione saranno ignorate e quelle già ripristinate non verranno pubblicate di nuovo.",
storageUnavailable: "Archiviazione locale sicura della cronologia non disponibile. Usa HTTPS o localhost con IndexedDB, Web Crypto e Web Locks abilitati. Non verrà pubblicato altro.",
storageFailed: "Impossibile salvare o leggere la cronologia del batch. Le pubblicazioni successive sono state interrotte. Controlla lo spazio locale e consulta la cronologia prima di riprovare; per gli elementi con stato incerto verifica la cronologia delle singole configurazioni Nacos.",
targetMismatch: "Questa connessione punta a un altro server Nacos o non esiste più. Ripeti l'anteprima; il ripristino dalla cronologia è bloccato per proteggere l'altro server.",
busy: "Un'altra scheda sta eseguendo un'operazione su questa connessione. Riprova al termine.",
uncertain: "Stato incerto",
notExecuted: "Non eseguita",
interrupted: "Alcune scritture si sono interrotte prima che i risultati fossero salvati. Gli elementi con stato incerto sono esclusi dal ripristino automatico. Controlla la cronologia delle singole configurazioni Nacos.",
state: { applying: "Sostituzione incompleta", completed: "Sostituzione completata", rollingBack: "Ripristino incompleto", rollbackCompleted: "Ripristino elaborato" },
rollbackStatus: { restored: "Ripristinata", conflict: "Conflitto di ripristino", failed: "Ripristino non riuscito" },
},
batchExport: "Esportazione in blocco",
batchImport: "Importazione in blocco",
copyToNamespace: "Sincronizza configurazioni",
+50
View File
@@ -8432,6 +8432,56 @@ export default withEnglishFallback({
clearSearchResults: "検索結果をクリア",
searchResultsExported: "検索結果をエクスポートしました",
searchResultsExportedTo: "検索結果を {path} にエクスポートしました",
contentReplace: "全体置換",
contentReplaceTitle: "Nacos 設定内容を一括置換",
contentReplaceDescription: "公開前に、一致するすべての Data ID に対する大文字と小文字を区別する完全一致の置換をプレビューします。",
contentReplaceSearch: "検索する値",
contentReplaceValue: "置換後の値",
contentReplaceScope: "対象範囲",
contentReplaceAllGroups: "すべての Group",
contentReplaceAllDataIds: "すべての Data ID",
contentReplaceAllNamespacesWarning: "この接続のすべての名前空間で一致する設定を走査し、公開する可能性があります。適用前に置換対象の一覧を確認してください。",
contentReplaceScanning: "{scanned} 件の設定を走査済み、{matched} 件の一致を検出",
contentReplaceIncomplete: "全体検索が完了していないため、置換計画を作成できませんでした。絞り込み条件を狭めるか、再試行してください。",
contentReplaceConfigCount: "{count} 件の設定",
contentReplaceSelectAll: "すべての設定を選択",
contentReplaceSelectConfig: "{dataId} を選択",
contentReplaceSelectedCount: "{total} 件中 {selected} 件の設定を選択",
contentReplaceOccurrenceCount: "{count} 箇所の置換",
contentReplaceNoMatches: "公開済みの設定に、この値と完全に一致する内容はありません。",
contentReplaceViewDiff: "置換差分を表示",
contentReplacePreview: "置換をプレビュー",
contentReplaceApply: "選択した {count} 件の設定を置換",
contentReplaceRollback: "置換済み設定を元に戻す",
contentReplaceReport: "置換成功: {replaced} 件、競合: {conflicts} 件、失敗: {failed} 件",
contentReplaceRollbackReport: "復元結果: 復元 {restored} 件、競合 {conflicts} 件、失敗 {failed} 件",
contentReplaceStatus: {
replaced: "置換済み",
conflict: "競合",
failed: "失敗",
},
replaceHistory: {
replaceTab: "置換",
historyTab: "履歴",
batchCount: "{count} 件のバッチ",
empty: "置換バッチの履歴はありません。",
refresh: "履歴を更新",
details: "バッチの詳細を表示",
delete: "記録を削除",
deleteTitle: "バッチ履歴を削除",
deleteConfirm: "この端末内の記録と復元用スナップショットを削除しますか?公開済みの Nacos 設定は変更されませんが、このバッチをここから元に戻すことはできなくなります。",
rollbackTitle: "この置換バッチを元に戻す",
rollbackConfirm: "置換が確認された設定について、保存した元の内容を公開しますか?置換後に変更された設定はスキップされ、復元済みの設定は再公開されません。",
storageUnavailable: "安全なローカル履歴ストレージを使用できません。HTTPS または localhost を使用し、IndexedDB、Web Crypto、Web Locks を有効にしてください。これ以上公開しません。",
storageFailed: "バッチ履歴の保存または読み込みに失敗したため、以降の公開を停止しました。ローカルの空き容量を確認し、再試行前に履歴を確認してください。状態が不明な項目は Nacos の個別設定履歴で確認してください。",
targetMismatch: "この接続は別の Nacos サーバーを指しているか、存在しません。再度プレビューしてください。別のサーバーを保護するため、履歴からの復元を禁止しました。",
busy: "別のタブがこの接続を操作中です。完了後に再試行してください。",
uncertain: "状態不明",
notExecuted: "未実行",
interrupted: "一部の書き込みは結果の保存前に中断されました。状態が不明な項目は自動復元されません。Nacos の個別設定履歴で確認してください。",
state: { applying: "置換未完了", completed: "置換完了", rollingBack: "復元未完了", rollbackCompleted: "復元処理済み" },
rollbackStatus: { restored: "復元済み", conflict: "復元時の競合", failed: "復元失敗" },
},
batchExport: "一括エクスポート",
batchImport: "一括インポート",
copyToNamespace: "設定同期",
+50
View File
@@ -9253,6 +9253,56 @@ export default withEnglishFallback({
clearSearchResults: "검색 결과 지우기",
searchResultsExported: "검색 결과를 내보냈습니다",
searchResultsExportedTo: "검색 결과를 {path}로 내보냈습니다",
contentReplace: "전체 바꾸기",
contentReplaceTitle: "Nacos 구성 내용 일괄 바꾸기",
contentReplaceDescription: "게시 전에 일치하는 모든 Data ID에서 대소문자를 구분하는 정확한 값 바꾸기를 미리 확인합니다.",
contentReplaceSearch: "찾을 값",
contentReplaceValue: "바꿀 값",
contentReplaceScope: "범위",
contentReplaceAllGroups: "모든 Group",
contentReplaceAllDataIds: "모든 Data ID",
contentReplaceAllNamespacesWarning: "이 연결의 모든 네임스페이스에서 일치하는 구성을 검색하고 게시할 수 있습니다. 적용하기 전에 전체 바꾸기 목록을 확인하세요.",
contentReplaceScanning: "구성 {scanned}개 검색, 일치 항목 {matched}개 발견",
contentReplaceIncomplete: "전체 검색이 완료되지 않아 바꾸기 계획을 만들지 않았습니다. 필터 범위를 좁히거나 다시 시도하세요.",
contentReplaceConfigCount: "구성 {count}개",
contentReplaceSelectAll: "모든 구성 선택",
contentReplaceSelectConfig: "{dataId} 선택",
contentReplaceSelectedCount: "구성 {total}개 중 {selected}개 선택",
contentReplaceOccurrenceCount: "바꾸기 {count}건",
contentReplaceNoMatches: "게시된 구성에서 이 값과 정확히 일치하는 내용을 찾지 못했습니다.",
contentReplaceViewDiff: "바꾸기 차이 보기",
contentReplacePreview: "바꾸기 미리 보기",
contentReplaceApply: "선택한 구성 {count}개 바꾸기",
contentReplaceRollback: "바꾼 구성 되돌리기",
contentReplaceReport: "바꾸기 성공 {replaced}개, 충돌 {conflicts}개, 실패 {failed}개",
contentReplaceRollbackReport: "되돌리기 결과: 복원 {restored}개, 충돌 {conflicts}개, 실패 {failed}개",
contentReplaceStatus: {
replaced: "바뀜",
conflict: "충돌",
failed: "실패",
},
replaceHistory: {
replaceTab: "바꾸기",
historyTab: "기록",
batchCount: "배치 {count}개",
empty: "저장된 바꾸기 배치 기록이 없습니다.",
refresh: "기록 새로 고침",
details: "배치 상세 정보 보기",
delete: "기록 삭제",
deleteTitle: "배치 기록 삭제",
deleteConfirm: "이 로컬 기록과 되돌리기 스냅샷을 삭제할까요? 게시된 Nacos 구성은 변경되지 않지만 이 배치를 여기에서 다시 되돌릴 수 없게 됩니다.",
rollbackTitle: "이 바꾸기 배치 되돌리기",
rollbackConfirm: "확인된 바꾸기 항목의 저장된 원본 내용을 게시할까요? 바꾼 뒤 수정된 구성은 건너뛰고 이미 복원된 구성은 다시 게시하지 않습니다.",
storageUnavailable: "안전한 로컬 기록 저장소를 사용할 수 없습니다. HTTPS 또는 localhost를 사용하고 IndexedDB, Web Crypto, Web Locks를 활성화하세요. 더 이상 게시하지 않습니다.",
storageFailed: "배치 기록을 저장하거나 읽지 못해 게시를 중단했습니다. 로컬 저장 공간을 확인하고 다시 시도하기 전에 기록을 살펴보세요. 상태가 불확실한 항목은 Nacos 개별 구성 기록에서 확인해야 합니다.",
targetMismatch: "이 연결이 다른 Nacos 서버를 가리키거나 더 이상 존재하지 않습니다. 다시 미리 확인하세요. 다른 서버를 보호하기 위해 기록에서 되돌리기를 차단했습니다.",
busy: "다른 탭에서 이 연결을 작업 중입니다. 완료된 후 다시 시도하세요.",
uncertain: "상태 불확실",
notExecuted: "실행되지 않음",
interrupted: "일부 쓰기 작업은 결과 저장 전에 중단되었습니다. 상태가 불확실한 항목은 자동으로 되돌리지 않습니다. Nacos의 개별 구성 기록을 확인하세요.",
state: { applying: "바꾸기 미완료", completed: "바꾸기 완료", rollingBack: "되돌리기 미완료", rollbackCompleted: "되돌리기 처리 완료" },
rollbackStatus: { restored: "복원됨", conflict: "되돌리기 충돌", failed: "되돌리기 실패" },
},
batchExport: "일괄 내보내기",
batchImport: "일괄 가져오기",
copyToNamespace: "구성 동기화",
+50
View File
@@ -8395,6 +8395,56 @@ export default withEnglishFallback({
clearSearchResults: "Limpar resultados",
searchResultsExported: "Resultados da pesquisa exportados",
searchResultsExportedTo: "Resultados da pesquisa exportados para {path}",
contentReplace: "Substituição global",
contentReplaceTitle: "Substituir conteúdo nas configurações Nacos",
contentReplaceDescription: "Visualize a substituição exata, que diferencia maiúsculas de minúsculas, em todos os Data IDs correspondentes antes de publicar.",
contentReplaceSearch: "Valor a buscar",
contentReplaceValue: "Substituir por",
contentReplaceScope: "Escopo",
contentReplaceAllGroups: "Todos os Group",
contentReplaceAllDataIds: "Todos os Data IDs",
contentReplaceAllNamespacesWarning: "Todas as configurações correspondentes em todos os namespaces desta conexão serão verificadas e poderão ser publicadas. Revise a lista completa antes de aplicar.",
contentReplaceScanning: "Configurações verificadas: {scanned}; correspondências encontradas: {matched}",
contentReplaceIncomplete: "A busca global está incompleta, portanto nenhum plano de substituição foi criado. Restrinja os filtros ou tente novamente.",
contentReplaceConfigCount: "{count} configurações",
contentReplaceSelectAll: "Selecionar todas as configurações",
contentReplaceSelectConfig: "Selecionar {dataId}",
contentReplaceSelectedCount: "{selected} de {total} configurações selecionadas",
contentReplaceOccurrenceCount: "{count} substituições",
contentReplaceNoMatches: "Nenhuma configuração publicada contém exatamente este valor.",
contentReplaceViewDiff: "Ver diferenças da substituição",
contentReplacePreview: "Visualizar substituições",
contentReplaceApply: "Substituir em {count} configurações selecionadas",
contentReplaceRollback: "Reverter configurações substituídas",
contentReplaceReport: "Substituídas: {replaced}; conflitos: {conflicts}; falhas: {failed}",
contentReplaceRollbackReport: "Reversão: {restored} restauradas; {conflicts} conflitos; {failed} falhas",
contentReplaceStatus: {
replaced: "Substituída",
conflict: "Conflito",
failed: "Falha",
},
replaceHistory: {
replaceTab: "Substituir",
historyTab: "Histórico",
batchCount: "{count} lotes",
empty: "Nenhum lote de substituição registrado.",
refresh: "Atualizar histórico",
details: "Ver detalhes do lote",
delete: "Excluir registro",
deleteTitle: "Excluir histórico do lote",
deleteConfirm: "Excluir este registro local e os snapshots de reversão? As configurações Nacos publicadas não serão alteradas, mas não será mais possível reverter este lote aqui.",
rollbackTitle: "Reverter este lote de substituição",
rollbackConfirm: "Publicar o conteúdo original salvo das substituições confirmadas? Configurações modificadas após a substituição serão ignoradas, e as já restauradas não serão publicadas novamente.",
storageUnavailable: "O armazenamento local seguro do histórico não está disponível. Use HTTPS ou localhost com IndexedDB, Web Crypto e Web Locks habilitados. Nenhuma outra configuração será publicada.",
storageFailed: "Não foi possível salvar ou ler o histórico do lote. As próximas publicações foram interrompidas. Verifique o espaço de armazenamento local e consulte o histórico antes de tentar novamente; itens com estado incerto podem exigir a consulta do histórico individual no Nacos.",
targetMismatch: "Esta conexão aponta para outro servidor Nacos ou não existe mais. Visualize novamente; a reversão pelo histórico foi bloqueada para proteger o outro servidor.",
busy: "Outra aba está operando nesta conexão. Tente novamente após a conclusão.",
uncertain: "Estado incerto",
notExecuted: "Não executada",
interrupted: "Algumas gravações foram interrompidas antes de salvar os resultados. Itens com estado incerto não serão revertidos automaticamente. Consulte o histórico individual das configurações no Nacos.",
state: { applying: "Substituição incompleta", completed: "Substituição concluída", rollingBack: "Reversão incompleta", rollbackCompleted: "Reversão processada" },
rollbackStatus: { restored: "Restaurada", conflict: "Conflito na reversão", failed: "Falha na reversão" },
},
batchExport: "Exportação em lote",
batchImport: "Importação em lote",
copyToNamespace: "Sincronizar configurações",
+50
View File
@@ -10285,6 +10285,56 @@ export default withEnglishFallback({
clearSearchResults: "清除搜索结果",
searchResultsExported: "搜索结果已导出",
searchResultsExportedTo: "搜索结果已导出到 {path}",
contentReplace: "全局替换",
contentReplaceTitle: "批量替换 Nacos 配置内容",
contentReplaceDescription: "先预览所有 Data ID 中大小写敏感的精确替换,确认后再批量发布。",
contentReplaceSearch: "查找值",
contentReplaceValue: "替换为",
contentReplaceScope: "范围",
contentReplaceAllGroups: "全部 Group",
contentReplaceAllDataIds: "全部 Data ID",
contentReplaceAllNamespacesWarning: "将扫描并可能发布此连接全部命名空间内的所有匹配配置。执行前请检查完整替换清单。",
contentReplaceScanning: "已扫描 {scanned} 个配置,发现 {matched} 个匹配项",
contentReplaceIncomplete: "全局搜索结果不完整,因此未生成替换计划。请缩小筛选范围或重试。",
contentReplaceConfigCount: "{count} 个配置",
contentReplaceSelectAll: "选择全部配置",
contentReplaceSelectConfig: "选择 {dataId}",
contentReplaceSelectedCount: "已选择 {selected} / {total} 个配置",
contentReplaceOccurrenceCount: "{count} 处替换",
contentReplaceNoMatches: "已发布配置中没有找到这个精确值。",
contentReplaceViewDiff: "查看替换差异",
contentReplacePreview: "预览替换",
contentReplaceApply: "替换选中的 {count} 个配置",
contentReplaceRollback: "回滚已替换配置",
contentReplaceReport: "成功替换 {replaced} 个,冲突 {conflicts} 个,失败 {failed} 个",
contentReplaceRollbackReport: "回滚结果:已恢复 {restored} 个,冲突 {conflicts} 个,失败 {failed} 个",
contentReplaceStatus: {
replaced: "已替换",
conflict: "冲突",
failed: "失败",
},
replaceHistory: {
replaceTab: "替换",
historyTab: "历史",
batchCount: "{count} 个批次",
empty: "暂无替换批次记录。",
refresh: "刷新历史",
details: "查看批次详情",
delete: "删除记录",
deleteTitle: "删除替换历史",
deleteConfirm: "确定删除本机记录和回滚快照?不会修改已发布的 Nacos 配置,但以后无法在这里回滚这个批次。",
rollbackTitle: "回滚此替换批次",
rollbackConfirm: "确定通过发布接口恢复已确认替换的原始内容?替换后被修改的配置会跳过,已恢复的配置不会再次发布。",
storageUnavailable: "无法使用安全的本地历史存储。请使用 HTTPS 或 localhost,并启用 IndexedDB、Web Crypto 和 Web Locks。不会继续发布。",
storageFailed: "批次历史保存或读取失败,已停止继续发布。请检查本地存储空间,重试前先查看历史;状态不确定的配置需核对 Nacos 单项历史。",
targetMismatch: "此连接已指向不同的 Nacos 服务,或连接已不存在。请重新预览;为保护其他服务,历史回滚已被阻止。",
busy: "另一个页面正在操作此连接,请等待操作完成后重试。",
uncertain: "状态不确定",
notExecuted: "未执行",
interrupted: "部分写入在保存结果前中断。状态不确定的配置不会自动回滚,请核对其 Nacos 单项配置历史。",
state: { applying: "替换未完成", completed: "替换完成", rollingBack: "回滚未完成", rollbackCompleted: "回滚已处理" },
rollbackStatus: { restored: "已恢复", conflict: "回滚冲突", failed: "回滚失败" },
},
batchExport: "批量导出",
batchImport: "批量导入",
copyToNamespace: "配置同步",
+50
View File
@@ -7702,6 +7702,56 @@ export default withEnglishFallback({
clearSearchResults: "清除搜尋結果",
searchResultsExported: "搜尋結果已匯出",
searchResultsExportedTo: "搜尋結果已匯出到 {path}",
contentReplace: "全域取代",
contentReplaceTitle: "批次取代 Nacos 設定內容",
contentReplaceDescription: "先預覽所有符合條件的 Data ID 中區分大小寫的精確取代,確認後再批次發布。",
contentReplaceSearch: "尋找值",
contentReplaceValue: "取代為",
contentReplaceScope: "範圍",
contentReplaceAllGroups: "所有 Group",
contentReplaceAllDataIds: "所有 Data ID",
contentReplaceAllNamespacesWarning: "將掃描並可能發布此連線所有命名空間內的相符設定。套用前請檢查完整取代清單。",
contentReplaceScanning: "已掃描 {scanned} 個設定,找到 {matched} 個相符項目",
contentReplaceIncomplete: "全域搜尋結果不完整,因此未建立取代計畫。請縮小篩選範圍或重試。",
contentReplaceConfigCount: "{count} 個設定",
contentReplaceSelectAll: "選取所有設定",
contentReplaceSelectConfig: "選取 {dataId}",
contentReplaceSelectedCount: "已選取 {selected} / {total} 個設定",
contentReplaceOccurrenceCount: "{count} 處取代",
contentReplaceNoMatches: "已發布的設定中找不到這個精確值。",
contentReplaceViewDiff: "檢視取代差異",
contentReplacePreview: "預覽取代",
contentReplaceApply: "取代已選取的 {count} 個設定",
contentReplaceRollback: "回復已取代的設定",
contentReplaceReport: "成功取代 {replaced} 個,衝突 {conflicts} 個,失敗 {failed} 個",
contentReplaceRollbackReport: "回復結果:已還原 {restored} 個,衝突 {conflicts} 個,失敗 {failed} 個",
contentReplaceStatus: {
replaced: "已取代",
conflict: "衝突",
failed: "失敗",
},
replaceHistory: {
replaceTab: "取代",
historyTab: "歷史記錄",
batchCount: "{count} 個批次",
empty: "尚無取代批次記錄。",
refresh: "重新整理歷史記錄",
details: "檢視批次詳細資料",
delete: "刪除記錄",
deleteTitle: "刪除批次歷史記錄",
deleteConfirm: "確定刪除此裝置的記錄和回復快照嗎?已發布的 Nacos 設定不會變更,但之後無法從這裡回復此批次。",
rollbackTitle: "回復此取代批次",
rollbackConfirm: "確定透過發布介面還原已確認取代的原始內容嗎?取代後曾修改的設定將略過,已還原的設定不會再次發布。",
storageUnavailable: "無法使用安全的本機歷史記錄儲存空間。請使用 HTTPS 或 localhost,並啟用 IndexedDB、Web Crypto 和 Web Locks。不會繼續發布。",
storageFailed: "批次歷史記錄儲存或讀取失敗,已停止繼續發布。請檢查本機儲存空間,重試前先檢視歷史記錄;狀態不明的設定需要核對 Nacos 單項設定歷史。",
targetMismatch: "此連線已指向不同的 Nacos 伺服器,或連線已不存在。請重新預覽;為保護其他伺服器,已禁止從歷史記錄回復。",
busy: "另一個分頁正在操作此連線,請等待操作完成後重試。",
uncertain: "狀態不明",
notExecuted: "未執行",
interrupted: "部分寫入在儲存結果前中斷。狀態不明的設定不會自動回復,請核對其 Nacos 單項設定歷史。",
state: { applying: "取代未完成", completed: "取代完成", rollingBack: "回復未完成", rollbackCompleted: "回復已處理" },
rollbackStatus: { restored: "已還原", conflict: "回復衝突", failed: "回復失敗" },
},
batchExport: "批次匯出",
batchImport: "批次匯入",
copyToNamespace: "設定同步",
@@ -0,0 +1,194 @@
import { describe, expect, it, vi } from "vitest";
import type { NacosConfigItem, NacosConfigUpsert } from "@/types/nacos";
import { applyNacosContentReplacePlan, buildNacosContentReplacePlan, rollbackNacosContentReplace } from "../nacosContentReplace";
function config(overrides: Partial<NacosConfigItem> = {}): NacosConfigItem {
return {
namespace: "public",
group: "DEFAULT_GROUP",
dataId: "application.yaml",
content: "url: mysql-old:3306\nreplica: mysql-old:3306",
configType: "yaml",
md5: "before-md5",
...overrides,
};
}
describe("buildNacosContentReplacePlan", () => {
it("builds one global plan across namespaces, groups, and data IDs", () => {
const plan = buildNacosContentReplacePlan(
[config(), config({ namespace: "tenant-a", group: "orders", dataId: "orders.yaml", content: "dsn=mysql-old:3306/orders", md5: "orders-md5" }), config({ namespace: "tenant-a", group: "billing", dataId: "billing.properties", content: "host=mysql-new", md5: "billing-md5" })],
"mysql-old:3306",
"mysql-new:3306",
);
expect(plan.items).toHaveLength(2);
expect(plan.totalReplacements).toBe(3);
expect(plan.items.map((item) => `${item.namespace}/${item.group}/${item.dataId}`)).toEqual(["public/DEFAULT_GROUP/application.yaml", "tenant-a/orders/orders.yaml"]);
expect(plan.items[0].afterContent).toBe("url: mysql-new:3306\nreplica: mysql-new:3306");
});
it("deduplicates search matches for the same configuration", () => {
const item = config();
const plan = buildNacosContentReplacePlan([item, { ...item }], "mysql-old", "mysql-new");
expect(plan.items).toHaveLength(1);
expect(plan.totalReplacements).toBe(2);
});
it("rejects an empty search value and a no-op replacement", () => {
expect(() => buildNacosContentReplacePlan([config()], "", "mysql-new")).toThrow(/required/i);
expect(() => buildNacosContentReplacePlan([config()], "same", "same")).toThrow(/different/i);
});
});
describe("applyNacosContentReplacePlan", () => {
it("awaits durable checkpoints and stops further publishing if a checkpoint fails", async () => {
const plan = buildNacosContentReplacePlan([config(), config({ dataId: "other.yaml" })], "mysql-old", "mysql-new");
const publish = vi.fn();
const onBeforeItem = vi.fn().mockResolvedValue(undefined);
const onItemResult = vi.fn().mockRejectedValue(new Error("history write failed"));
let reads = 0;
await expect(
applyNacosContentReplacePlan(plan, {
getConfig: async () => config({ content: ++reads === 1 ? plan.items[0].beforeContent : plan.items[0].afterContent }),
publishConfig: publish,
onBeforeItem,
onItemResult,
}),
).rejects.toThrow("history write failed");
expect(onBeforeItem).toHaveBeenCalledTimes(1);
expect(onItemResult).toHaveBeenCalledWith(expect.objectContaining({ status: "replaced" }));
expect(publish).toHaveBeenCalledTimes(1);
});
it("does not publish when the pre-item checkpoint cannot be saved", async () => {
const publish = vi.fn();
await expect(
applyNacosContentReplacePlan(buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new"), {
getConfig: async () => config(),
publishConfig: publish,
onBeforeItem: async () => {
throw new Error("storage unavailable");
},
}),
).rejects.toThrow("storage unavailable");
expect(publish).not.toHaveBeenCalled();
});
it("publishes every current item with CAS metadata and verifies the result", async () => {
const plan = buildNacosContentReplacePlan([config(), config({ namespace: "tenant-a", dataId: "orders.yaml", content: "mysql-old", md5: "orders-before" })], "mysql-old", "mysql-new");
const current = new Map(plan.items.map((item) => [item.key, config({ namespace: item.namespace, group: item.group, dataId: item.dataId, content: item.beforeContent, md5: item.expectedMd5 })]));
const publish = vi.fn(async (request: NacosConfigUpsert) => {
const key = `${request.namespace || ""}\u0000${request.group}\u0000${request.dataId}`;
current.set(key, config({ ...request, md5: `after-${request.dataId}` }));
});
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async (key) => current.get(`${key.namespace || ""}\u0000${key.group}\u0000${key.dataId}`)!,
publishConfig: publish,
});
expect(report.replaced).toBe(2);
expect(report.conflicts).toBe(0);
expect(report.failed).toBe(0);
expect(publish).toHaveBeenCalledTimes(2);
expect(publish.mock.calls[0][0]).toMatchObject({ content: "url: mysql-new:3306\nreplica: mysql-new:3306", casMd5: "before-md5" });
expect(report.items.every((item) => item.status === "replaced" && item.appliedMd5)).toBe(true);
});
it("skips a configuration changed after preview", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const publish = vi.fn();
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => config({ content: "changed elsewhere", md5: "other-md5" }),
publishConfig: publish,
});
expect(report.conflicts).toBe(1);
expect(report.replaced).toBe(0);
expect(publish).not.toHaveBeenCalled();
});
it("reports a failed verification when the server does not retain the replacement", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
let reads = 0;
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => {
reads += 1;
return reads === 1 ? config() : config({ md5: "after-md5" });
},
publishConfig: vi.fn().mockResolvedValue(undefined),
});
expect(report.failed).toBe(1);
expect(report.items[0].message).toMatch(/verification/i);
});
it("reports a CAS rejection as a conflict instead of a generic failure", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const report = await applyNacosContentReplacePlan(plan, {
getConfig: async () => config(),
publishConfig: vi.fn().mockRejectedValue(new Error("Nacos publish rejected by CAS validation")),
});
expect(report.conflicts).toBe(1);
expect(report.failed).toBe(0);
expect(report.items[0].status).toBe("conflict");
});
});
describe("rollbackNacosContentReplace", () => {
it("restores successfully replaced configurations with CAS protection", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const applied = config({ content: plan.items[0].afterContent, md5: "after-md5" });
const publish = vi.fn().mockResolvedValue(undefined);
let reads = 0;
const report = await rollbackNacosContentReplace(
{
...plan,
items: [{ ...plan.items[0], status: "replaced", appliedMd5: "after-md5" }],
replaced: 1,
conflicts: 0,
failed: 0,
cancelled: false,
},
{
getConfig: async () => {
reads += 1;
return reads === 1 ? applied : config();
},
publishConfig: publish,
},
);
expect(report.restored).toBe(1);
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ content: config().content, casMd5: "after-md5" }));
});
it("does not overwrite a configuration changed after replacement", async () => {
const plan = buildNacosContentReplacePlan([config()], "mysql-old", "mysql-new");
const publish = vi.fn();
const report = await rollbackNacosContentReplace(
{
...plan,
items: [{ ...plan.items[0], status: "replaced", appliedMd5: "after-md5" }],
replaced: 1,
conflicts: 0,
failed: 0,
cancelled: false,
},
{
getConfig: async () => config({ content: "newer change", md5: "newer-md5" }),
publishConfig: publish,
},
);
expect(report.conflicts).toBe(1);
expect(publish).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildNacosContentReplacePlan } from "../nacosContentReplace";
import { applyWithNacosHistory, rollbackFromNacosHistory, nacosHistoryTarget, withNacosHistoryLock } from "../nacosReplaceHistory";
import type { NacosReplaceHistoryEntry } from "../nacosReplaceHistory";
import type { NacosConfigItem, NacosConfigUpsert } from "@/types/nacos";
const storage = vi.hoisted(() => ({ saveNacosReplaceHistory: vi.fn(), getNacosReplaceHistory: vi.fn() }));
vi.mock("../nacosReplaceHistoryStorage", () => storage);
const saved = new Map<string, NacosReplaceHistoryEntry>();
const original: NacosConfigItem = { namespace: "public", group: "test", dataId: "one", content: "mysql-old", md5: "before" };
const target = "test-endpoint";
let current: NacosConfigItem;
const backend = {
getConfig: vi.fn(async () => ({ ...current })),
publishConfig: vi.fn(async (req: NacosConfigUpsert) => {
current = { ...current, ...req, md5: req.content === original.content ? "before" : "after" };
}),
};
beforeEach(() => {
vi.clearAllMocks();
saved.clear();
current = { ...original };
storage.saveNacosReplaceHistory.mockImplementation(async (entry: NacosReplaceHistoryEntry) => {
saved.set(entry.id, structuredClone(entry));
});
storage.getNacosReplaceHistory.mockImplementation(async (id: string) => structuredClone(saved.get(id)));
});
function apply() {
return applyWithNacosHistory("main", target, { scope: "allNamespaces", namespace: "public", group: "", dataId: "" }, buildNacosContentReplacePlan([original], "mysql-old", "mysql-new"), backend);
}
describe("durable Nacos batch journal", () => {
it("saves snapshots before publishing and rolls back a reloaded historical batch", async () => {
backend.publishConfig.mockImplementationOnce(async (req) => {
const persisted = [...saved.values()][0];
expect(persisted.plan.items[0].beforeContent).toBe("mysql-old");
expect(persisted.inFlight).toEqual({ key: persisted.plan.items[0].key, phase: "apply" });
current = { ...current, ...req, md5: "after" };
});
const entry = await apply();
expect(saved.get(entry.id)?.state).toBe("completed");
const restored = await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(restored.rollback?.restored).toBe(1);
expect(current.content).toBe("mysql-old");
await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(backend.publishConfig).toHaveBeenCalledTimes(2);
});
it("does not publish when initial backup storage fails", async () => {
storage.saveNacosReplaceHistory.mockRejectedValueOnce(new Error("quota"));
await expect(apply()).rejects.toThrow("quota");
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("preserves an uncertain write and never treats it as a confirmed rollback candidate", async () => {
storage.saveNacosReplaceHistory
.mockImplementationOnce(async (entry) => {
saved.set(entry.id, structuredClone(entry));
})
.mockImplementationOnce(async (entry) => {
saved.set(entry.id, structuredClone(entry));
})
.mockRejectedValueOnce(new Error("quota"));
await expect(apply()).rejects.toThrow("quota");
const entry = [...saved.values()][0];
expect(entry.inFlight?.phase).toBe("apply");
await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(backend.publishConfig).toHaveBeenCalledTimes(1);
});
it("rejects another connection or changed target before any rollback reads/writes", async () => {
const entry = await apply();
backend.getConfig.mockClear();
backend.publishConfig.mockClear();
await expect(rollbackFromNacosHistory(entry.id, "other", target, backend)).rejects.toThrow(/target/i);
await expect(rollbackFromNacosHistory(entry.id, "main", "changed", backend)).rejects.toThrow(/target/i);
expect(backend.getConfig).not.toHaveBeenCalled();
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("records rollback conflicts without overwriting subsequent edits and permits retry", async () => {
const entry = await apply();
current = { ...current, content: "someone else's change", md5: "other" };
const conflict = await rollbackFromNacosHistory(entry.id, "main", target, backend);
expect(conflict.rollback?.conflicts).toBe(1);
expect(backend.publishConfig).toHaveBeenCalledTimes(1);
current = { ...current, content: "mysql-new", md5: "after" };
expect((await rollbackFromNacosHistory(entry.id, "main", target, backend)).rollback?.restored).toBe(1);
});
it("stops a rollback when its durable checkpoint fails", async () => {
const entry = await apply();
backend.publishConfig.mockClear();
storage.saveNacosReplaceHistory.mockRejectedValueOnce(new Error("quota"));
await expect(rollbackFromNacosHistory(entry.id, "main", target, backend)).rejects.toThrow("quota");
expect(backend.publishConfig).not.toHaveBeenCalled();
});
it("retains uncertain items across partial rollback retries without republishing already restored items", async () => {
const configs = ["one", "two", "three"].map((dataId) => ({ ...original, dataId }));
const rows = new Map(configs.map((item) => [item.dataId, item]));
const live = {
getConfig: async (key: { dataId: string }) => ({ ...rows.get(key.dataId)! }),
publishConfig: vi.fn(async (req: NacosConfigUpsert) => {
rows.set(req.dataId, { ...rows.get(req.dataId)!, ...req, md5: req.content === "mysql-old" ? "before" : "after" });
}),
};
const entry = await applyWithNacosHistory("main", target, { scope: "allNamespaces", namespace: "public", group: "", dataId: "" }, buildNacosContentReplacePlan(configs, "mysql-old", "mysql-new"), live);
let failOnce = true;
storage.saveNacosReplaceHistory.mockImplementation(async (candidate) => {
if (failOnce && candidate.state === "rollingBack" && candidate.rollback?.items.length === 2 && !candidate.inFlight) {
failOnce = false;
throw new Error("quota");
}
saved.set(candidate.id, structuredClone(candidate));
});
await expect(rollbackFromNacosHistory(entry.id, "main", target, live)).rejects.toThrow("quota");
expect(saved.get(entry.id)?.rollback?.restored).toBe(1);
expect(saved.get(entry.id)?.inFlight?.key).toBe(entry.plan.items[1].key);
const retried = await rollbackFromNacosHistory(entry.id, "main", target, live);
expect(retried.rollback?.restored).toBe(2);
expect(retried.uncertainItems).toEqual([{ key: entry.plan.items[1].key, phase: "rollback" }]);
await rollbackFromNacosHistory(entry.id, "main", target, live);
expect(live.publishConfig).toHaveBeenCalledTimes(6);
});
it("binds target identity to endpoints but excludes credentials", () => {
const connection = { id: "main", host: "localhost", port: 8848, external_config: { serverAddr: "http://localhost:8848", contextPath: "/nacos", auth: { password: "secret" } } };
const identity = nacosHistoryTarget(connection);
expect(identity).not.toContain("secret");
expect(nacosHistoryTarget({ ...connection, external_config: { ...connection.external_config, serverAddr: "http://other:8848" } })).not.toBe(identity);
});
it("rejects an overlapping operation across tabs", async () => {
vi.stubGlobal("navigator", { locks: { request: async (_name: string, _options: unknown, cb: (lock: null) => unknown) => cb(null) } });
const action = vi.fn();
await expect(withNacosHistoryLock("main", action)).rejects.toThrow(/busy/i);
expect(action).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,128 @@
import { webcrypto } from "node:crypto";
import { IDBFactory, IDBObjectStore } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { saveNacosReplaceHistory, getNacosReplaceHistory, listNacosReplaceHistory, deleteNacosReplaceHistory } from "../nacosReplaceHistoryStorage";
import { buildNacosContentReplacePlan } from "../nacosContentReplace";
import type { NacosReplaceHistoryEntry } from "../nacosReplaceHistory";
const plan = buildNacosContentReplacePlan([{ namespace: "", group: "test", dataId: "one", content: "secret-test-old" }], "secret-test-old", "secret-test-new");
function entry(id = "one", connectionId = "main", createdAt = 1): NacosReplaceHistoryEntry {
return {
version: 1,
id,
connectionId,
target: "local",
createdAt,
updatedAt: createdAt,
scope: { scope: "allNamespaces", namespace: "", group: "", dataId: "" },
state: "completed",
plan,
report: { ...plan, items: [{ ...plan.items[0], status: "replaced" }], replaced: 1, conflicts: 0, failed: 0, cancelled: false },
};
}
function value<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function open() {
return value(indexedDB.open("dbx-nacos-replace-history", 1));
}
async function stored() {
const db = await open();
const row = await value(db.transaction("entries").objectStore("entries").get("one"));
const key = await value(db.transaction("keys").objectStore("keys").get("encryption-key"));
db.close();
return { row, key };
}
beforeEach(() => {
vi.stubGlobal("indexedDB", new IDBFactory());
vi.stubGlobal("crypto", webcrypto);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("encrypted local Nacos history storage", () => {
it("round trips snapshots with a durable non-extractable key and no plaintext values", async () => {
await saveNacosReplaceHistory(entry());
expect(await getNacosReplaceHistory("one", "main")).toEqual(JSON.parse(JSON.stringify(entry())));
const { row, key } = await stored();
expect(row.data).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder().decode(row.data)).not.toContain("secret-test");
expect(JSON.stringify(row)).not.toContain("secret-test");
expect(key.extractable).toBe(false);
await expect(crypto.subtle.exportKey("raw", key)).rejects.toThrow();
});
it("lists only the current connection, sorts newest first, updates one row and deletes only that connection's record", async () => {
expect(await listNacosReplaceHistory("main")).toEqual([]);
await saveNacosReplaceHistory(entry());
await saveNacosReplaceHistory(entry("two", "main", 2));
await saveNacosReplaceHistory(entry("foreign", "other", 3));
await saveNacosReplaceHistory({ ...entry(), state: "rollbackCompleted" });
expect((await listNacosReplaceHistory("main")).map((item) => item.id)).toEqual(["two", "one"]);
expect((await getNacosReplaceHistory("one", "main"))?.state).toBe("rollbackCompleted");
expect(await getNacosReplaceHistory("one", "other")).toBeUndefined();
expect(await getNacosReplaceHistory("missing", "main")).toBeUndefined();
await deleteNacosReplaceHistory("one", "other");
expect(await getNacosReplaceHistory("one", "main")).toBeDefined();
await deleteNacosReplaceHistory("one", "main");
await deleteNacosReplaceHistory("missing", "main");
expect((await listNacosReplaceHistory("main")).map((item) => item.id)).toEqual(["two"]);
});
it("atomically initializes one shared key during concurrent first saves", async () => {
await Promise.all(Array.from({ length: 8 }, (_, i) => saveNacosReplaceHistory(entry(String(i)))));
expect(await listNacosReplaceHistory("main")).toHaveLength(8);
});
it("rejects tampered ciphertext rather than returning untrusted snapshots", async () => {
await saveNacosReplaceHistory(entry());
const { row } = await stored();
new Uint8Array(row.data)[0] ^= 1;
const db = await open();
await value(db.transaction("entries", "readwrite").objectStore("entries").put(row));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
await expect(listNacosReplaceHistory("main")).rejects.toThrow("nacos-history-storage-failed");
});
it.each([{ version: 2 }, { id: "changed" }, { connectionId: "other" }, { plan: {} }, { report: {} }])("validates authenticated record identity and version (%j)", async (change) => {
await saveNacosReplaceHistory(entry());
const { row, key } = await stored();
row.data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: row.iv, additionalData: new TextEncoder().encode("one\u0000main") }, key, new TextEncoder().encode(JSON.stringify({ ...entry(), ...change })));
const db = await open();
await value(db.transaction("entries", "readwrite").objectStore("entries").put(row));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
});
it("never replaces a lost key when encrypted backups already exist", async () => {
await saveNacosReplaceHistory(entry());
const db = await open();
await value(db.transaction("keys", "readwrite").objectStore("keys").delete("encryption-key"));
db.close();
await expect(getNacosReplaceHistory("one", "main")).rejects.toThrow("nacos-history-storage-failed");
await expect(saveNacosReplaceHistory(entry("two"))).rejects.toThrow("nacos-history-storage-failed");
});
it("fails closed without IndexedDB or Web Crypto and never falls back to localStorage", async () => {
const setItem = vi.fn();
vi.stubGlobal("localStorage", { setItem });
vi.stubGlobal("indexedDB", undefined);
await expect(saveNacosReplaceHistory(entry())).rejects.toThrow("nacos-history-storage-unavailable");
vi.stubGlobal("indexedDB", new IDBFactory());
vi.stubGlobal("crypto", {});
await expect(saveNacosReplaceHistory(entry())).rejects.toThrow("nacos-history-storage-unavailable");
expect(setItem).not.toHaveBeenCalled();
});
it("reports quota/transaction errors rather than silently losing snapshots", async () => {
await saveNacosReplaceHistory(entry());
vi.spyOn(IDBObjectStore.prototype, "put").mockImplementationOnce(() => {
throw new DOMException("quota", "QuotaExceededError");
});
await expect(saveNacosReplaceHistory(entry("two"))).rejects.toThrow("nacos-history-storage-failed");
expect(await getNacosReplaceHistory("one", "main")).toBeDefined();
});
it("reports a database open error", async () => {
await value(indexedDB.open("dbx-nacos-replace-history", 2)).then((db) => db.close());
await expect(listNacosReplaceHistory("main")).rejects.toThrow("nacos-history-storage-failed");
});
});
@@ -0,0 +1,256 @@
import type { NacosConfigItem, NacosConfigKey, NacosConfigUpsert } from "@/types/nacos";
export type NacosContentReplaceItemStatus = "replaced" | "conflict" | "failed";
export interface NacosContentReplacePlanItem {
key: string;
namespace: string;
group: string;
dataId: string;
beforeContent: string;
afterContent: string;
replacements: number;
expectedMd5?: string;
configType?: string;
appName?: string;
desc?: string;
tags?: string;
}
export interface NacosContentReplacePlan {
search: string;
replacement: string;
totalReplacements: number;
items: NacosContentReplacePlanItem[];
}
export interface NacosContentReplaceResultItem extends NacosContentReplacePlanItem {
status: NacosContentReplaceItemStatus;
message?: string;
appliedMd5?: string;
}
export interface NacosContentReplaceReport extends Omit<NacosContentReplacePlan, "items"> {
items: NacosContentReplaceResultItem[];
replaced: number;
conflicts: number;
failed: number;
cancelled: boolean;
}
export interface NacosContentRollbackReport {
restored: number;
conflicts: number;
failed: number;
items: Array<{
key: string;
namespace: string;
group: string;
dataId: string;
status: "restored" | "conflict" | "failed";
message?: string;
}>;
}
export interface NacosContentReplaceApi {
getConfig(key: NacosConfigKey): Promise<NacosConfigItem>;
publishConfig(request: NacosConfigUpsert): Promise<void>;
}
export interface NacosContentReplaceExecutionOptions extends NacosContentReplaceApi {
signal?: AbortSignal;
onProgress?: (completed: number, total: number) => void;
onBeforeItem?: (item: NacosContentReplacePlanItem) => Promise<void>;
onItemResult?: (item: NacosContentReplaceResultItem) => Promise<void>;
}
export interface NacosContentRollbackOptions extends NacosContentReplaceApi {
onBeforeItem?: (item: NacosContentReplaceResultItem) => Promise<void>;
onItemResult?: (item: NacosContentRollbackReport["items"][number]) => Promise<void>;
}
export function nacosConfigIdentity(config: Pick<NacosConfigItem, "namespace" | "group" | "dataId">): string {
return `${config.namespace || ""}\u0000${config.group}\u0000${config.dataId}`;
}
function countLiteralOccurrences(content: string, search: string): number {
let count = 0;
let offset = 0;
while (true) {
const match = content.indexOf(search, offset);
if (match < 0) return count;
count += 1;
offset = match + search.length;
}
}
function validateReplaceInput(search: string, replacement: string) {
if (!search) throw new Error("Nacos content replacement search value is required");
if (search.length > 1024) throw new Error("Nacos content replacement search value exceeds the 1024 character limit");
if (search === replacement) throw new Error("Nacos content replacement values must be different");
}
export function buildNacosContentReplacePlan(configs: readonly NacosConfigItem[], search: string, replacement: string): NacosContentReplacePlan {
validateReplaceInput(search, replacement);
const seen = new Set<string>();
const items: NacosContentReplacePlanItem[] = [];
let totalReplacements = 0;
for (const config of configs) {
const key = nacosConfigIdentity(config);
if (seen.has(key)) continue;
seen.add(key);
const beforeContent = config.content ?? "";
const replacements = countLiteralOccurrences(beforeContent, search);
if (replacements === 0) continue;
totalReplacements += replacements;
items.push({
key,
namespace: config.namespace || "",
group: config.group,
dataId: config.dataId,
beforeContent,
afterContent: beforeContent.split(search).join(replacement),
replacements,
expectedMd5: config.md5,
configType: config.configType,
appName: config.appName,
desc: config.desc,
tags: config.tags,
});
}
return { search, replacement, totalReplacements, items };
}
function configKey(item: NacosContentReplacePlanItem): NacosConfigKey & { namespace: string } {
return { namespace: item.namespace, group: item.group, dataId: item.dataId };
}
function configMatchesPreview(item: NacosContentReplacePlanItem, current: NacosConfigItem): boolean {
if (current.content !== item.beforeContent) return false;
return !item.expectedMd5 || !current.md5 || current.md5 === item.expectedMd5;
}
function configMatchesApplied(item: NacosContentReplaceResultItem, current: NacosConfigItem): boolean {
if (current.content !== item.afterContent) return false;
return !item.appliedMd5 || !current.md5 || current.md5 === item.appliedMd5;
}
function replacementUpsert(item: NacosContentReplacePlanItem, currentMd5?: string): NacosConfigUpsert {
return {
namespace: item.namespace,
group: item.group,
dataId: item.dataId,
content: item.afterContent,
configType: item.configType,
appName: item.appName,
desc: item.desc,
tags: item.tags,
casMd5: currentMd5,
};
}
function rollbackUpsert(item: NacosContentReplaceResultItem, currentMd5?: string): NacosConfigUpsert {
return {
namespace: item.namespace,
group: item.group,
dataId: item.dataId,
content: item.beforeContent,
configType: item.configType,
appName: item.appName,
desc: item.desc,
tags: item.tags,
casMd5: currentMd5,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function applyNacosContentReplacePlan(plan: NacosContentReplacePlan, options: NacosContentReplaceExecutionOptions): Promise<NacosContentReplaceReport> {
const resultItems: NacosContentReplaceResultItem[] = [];
let replaced = 0;
let conflicts = 0;
let failed = 0;
for (const item of plan.items) {
if (options.signal?.aborted) break;
await options.onBeforeItem?.(item);
try {
const current = await options.getConfig(configKey(item));
if (!configMatchesPreview(item, current)) {
conflicts += 1;
resultItems.push({ ...item, status: "conflict", message: "Configuration changed after preview" });
continue;
}
await options.publishConfig(replacementUpsert(item, current.md5));
const verified = await options.getConfig(configKey(item));
if (verified.content !== item.afterContent) {
failed += 1;
resultItems.push({ ...item, status: "failed", message: "Published content verification failed" });
continue;
}
replaced += 1;
resultItems.push({ ...item, status: "replaced", appliedMd5: verified.md5 });
} catch (error) {
const message = errorMessage(error);
if (/\bcas\b|casmd5/i.test(message)) {
conflicts += 1;
resultItems.push({ ...item, status: "conflict", message });
} else {
failed += 1;
resultItems.push({ ...item, status: "failed", message });
}
} finally {
// Journal errors must escape the API-error handler and stop subsequent writes.
await options.onItemResult?.(resultItems[resultItems.length - 1]);
options.onProgress?.(resultItems.length, plan.items.length);
}
}
return {
...plan,
items: resultItems,
replaced,
conflicts,
failed,
cancelled: options.signal?.aborted === true,
};
}
export async function rollbackNacosContentReplace(report: NacosContentReplaceReport, options: NacosContentRollbackOptions): Promise<NacosContentRollbackReport> {
const items: NacosContentRollbackReport["items"] = [];
let restored = 0;
let conflicts = 0;
let failed = 0;
for (const item of report.items.filter((candidate) => candidate.status === "replaced")) {
await options.onBeforeItem?.(item);
try {
const current = await options.getConfig(configKey(item));
if (!configMatchesApplied(item, current)) {
conflicts += 1;
items.push({ ...configKey(item), key: item.key, status: "conflict", message: "Configuration changed after replacement" });
continue;
}
await options.publishConfig(rollbackUpsert(item, current.md5));
const verified = await options.getConfig(configKey(item));
if (verified.content !== item.beforeContent) {
failed += 1;
items.push({ ...configKey(item), key: item.key, status: "failed", message: "Rollback verification failed" });
continue;
}
restored += 1;
items.push({ ...configKey(item), key: item.key, status: "restored" });
} catch (error) {
failed += 1;
items.push({ ...configKey(item), key: item.key, status: "failed", message: errorMessage(error) });
} finally {
await options.onItemResult?.(items[items.length - 1]);
}
}
return { restored, conflicts, failed, items };
}
@@ -0,0 +1,122 @@
import { uuid } from "@/lib/common/utils";
import type { ConnectionConfig } from "@/types/database";
import type { NacosAdminConfig, NacosNamespaceScope } from "@/types/nacos";
import { applyNacosContentReplacePlan, rollbackNacosContentReplace, type NacosContentReplaceApi, type NacosContentReplacePlan, type NacosContentReplaceReport, type NacosContentRollbackReport } from "./nacosContentReplace";
import { getNacosReplaceHistory, saveNacosReplaceHistory } from "./nacosReplaceHistoryStorage";
export interface NacosReplaceHistoryEntry {
version: 1;
id: string;
connectionId: string;
target: string;
createdAt: number;
updatedAt: number;
scope: { scope: NacosNamespaceScope; namespace: string; group: string; dataId: string };
state: "applying" | "completed" | "rollingBack" | "rollbackCompleted";
plan: NacosContentReplacePlan;
report: NacosContentReplaceReport;
rollback?: NacosContentRollbackReport;
inFlight?: { key: string; phase: "apply" | "rollback" };
uncertainItems?: Array<{ key: string; phase: "apply" | "rollback" }>;
}
export function nacosHistoryTarget(connection: Pick<ConnectionConfig, "id" | "host" | "port" | "external_config">): string {
const config = connection.external_config as NacosAdminConfig | undefined;
// Do not include authentication or transport secrets in the identity.
return JSON.stringify([connection.host, connection.port, config?.serverAddr ?? "", config?.contextPath ?? "/nacos", config?.implementation ?? "nacos", config?.rnacosConsoleAddr ?? ""]);
}
export async function withNacosHistoryLock<T>(connectionId: string, action: () => Promise<T>): Promise<T> {
if (!globalThis.navigator?.locks) throw new Error("nacos-history-storage-unavailable");
return navigator.locks.request(`dbx-nacos-replace:${connectionId}`, { ifAvailable: true }, async (lock) => {
if (!lock) throw new Error("nacos-history-busy");
return action();
});
}
function updateCounts(entry: NacosReplaceHistoryEntry) {
entry.report.replaced = entry.report.items.filter((item) => item.status === "replaced").length;
entry.report.conflicts = entry.report.items.filter((item) => item.status === "conflict").length;
entry.report.failed = entry.report.items.filter((item) => item.status === "failed").length;
if (entry.rollback) {
entry.rollback.restored = entry.rollback.items.filter((item) => item.status === "restored").length;
entry.rollback.conflicts = entry.rollback.items.filter((item) => item.status === "conflict").length;
entry.rollback.failed = entry.rollback.items.filter((item) => item.status === "failed").length;
}
entry.updatedAt = Date.now();
}
export function nacosHistoryRollbackCandidates(entry: NacosReplaceHistoryEntry) {
const restored = new Set(entry.rollback?.items.filter((item) => item.status === "restored").map((item) => item.key));
const uncertain = new Set(entry.uncertainItems?.map((item) => item.key));
if (entry.inFlight) uncertain.add(entry.inFlight.key);
return entry.report.items.filter((item) => item.status === "replaced" && !restored.has(item.key) && !uncertain.has(item.key));
}
export async function applyWithNacosHistory(connectionId: string, target: string, scope: NacosReplaceHistoryEntry["scope"], plan: NacosContentReplacePlan, api: NacosContentReplaceApi): Promise<NacosReplaceHistoryEntry> {
const entry: NacosReplaceHistoryEntry = {
version: 1,
id: uuid(),
connectionId,
target,
scope,
createdAt: Date.now(),
updatedAt: Date.now(),
state: "applying",
plan,
report: { search: plan.search, replacement: plan.replacement, totalReplacements: plan.totalReplacements, items: [], replaced: 0, conflicts: 0, failed: 0, cancelled: false },
};
await saveNacosReplaceHistory(entry);
entry.report = await applyNacosContentReplacePlan(plan, {
...api,
onBeforeItem: async (item) => {
entry.inFlight = { key: item.key, phase: "apply" };
await saveNacosReplaceHistory(entry);
},
onItemResult: async (item) => {
entry.report.items.push(item);
delete entry.inFlight;
updateCounts(entry);
await saveNacosReplaceHistory(entry);
},
});
entry.state = "completed";
updateCounts(entry);
await saveNacosReplaceHistory(entry);
return entry;
}
export async function rollbackFromNacosHistory(id: string, connectionId: string, target: string, api: NacosContentReplaceApi): Promise<NacosReplaceHistoryEntry> {
const entry = await getNacosReplaceHistory(id, connectionId);
if (!entry || entry.connectionId !== connectionId || entry.target !== target) throw new Error("nacos-history-target-mismatch");
const candidates = nacosHistoryRollbackCandidates(entry);
if (!candidates.length) return entry;
entry.state = "rollingBack";
entry.rollback ??= { restored: 0, conflicts: 0, failed: 0, items: [] };
if (entry.inFlight) {
entry.uncertainItems = [...(entry.uncertainItems ?? []), entry.inFlight];
delete entry.inFlight;
}
await saveNacosReplaceHistory(entry);
await rollbackNacosContentReplace(
{ ...entry.report, items: candidates },
{
...api,
onBeforeItem: async (item) => {
entry.inFlight = { key: item.key, phase: "rollback" };
await saveNacosReplaceHistory(entry);
},
onItemResult: async (item) => {
entry.rollback!.items = entry.rollback!.items.filter((previous) => previous.key !== item.key);
entry.rollback!.items.push(item);
delete entry.inFlight;
updateCounts(entry);
await saveNacosReplaceHistory(entry);
},
},
);
entry.state = entry.uncertainItems?.length ? "rollingBack" : "rollbackCompleted";
updateCounts(entry);
await saveNacosReplaceHistory(entry);
return entry;
}
@@ -0,0 +1,135 @@
import type { NacosReplaceHistoryEntry } from "./nacosReplaceHistory";
const DB_NAME = "dbx-nacos-replace-history";
const KEY_ID = "encryption-key";
interface EncryptedRow {
id: string;
connectionId: string;
createdAt: number;
iv: Uint8Array<ArrayBuffer>;
data: ArrayBuffer;
}
function requestValue<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("nacos-history-storage-failed"));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = transaction.onerror = () => reject(new Error("nacos-history-storage-failed"));
});
}
async function openDatabase(): Promise<IDBDatabase> {
if (!globalThis.indexedDB || !globalThis.crypto?.subtle) throw new Error("nacos-history-storage-unavailable");
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("keys");
const entries = db.createObjectStore("entries", { keyPath: "id" });
entries.createIndex("connectionId", "connectionId");
};
request.onblocked = () => request.onerror?.(new Event("error"));
const db = await requestValue(request);
db.onversionchange = () => db.close();
return db;
}
async function encryptionKey(db: IDBDatabase): Promise<CryptoKey> {
const existing = await requestValue<CryptoKey | undefined>(db.transaction("keys").objectStore("keys").get(KEY_ID));
if (existing) return existing;
const candidate = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const tx = db.transaction(["keys", "entries"], "readwrite");
const done = transactionDone(tx);
let key: CryptoKey = candidate;
const lookup = tx.objectStore("keys").get(KEY_ID);
lookup.onsuccess = () => {
if (lookup.result) {
key = lookup.result;
return;
}
const count = tx.objectStore("entries").count();
count.onsuccess = () => {
if (count.result) tx.abort();
else tx.objectStore("keys").add(candidate, KEY_ID);
};
};
await done;
return key;
}
function additionalData(row: Pick<EncryptedRow, "id" | "connectionId">) {
return new TextEncoder().encode(`${row.id}\u0000${row.connectionId}`);
}
async function decryptRow(row: EncryptedRow, key: CryptoKey): Promise<NacosReplaceHistoryEntry> {
try {
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: row.iv, additionalData: additionalData(row) }, key, row.data);
const entry = JSON.parse(new TextDecoder().decode(plain)) as NacosReplaceHistoryEntry;
if (entry.version !== 1 || entry.id !== row.id || entry.connectionId !== row.connectionId || !Array.isArray(entry.plan?.items) || !Array.isArray(entry.report?.items)) throw new Error();
return entry;
} catch {
throw new Error("nacos-history-storage-failed");
}
}
export async function saveNacosReplaceHistory(entry: NacosReplaceHistoryEntry): Promise<void> {
const db = await openDatabase();
try {
const key = await encryptionKey(db);
const row: EncryptedRow = { id: entry.id, connectionId: entry.connectionId, createdAt: entry.createdAt, iv: crypto.getRandomValues(new Uint8Array(12)), data: new ArrayBuffer(0) };
row.data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: row.iv, additionalData: additionalData(row) }, key, new TextEncoder().encode(JSON.stringify(entry)));
const tx = db.transaction("entries", "readwrite");
const done = transactionDone(tx);
tx.objectStore("entries").put(row);
await done;
} catch {
throw new Error("nacos-history-storage-failed");
} finally {
db.close();
}
}
export async function listNacosReplaceHistory(connectionId: string): Promise<NacosReplaceHistoryEntry[]> {
const db = await openDatabase();
try {
const rows = await requestValue<EncryptedRow[]>(db.transaction("entries").objectStore("entries").index("connectionId").getAll(connectionId));
if (!rows.length) return [];
const key = await encryptionKey(db);
const entries = await Promise.all(rows.map((row) => decryptRow(row, key)));
return entries.sort((a, b) => b.createdAt - a.createdAt);
} finally {
db.close();
}
}
export async function getNacosReplaceHistory(id: string, connectionId: string): Promise<NacosReplaceHistoryEntry | undefined> {
const db = await openDatabase();
try {
const row = await requestValue<EncryptedRow | undefined>(db.transaction("entries").objectStore("entries").get(id));
if (!row || row.connectionId !== connectionId) return undefined;
return await decryptRow(row, await encryptionKey(db));
} finally {
db.close();
}
}
export async function deleteNacosReplaceHistory(id: string, connectionId: string): Promise<void> {
const db = await openDatabase();
try {
const tx = db.transaction("entries", "readwrite");
const done = transactionDone(tx);
const store = tx.objectStore("entries");
const lookup = store.get(id);
lookup.onsuccess = () => {
if (lookup.result?.connectionId === connectionId) store.delete(id);
};
await done;
} finally {
db.close();
}
}
+2
View File
@@ -465,6 +465,8 @@ export interface NacosConfigUpsert extends NacosConfigKey {
appName?: string;
desc?: string;
tags?: string;
/** Publish only while the server still holds this MD5. */
casMd5?: string;
}
export interface NacosConfigHistoryQuery extends NacosConfigKey {