From 7ade5d2b6e799bb4675a491a4ca49bf248493dae Mon Sep 17 00:00:00 2001 From: xiaochouwei <49049474+xiaochouwei@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:12:18 +0800 Subject: [PATCH] feat(nacos): global content replacement with persistent rollback --- .../components/nacos/NacosAdminConsole.vue | 15 +- .../nacos/NacosContentReplaceDialog.vue | 603 ++++++++++++++++++ .../NacosContentReplaceDialog.spec.ts | 387 +++++++++++ .../nacosContentReplaceParity.spec.ts | 38 ++ apps/desktop/src/i18n/locales/en.ts | 50 ++ apps/desktop/src/i18n/locales/es.ts | 50 ++ apps/desktop/src/i18n/locales/it.ts | 50 ++ apps/desktop/src/i18n/locales/ja.ts | 50 ++ apps/desktop/src/i18n/locales/ko.ts | 50 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 50 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 50 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 50 ++ .../__tests__/nacosContentReplace.spec.ts | 194 ++++++ .../__tests__/nacosReplaceHistory.spec.ts | 132 ++++ .../nacosReplaceHistoryStorage.spec.ts | 128 ++++ .../src/lib/nacos/nacosContentReplace.ts | 256 ++++++++ .../src/lib/nacos/nacosReplaceHistory.ts | 122 ++++ .../lib/nacos/nacosReplaceHistoryStorage.ts | 135 ++++ apps/desktop/src/types/nacos.ts | 2 + crates/dbx-core/src/admin/nacos/archive.rs | 1 + crates/dbx-core/src/admin/nacos/batch.rs | 2 + crates/dbx-core/src/admin/nacos/http.rs | 28 +- crates/dbx-core/src/admin/nacos/types.rs | 2 + package.json | 2 + pnpm-lock.yaml | 136 +++- scripts/verify-nacos-replace-history.mjs | 136 ++++ 26 files changed, 2706 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/nacos/NacosContentReplaceDialog.vue create mode 100644 apps/desktop/src/components/nacos/__tests__/NacosContentReplaceDialog.spec.ts create mode 100644 apps/desktop/src/i18n/__tests__/nacosContentReplaceParity.spec.ts create mode 100644 apps/desktop/src/lib/nacos/__tests__/nacosContentReplace.spec.ts create mode 100644 apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistory.spec.ts create mode 100644 apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistoryStorage.spec.ts create mode 100644 apps/desktop/src/lib/nacos/nacosContentReplace.ts create mode 100644 apps/desktop/src/lib/nacos/nacosReplaceHistory.ts create mode 100644 apps/desktop/src/lib/nacos/nacosReplaceHistoryStorage.ts create mode 100644 scripts/verify-nacos-replace-history.mjs diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 1b0054a9b7..c0991b4164 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -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>({}); const selectedConfigKeys = ref([]); const searchOpen = ref(false); +const contentReplaceOpen = ref(false); const searchLoading = ref(false); const searchError = ref(""); const searchResult = ref(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(() => + ' }), +})); + +vi.mock("@/lib/backend/api", () => api); + +vi.mock("@/components/nacos/NacosConfigDiffDialog.vue", () => ({ + default: defineComponent({ + props: { open: Boolean, before: String, after: String }, + template: '
', + }), +})); + +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(); + }); +}); diff --git a/apps/desktop/src/i18n/__tests__/nacosContentReplaceParity.spec.ts b/apps/desktop/src/i18n/__tests__/nacosContentReplaceParity.spec.ts new file mode 100644 index 0000000000..30aefb5091 --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/nacosContentReplaceParity.spec.ts @@ -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).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)); + } + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0899f4d480..ecbf32b562 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 92de378fd7..8ea16f7d28 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 304e71df6f..685f9e40ef 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d8038faf75..f6f3b06262 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "設定同期", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 8dfeb2587b..c4854a6ed5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "구성 동기화", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d122694436..387b15d171 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f2aa516e35..5eb0566efd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "配置同步", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 368773b514..6af1d1a76e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "設定同步", diff --git a/apps/desktop/src/lib/nacos/__tests__/nacosContentReplace.spec.ts b/apps/desktop/src/lib/nacos/__tests__/nacosContentReplace.spec.ts new file mode 100644 index 0000000000..ceaff6d95d --- /dev/null +++ b/apps/desktop/src/lib/nacos/__tests__/nacosContentReplace.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistory.spec.ts b/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistory.spec.ts new file mode 100644 index 0000000000..6f886cfcb8 --- /dev/null +++ b/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistory.spec.ts @@ -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(); +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(); + }); +}); diff --git a/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistoryStorage.spec.ts b/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistoryStorage.spec.ts new file mode 100644 index 0000000000..2ca5d54ed5 --- /dev/null +++ b/apps/desktop/src/lib/nacos/__tests__/nacosReplaceHistoryStorage.spec.ts @@ -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(req: IDBRequest): Promise { + 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"); + }); +}); diff --git a/apps/desktop/src/lib/nacos/nacosContentReplace.ts b/apps/desktop/src/lib/nacos/nacosContentReplace.ts new file mode 100644 index 0000000000..61520a5996 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosContentReplace.ts @@ -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 { + 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; + publishConfig(request: NacosConfigUpsert): Promise; +} + +export interface NacosContentReplaceExecutionOptions extends NacosContentReplaceApi { + signal?: AbortSignal; + onProgress?: (completed: number, total: number) => void; + onBeforeItem?: (item: NacosContentReplacePlanItem) => Promise; + onItemResult?: (item: NacosContentReplaceResultItem) => Promise; +} + +export interface NacosContentRollbackOptions extends NacosContentReplaceApi { + onBeforeItem?: (item: NacosContentReplaceResultItem) => Promise; + onItemResult?: (item: NacosContentRollbackReport["items"][number]) => Promise; +} + +export function nacosConfigIdentity(config: Pick): 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(); + 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 { + 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 { + 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 }; +} diff --git a/apps/desktop/src/lib/nacos/nacosReplaceHistory.ts b/apps/desktop/src/lib/nacos/nacosReplaceHistory.ts new file mode 100644 index 0000000000..42a87b5272 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosReplaceHistory.ts @@ -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): 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(connectionId: string, action: () => Promise): Promise { + 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 { + 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 { + 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; +} diff --git a/apps/desktop/src/lib/nacos/nacosReplaceHistoryStorage.ts b/apps/desktop/src/lib/nacos/nacosReplaceHistoryStorage.ts new file mode 100644 index 0000000000..680df29241 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosReplaceHistoryStorage.ts @@ -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; + data: ArrayBuffer; +} + +function requestValue(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new Error("nacos-history-storage-failed")); + }); +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = transaction.onerror = () => reject(new Error("nacos-history-storage-failed")); + }); +} + +async function openDatabase(): Promise { + 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 { + const existing = await requestValue(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) { + return new TextEncoder().encode(`${row.id}\u0000${row.connectionId}`); +} + +async function decryptRow(row: EncryptedRow, key: CryptoKey): Promise { + 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 { + 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 { + const db = await openDatabase(); + try { + const rows = await requestValue(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 { + const db = await openDatabase(); + try { + const row = await requestValue(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 { + 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(); + } +} diff --git a/apps/desktop/src/types/nacos.ts b/apps/desktop/src/types/nacos.ts index fe1c5b5a7d..334f1d31fb 100644 --- a/apps/desktop/src/types/nacos.ts +++ b/apps/desktop/src/types/nacos.ts @@ -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 { diff --git a/crates/dbx-core/src/admin/nacos/archive.rs b/crates/dbx-core/src/admin/nacos/archive.rs index 9d442f5dd0..1fcadaac0a 100644 --- a/crates/dbx-core/src/admin/nacos/archive.rs +++ b/crates/dbx-core/src/admin/nacos/archive.rs @@ -189,6 +189,7 @@ pub fn decode_config_archive(bytes: &[u8], target_namespace: &str) -> Result, String>>()?; @@ -745,6 +746,7 @@ mod tests { app_name: None, desc: None, tags: None, + cas_md5: None, } } diff --git a/crates/dbx-core/src/admin/nacos/http.rs b/crates/dbx-core/src/admin/nacos/http.rs index c3f174cdc5..8468d7cd5d 100644 --- a/crates/dbx-core/src/admin/nacos/http.rs +++ b/crates/dbx-core/src/admin/nacos/http.rs @@ -2653,6 +2653,7 @@ impl NacosAdmin for NacosOpenApiAdmin { async fn publish_config(&self, req: NacosConfigUpsert) -> Result<(), String> { let namespace = self.namespace(req.namespace.as_deref()); + let uses_cas = req.cas_md5.is_some(); let (v3_form, v1_form) = build_publish_forms(req, namespace); let mut errors = Vec::new(); @@ -2661,8 +2662,8 @@ impl NacosAdmin for NacosOpenApiAdmin { continue; } match self.request(reqwest::Method::POST, path, Vec::new(), Some(form), None).await { - Ok(resp) => match error_for_status(resp, path).await { - Ok(_) => return Ok(()), + Ok(resp) => match publish_response(resp, path, uses_cas).await { + Ok(()) => return Ok(()), Err(err) => errors.push(err), }, Err(err) => errors.push(err), @@ -2672,8 +2673,8 @@ impl NacosAdmin for NacosOpenApiAdmin { return Err(format!("Failed to publish Nacos config: {}", errors.join("; "))); } match self.request(reqwest::Method::POST, "/v1/cs/configs", Vec::new(), Some(v1_form), None).await { - Ok(resp) => match error_for_status(resp, "/v1/cs/configs").await { - Ok(_) => Ok(()), + Ok(resp) => match publish_response(resp, "/v1/cs/configs", uses_cas).await { + Ok(()) => Ok(()), Err(err) => { errors.push(err); Err(format!("Failed to publish Nacos config: {}", errors.join("; "))) @@ -2888,6 +2889,7 @@ impl NacosAdmin for NacosOpenApiAdmin { app_name: history.app_name, desc: history.desc, tags: history.tags, + cas_md5: None, }) .await .map_err(|publish_err| { @@ -3789,6 +3791,7 @@ fn build_publish_forms(req: NacosConfigUpsert, namespace: String) -> (NacosForm, push_optional(&mut v3_form, "desc", req.desc.clone()); push_optional(&mut v3_form, "configTags", req.tags.clone()); push_optional(&mut v3_form, "config_tags", req.tags.clone()); + push_optional(&mut v3_form, "casMd5", req.cas_md5.clone()); let mut v1_form = vec![ ("dataId".to_string(), req.data_id), @@ -3800,6 +3803,7 @@ fn build_publish_forms(req: NacosConfigUpsert, namespace: String) -> (NacosForm, push_optional(&mut v1_form, "appName", req.app_name); push_optional(&mut v1_form, "desc", req.desc); push_optional(&mut v1_form, "config_tags", req.tags); + push_optional(&mut v1_form, "casMd5", req.cas_md5); (v3_form, v1_form) } @@ -3839,6 +3843,19 @@ async fn error_for_status(resp: reqwest::Response, path: &str) -> Result Result<(), String> { + let response = error_for_status(resp, path).await?; + let body = response.text().await.map_err(|error| format!("Failed to read Nacos publish response: {error}"))?; + if body.trim().eq_ignore_ascii_case("false") { + return Err(if uses_cas { + format!("Nacos publish rejected by CAS validation at {path}") + } else { + format!("Nacos publish rejected at {path}") + }); + } + Ok(()) +} + fn compact_response_detail(detail: &str) -> String { let detail = detail.trim(); if detail.is_empty() { @@ -7695,6 +7712,7 @@ mod tests { app_name: Some("portal".to_string()), desc: Some("main config".to_string()), tags: Some("prod,gray".to_string()), + cas_md5: Some("before-md5".to_string()), }, "ops".to_string(), ); @@ -7706,8 +7724,10 @@ mod tests { assert!(v3_form.contains(&("type".to_string(), "yaml".to_string()))); assert!(v3_form.contains(&("configTags".to_string(), "prod,gray".to_string()))); assert!(v3_form.contains(&("config_tags".to_string(), "prod,gray".to_string()))); + assert!(v3_form.contains(&("casMd5".to_string(), "before-md5".to_string()))); assert!(v1_form.contains(&("group".to_string(), "DEFAULT_GROUP".to_string()))); assert!(v1_form.contains(&("tenant".to_string(), "ops".to_string()))); + assert!(v1_form.contains(&("casMd5".to_string(), "before-md5".to_string()))); } #[test] diff --git a/crates/dbx-core/src/admin/nacos/types.rs b/crates/dbx-core/src/admin/nacos/types.rs index dab556dfa3..d30e5ec1a1 100644 --- a/crates/dbx-core/src/admin/nacos/types.rs +++ b/crates/dbx-core/src/admin/nacos/types.rs @@ -867,6 +867,8 @@ pub struct NacosConfigUpsert { pub desc: Option, #[serde(default)] pub tags: Option, + #[serde(default)] + pub cas_md5: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/package.json b/package.json index b6f2916c45..946c293967 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,9 @@ "@types/node": "^25.9.1", "@types/splitpanes": "^2.2.6", "@vitejs/plugin-vue": "^6.0.7", + "@vitest/coverage-v8": "4.1.8", "@xmldom/xmldom": "^0.8.13", + "fake-indexeddb": "6.2.4", "happy-dom": "^20.10.6", "husky": "^9.1.7", "lint-staged": "^16.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 541898a175..6354f472bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,7 +181,7 @@ importers: version: 2.10.1(vue@3.5.35(typescript@6.0.3)) shadcn-vue: specifier: ^2.7.4 - version: 2.7.4(eslint@10.2.1(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)) + version: 2.7.4(eslint@10.2.1(jiti@2.7.0))(magicast@0.5.5)(vue@3.5.35(typescript@6.0.3)) shiki: specifier: ^4.2.0 version: 4.2.0 @@ -237,9 +237,15 @@ importers: '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))(vue@3.5.35(typescript@6.0.3)) + '@vitest/coverage-v8': + specifier: 4.1.8 + version: 4.1.8(vitest@4.1.8) '@xmldom/xmldom': specifier: ^0.8.13 version: 0.8.13 + fake-indexeddb: + specifier: 6.2.4 + version: 6.2.4 happy-dom: specifier: ^20.10.6 version: 20.10.6 @@ -269,7 +275,7 @@ importers: version: 8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.6)(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.6)(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) vue-tsc: specifier: ^3.3.3 version: 3.3.3(typescript@6.0.3) @@ -325,7 +331,7 @@ importers: version: 22.19.19 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) + version: 4.1.8(@types/node@22.19.19)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.6)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) optionalDependencies: '@dbx-app/mcp-darwin-arm64': specifier: 0.4.93 @@ -529,6 +535,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} cpu: [arm64] @@ -1596,6 +1606,15 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -1834,6 +1853,9 @@ packages: resolution: {integrity: sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.6: + resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2388,6 +2410,10 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fake-indexeddb@6.2.4: + resolution: {integrity: sha512-INKeIKEtSViN4yVtEWEUqbsqmaIy7Ls+MfU0yxQVXg67pOJ/sH1ZxcVrP8XrKULUFohcPD9gnmym+qBfEybACw==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2556,6 +2582,10 @@ packages: resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2581,6 +2611,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2716,6 +2749,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -2727,6 +2772,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2959,10 +3007,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked@18.0.4: resolution: {integrity: sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==} engines: {node: '>= 20'} @@ -3662,6 +3717,10 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + systeminformation@5.31.11: resolution: {integrity: sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==} engines: {node: '>=8.0.0'} @@ -4252,6 +4311,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': optional: true @@ -5190,6 +5251,20 @@ snapshots: vite: 8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4) vue: 3.5.35(typescript@6.0.3) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.6 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.6)(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 @@ -5510,6 +5585,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astral-regex@2.0.0: {} atob@2.1.2: {} @@ -5575,7 +5656,7 @@ snapshots: bytes@3.1.2: {} - c12@3.3.4: + c12@3.3.4(magicast@0.5.5): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -5589,6 +5670,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.5 call-bind-apply-helpers@1.0.2: dependencies: @@ -6086,6 +6169,8 @@ snapshots: exsolve@1.1.0: {} + fake-indexeddb@6.2.4: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6268,6 +6353,8 @@ snapshots: - bufferutil - utf-8-validate + has-flag@4.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.3: @@ -6298,6 +6385,8 @@ snapshots: hookable@5.5.3: {} + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -6392,6 +6481,19 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -6400,6 +6502,8 @@ snapshots: jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -6591,12 +6695,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + marked@18.0.4: {} math-intrinsics@1.1.0: {} @@ -7151,13 +7265,13 @@ snapshots: setprototypeof@1.2.0: {} - shadcn-vue@2.7.4(eslint@10.2.1(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)): + shadcn-vue@2.7.4(eslint@10.2.1(jiti@2.7.0))(magicast@0.5.5)(vue@3.5.35(typescript@6.0.3)): dependencies: '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@unovue/detypes': 0.8.5 '@vue/compiler-sfc': 3.5.39 - c12: 3.3.4 + c12: 3.3.4(magicast@0.5.5) commander: 14.0.3 consola: 3.4.2 dedent: 1.7.2 @@ -7368,6 +7482,10 @@ snapshots: dependencies: copy-anything: 4.0.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + systeminformation@5.31.11: {} table@6.9.0: @@ -7539,7 +7657,7 @@ snapshots: tsx: 4.22.4 yaml: 2.8.4 - vitest@4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): + vitest@4.1.8(@types/node@22.19.19)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.6)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) @@ -7563,11 +7681,12 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) happy-dom: 20.10.6 transitivePeerDependencies: - msw - vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.6)(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): + vitest@4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.6)(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.2.1(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) @@ -7591,6 +7710,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) happy-dom: 20.10.6 transitivePeerDependencies: - msw diff --git a/scripts/verify-nacos-replace-history.mjs b/scripts/verify-nacos-replace-history.mjs new file mode 100644 index 0000000000..a0da37b8ad --- /dev/null +++ b/scripts/verify-nacos-replace-history.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +// Explicitly opt into a local test connection. Never run this against production. +const connectionId = process.env.NACOS_TEST_CONNECTION_ID; +assert(connectionId, "Set NACOS_TEST_CONNECTION_ID to a local Docker Nacos connection"); +const require = createRequire(import.meta.url); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE_PATH || "playwright"); +const frontend = process.env.DBX_TEST_FRONTEND || "http://127.0.0.1:5173"; +const backend = process.env.DBX_TEST_BACKEND || "http://127.0.0.1:4225"; +assert.equal(new URL(frontend).hostname, "127.0.0.1"); +assert.equal(new URL(backend).hostname, "127.0.0.1"); +const runId = randomUUID(); +const group = `DBX_VERIFY_ONLY_${runId}`; +const oldValue = `mysql-old-${runId}.invalid:3306`; +const newValue = `mysql-new-${runId}.invalid:3306`; +const keys = ["one.yaml", "two.properties"].map(name => ({ namespace: "", group, dataId: `${runId}-${name}` })); +const created = []; +const outputDir = process.env.DBX_TEST_OUTPUT || path.resolve("outputs/nacos-history-verification"); +await mkdir(outputDir, { recursive: true }); +const metadata = JSON.parse(await readFile("node_modules/.vite/deps/_metadata.json", "utf8")); +const dependencyUrl = name => `/@fs/${path.resolve("node_modules/.vite/deps", metadata.optimized[name].file).replaceAll("\\", "/")}?v=${metadata.browserHash}`; + +async function request(endpoint, body) { + const response = await fetch(`${backend}${endpoint}`, body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : undefined); + assert(response.ok, `Backend request ${endpoint} failed (${response.status})`); + return response.json(); +} +function checksum() { + return execFileSync("docker", ["exec", "dbx-nacos-mysql", "sh", "-c", + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --batch --skip-column-names -e "SET SESSION group_concat_max_len=16777216; SELECT COUNT(*), MD5(GROUP_CONCAT(CONCAT(id, CHAR(58), MD5(content)) ORDER BY id)) FROM nacos.config_info;"', + ], { encoding: "utf8" }).trim(); +} +const connections = await request("/api/connection/list"); +const connection = connections.find(item => item.id === connectionId); +assert(connection?.db_type === "nacos"); +const addr = new URL(connection.external_config.serverAddr); +assert(["host.docker.internal", "127.0.0.1", "localhost", "database"].includes(addr.hostname), "Only local Docker Nacos is allowed"); +assert.equal(addr.port, "8848"); +const before = checksum(); +const browser = await chromium.launch({ headless: true }); +try { + for (const key of keys) { + await request("/api/nacos/configs/publish", { connectionId, req: { ...key, content: `url=${oldValue}\nreplica=${oldValue}`, configType: "properties" } }); + created.push(key); + } + const context = await browser.newContext({ locale: "zh-CN", viewport: { width: 1366, height: 900 } }); + const page = await context.newPage(); + const pageErrors = []; + page.on("pageerror", error => { pageErrors.push(error.message); console.error("Browser module error:", error.message); }); + page.on("requestfailed", req => console.error("Browser module request failed:", new URL(req.url()).pathname)); + // Mount the real component in an isolated harness without touching user tabs + // or the normal app's saved connections, layout and editor state. + await page.route(`${frontend}/`, route => route.fulfill({ contentType: "text/html", body: `
` })); + await page.route("**/api/nacos/configs/publish", async route => { + const { req } = route.request().postDataJSON(); + assert(keys.some(key => req.group === key.group && req.dataId === key.dataId && !req.namespace), "Browser tried to publish outside the fixtures"); + assert([`url=${oldValue}\nreplica=${oldValue}`, `url=${newValue}\nreplica=${newValue}`].includes(req.content)); + await route.continue(); + }); + await page.goto(frontend); + await page.waitForFunction(() => window.testReady, null, { timeout: 60000 }); + await page.getByTestId("nacos-replace-search").fill(oldValue); + await page.getByTestId("nacos-replace-value").fill(newValue); + await page.getByTestId("nacos-replace-scope").selectOption("allNamespaces"); + await page.getByTestId("nacos-replace-preview").click(); + await page.getByTestId("nacos-replace-apply").waitFor({ timeout: 60000 }); + await page.getByTestId("nacos-replace-apply").click(); + await page.getByText("成功替换 2 个,冲突 0 个,失败 0 个", { exact: true }).waitFor({ timeout: 60000 }); + for (const key of keys) assert.equal((await request("/api/nacos/configs/get", { connectionId, key })).content, `url=${newValue}\nreplica=${newValue}`); + await page.evaluate(() => window.setTestOpen(false)); + await page.locator('[role="dialog"]').waitFor({ state: "hidden" }); + await page.evaluate(() => window.setTestOpen(true)); + await page.getByTestId("nacos-replace-history-tab").click(); + await page.getByTestId("nacos-replace-history-details").waitFor(); + await page.reload(); + await page.waitForFunction(() => window.testReady); + await page.getByTestId("nacos-replace-history-tab").click(); + await page.getByTestId("nacos-replace-history-details").click(); + await page.screenshot({ path: path.join(outputDir, "history-desktop.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(outputDir, "history-mobile.png") }); + const overflow = await page.locator('[role="dialog"]').evaluate(element => element.scrollWidth > element.clientWidth + 2); + assert.equal(overflow, false, "History dialog has horizontal overflow on mobile"); + await page.setViewportSize({ width: 1366, height: 900 }); + const secureStorage = await page.evaluate(async ({ connectionId, oldValue, newValue }) => { + const storage = await import('/src/lib/nacos/nacosReplaceHistoryStorage.ts'); + const entries = await storage.listNacosReplaceHistory(connectionId); + const db = await new Promise((resolve, reject) => { const req = indexedDB.open('dbx-nacos-replace-history', 1); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); + const get = req => new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); + const rows = await get(db.transaction('entries').objectStore('entries').getAll()); + const key = await get(db.transaction('keys').objectStore('keys').get('encryption-key')); + db.close(); + const raw = JSON.stringify(rows); + const foreign = await storage.getNacosReplaceHistory(entries[0].id, 'other-connection'); + return { count: entries.length, extractable: key.extractable, plaintext: raw.includes(oldValue) || raw.includes(newValue), foreign: !!foreign }; + }, { connectionId, oldValue, newValue }); + assert.deepEqual(secureStorage, { count: 1, extractable: false, plaintext: false, foreign: false }); + await page.getByTestId("nacos-replace-history-rollback").click(); + await page.getByRole("button", { name: "回滚已替换配置", exact: true }).last().waitFor(); + await page.screenshot({ path: path.join(outputDir, "rollback-confirmation.png") }); + assert.equal((await request("/api/nacos/configs/get", { connectionId, key: keys[0] })).content, `url=${newValue}\nreplica=${newValue}`, "Rollback must wait for confirmation"); + await page.getByRole("button", { name: "回滚已替换配置", exact: true }).last().click(); + await page.getByText("回滚结果:已恢复 2 个,冲突 0 个,失败 0 个", { exact: true }).waitFor({ timeout: 60000 }); + for (const key of keys) assert.equal((await request("/api/nacos/configs/get", { connectionId, key })).content, `url=${oldValue}\nreplica=${oldValue}`); + await page.reload(); await page.waitForFunction(() => window.testReady); + await page.getByTestId("nacos-replace-history-tab").click(); + await page.getByText("回滚结果:已恢复 2 个,冲突 0 个,失败 0 个", { exact: true }).waitFor(); + await page.getByTestId("nacos-replace-history-details").click(); + assert.equal(await page.getByTestId("nacos-replace-history-rollback").isDisabled(), true); + assert.deepEqual(pageErrors, []); + console.log("PASS: real API publish, close/reopen, reload, encrypted IndexedDB, confirmed historical rollback, rollback persistence, desktop/mobile overflow"); +} catch (error) { + const page = browser.contexts()[0]?.pages()[0]; + if (page) await page.screenshot({ path: path.join(outputDir, "failure.png") }); + throw error; +} finally { + await browser.close(); + for (const key of created) await request("/api/nacos/configs/delete", { connectionId, key }); + assert.equal(checksum(), before, "Original Nacos configs changed during verification"); + console.log("PASS: only exact test fixtures removed; original config count/checksum unchanged"); +}