feat(nacos): global content replacement with persistent rollback
This commit is contained in:
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>
|
||||
+387
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "設定同期",
|
||||
|
||||
@@ -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: "구성 동기화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "配置同步",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user