feat(nacos): enhance configuration management
This commit is contained in:
52 files changed
+7139
-263
No files matched your search
@@ -1,19 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildNacosContentSearchCsv,
|
||||
buildNacosConfigExportFileName,
|
||||
buildNacosConfigDeleteConfirm,
|
||||
buildNacosInlineDiff,
|
||||
buildNacosInstanceConfirm,
|
||||
buildNacosRawRequest,
|
||||
buildNacosSideBySideDiff,
|
||||
canDeleteNacosConfig,
|
||||
canStartNacosConfigDelete,
|
||||
canStartNacosConfigSave,
|
||||
createNacosConfigDeleteSnapshot,
|
||||
createNacosConfigSaveSnapshot,
|
||||
createNacosLatestRequestGuard,
|
||||
isNacosRawMutation,
|
||||
isNacosErrorCode,
|
||||
isNacosConfigSaveSnapshotCurrent,
|
||||
isNacosConfigDeleteSnapshotInScope,
|
||||
nacosConfigFileExtension,
|
||||
parseNacosRawBody,
|
||||
parseNacosRawQuery,
|
||||
normalizeNacosEndpoint,
|
||||
resolveRNacosOpenApiFallback,
|
||||
resolveNacosConfigCopyText,
|
||||
resolveNacosConfigSaveCompletion,
|
||||
sanitizeNacosConfigFileNameSegment,
|
||||
splitNacosContentLiteralMatches,
|
||||
summarizeNacosConfigDiff,
|
||||
} from "@/lib/nacos/nacosAdmin";
|
||||
|
||||
@@ -49,6 +61,12 @@ describe("nacosAdmin helpers", () => {
|
||||
expect(isNacosRawMutation("DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes structured Nacos errors without matching unrelated failures", () => {
|
||||
expect(isNacosErrorCode(new Error("NACOS_ERROR[stalePreview]: preview again"), "stalePreview")).toBe(true);
|
||||
expect(isNacosErrorCode("NACOS_ERROR[authFailed]: forbidden", "stalePreview")).toBe(false);
|
||||
expect(isNacosErrorCode(new Error("stalePreview"), "stalePreview")).toBe(false);
|
||||
});
|
||||
|
||||
it("redirects r-nacos console settings to the compatible OpenAPI endpoint", () => {
|
||||
expect(resolveRNacosOpenApiFallback("http://rnacos.example:10848", "/rnacos")).toEqual({
|
||||
serverAddr: "http://rnacos.example:8848",
|
||||
@@ -113,4 +131,236 @@ describe("nacosAdmin helpers", () => {
|
||||
expect(resolveNacosConfigCopyText("", "editor", "state")).toBe("editor");
|
||||
expect(resolveNacosConfigCopyText("", "", "state")).toBe("state");
|
||||
});
|
||||
|
||||
it("rejects stale config detail requests after a newer selection or invalidation", () => {
|
||||
const guard = createNacosLatestRequestGuard();
|
||||
const first = guard.begin();
|
||||
const second = guard.begin();
|
||||
|
||||
expect(guard.isCurrent(first)).toBe(false);
|
||||
expect(guard.isCurrent(second)).toBe(true);
|
||||
|
||||
guard.invalidate();
|
||||
expect(guard.isCurrent(second)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a late A detail response from replacing a newer B selection", async () => {
|
||||
const guard = createNacosLatestRequestGuard();
|
||||
let resolveA!: (value: string) => void;
|
||||
let resolveB!: (value: string) => void;
|
||||
const responseA = new Promise<string>((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
const responseB = new Promise<string>((resolve) => {
|
||||
resolveB = resolve;
|
||||
});
|
||||
let selected = "";
|
||||
const load = async (response: Promise<string>) => {
|
||||
const requestId = guard.begin();
|
||||
const detail = await response;
|
||||
if (guard.isCurrent(requestId)) selected = detail;
|
||||
};
|
||||
|
||||
const loadingA = load(responseA);
|
||||
const loadingB = load(responseB);
|
||||
resolveB("B");
|
||||
await loadingB;
|
||||
resolveA("A");
|
||||
await loadingA;
|
||||
|
||||
expect(selected).toBe("B");
|
||||
});
|
||||
|
||||
it("keeps save payloads immutable and applies them only to the unchanged editor session", () => {
|
||||
const editedConfig = {
|
||||
namespace: "dev",
|
||||
dataId: "application.yaml",
|
||||
group: "DEFAULT_GROUP",
|
||||
configType: "yaml",
|
||||
appName: "gateway",
|
||||
desc: "published settings",
|
||||
tags: "prod",
|
||||
};
|
||||
const snapshot = createNacosConfigSaveSnapshot({
|
||||
requestId: 4,
|
||||
editorSessionId: 9,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
|
||||
config: editedConfig,
|
||||
content: "server:\n port: 8080",
|
||||
configType: "yaml",
|
||||
});
|
||||
editedConfig.desc = "changed after publish started";
|
||||
|
||||
expect(snapshot.config.desc).toBe("published settings");
|
||||
expect(snapshot.content).toBe("server:\n port: 8080");
|
||||
expect(
|
||||
isNacosConfigSaveSnapshotCurrent(snapshot, {
|
||||
latestRequestId: 4,
|
||||
editorSessionId: 9,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { ...snapshot.config },
|
||||
content: snapshot.content,
|
||||
configType: snapshot.configType,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply a completed save to another selection or to later edits", () => {
|
||||
const snapshot = createNacosConfigSaveSnapshot({
|
||||
requestId: 1,
|
||||
editorSessionId: 3,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "A" },
|
||||
content: "value: A",
|
||||
configType: "yaml",
|
||||
});
|
||||
const matchingState = {
|
||||
latestRequestId: 1,
|
||||
editorSessionId: 3,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { ...snapshot.config },
|
||||
content: snapshot.content,
|
||||
configType: snapshot.configType,
|
||||
};
|
||||
|
||||
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, editorSessionId: 4, config: { ...snapshot.config, dataId: "b.yaml" } })).toBe(false);
|
||||
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, content: "value: edited while saving" })).toBe(false);
|
||||
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, latestRequestId: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("advances the published baseline without overwriting edits made while saving", () => {
|
||||
const originalContent = "value: O";
|
||||
const publishedContent = "value: S";
|
||||
const laterContent = "value: T";
|
||||
const snapshot = createNacosConfigSaveSnapshot({
|
||||
requestId: 7,
|
||||
editorSessionId: 12,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "snapshot metadata" },
|
||||
content: publishedContent,
|
||||
configType: "yaml",
|
||||
});
|
||||
const completion = resolveNacosConfigSaveCompletion(snapshot, {
|
||||
latestRequestId: 7,
|
||||
editorSessionId: 12,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { ...snapshot.config, desc: "edited metadata" },
|
||||
content: laterContent,
|
||||
configType: "yaml",
|
||||
});
|
||||
|
||||
expect(completion.kind).toBe("saved-with-later-edits");
|
||||
if (completion.kind === "stale") throw new Error("expected a relevant save completion");
|
||||
expect(completion.baseline.content).toBe(publishedContent);
|
||||
expect(laterContent).not.toBe(completion.baseline.content);
|
||||
expect(originalContent).not.toBe(completion.baseline.content);
|
||||
});
|
||||
|
||||
it("keeps a renamed draft separate from the identity published by an in-flight save", () => {
|
||||
const snapshot = createNacosConfigSaveSnapshot({
|
||||
requestId: 8,
|
||||
editorSessionId: 13,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: null,
|
||||
config: { namespace: "dev", dataId: "draft-a.yaml", group: "DEFAULT_GROUP" },
|
||||
content: "value: S",
|
||||
configType: "yaml",
|
||||
});
|
||||
const completion = resolveNacosConfigSaveCompletion(snapshot, {
|
||||
latestRequestId: 8,
|
||||
editorSessionId: 13,
|
||||
connectionId: "nacos-a",
|
||||
originalKey: null,
|
||||
config: { ...snapshot.config, dataId: "draft-b.yaml" },
|
||||
content: "value: T",
|
||||
configType: "yaml",
|
||||
});
|
||||
|
||||
expect(completion).toEqual({ kind: "stale" });
|
||||
});
|
||||
|
||||
it("never allows a draft or read-only config to enter the delete flow", () => {
|
||||
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
|
||||
expect(canDeleteNacosConfig(false, publishedKey)).toBe(true);
|
||||
expect(canDeleteNacosConfig(false, null)).toBe(false);
|
||||
expect(canDeleteNacosConfig(true, publishedKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps save and delete mutations mutually exclusive in both directions", () => {
|
||||
const idle = {
|
||||
readOnly: false,
|
||||
saving: false,
|
||||
deleting: false,
|
||||
hasPendingDelete: false,
|
||||
hasPendingSave: false,
|
||||
};
|
||||
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
|
||||
|
||||
expect(canStartNacosConfigSave(idle)).toBe(true);
|
||||
expect(canStartNacosConfigSave({ ...idle, saving: true })).toBe(false);
|
||||
expect(canStartNacosConfigSave({ ...idle, deleting: true })).toBe(false);
|
||||
expect(canStartNacosConfigSave({ ...idle, hasPendingDelete: true })).toBe(false);
|
||||
expect(canStartNacosConfigDelete(idle, publishedKey)).toBe(true);
|
||||
expect(canStartNacosConfigDelete({ ...idle, saving: true }, publishedKey)).toBe(false);
|
||||
expect(canStartNacosConfigDelete({ ...idle, deleting: true }, publishedKey)).toBe(false);
|
||||
expect(canStartNacosConfigDelete({ ...idle, hasPendingSave: true }, publishedKey)).toBe(false);
|
||||
expect(canStartNacosConfigDelete({ ...idle, hasPendingDelete: true }, publishedKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("freezes delete confirmation scope and never redirects it to a newly selected connection", async () => {
|
||||
const key = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
|
||||
const config = { ...key, desc: "old connection config" };
|
||||
const snapshot = createNacosConfigDeleteSnapshot("old-connection", key, config);
|
||||
key.dataId = "mutated.yaml";
|
||||
config.desc = "mutated after confirmation";
|
||||
const deletedConnections: string[] = [];
|
||||
const executeIfCurrent = async (connectionId: string, namespace: string) => {
|
||||
if (!isNacosConfigDeleteSnapshotInScope(snapshot, connectionId, namespace)) return;
|
||||
deletedConnections.push(snapshot.connectionId);
|
||||
};
|
||||
|
||||
await executeIfCurrent("new-connection", "dev");
|
||||
expect(deletedConnections).toEqual([]);
|
||||
expect(snapshot).toMatchObject({
|
||||
connectionId: "old-connection",
|
||||
key: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
|
||||
config: { desc: "old connection config" },
|
||||
});
|
||||
});
|
||||
|
||||
it("splits every case-sensitive literal content match for safe highlighting", () => {
|
||||
expect(splitNacosContentLiteralMatches("url=/deploy/deploy?mode=Deploy", "deploy")).toEqual([
|
||||
{ text: "url=/", matched: false },
|
||||
{ text: "deploy", matched: true },
|
||||
{ text: "/", matched: false },
|
||||
{ text: "deploy", matched: true },
|
||||
{ text: "?mode=Deploy", matched: false },
|
||||
]);
|
||||
expect(splitNacosContentLiteralMatches("<script>alert(1)</script>", "alert")).toEqual([
|
||||
{ text: "<script>", matched: false },
|
||||
{ text: "alert", matched: true },
|
||||
{ text: "(1)</script>", matched: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports content search matches as UTF-8 CSV and neutralizes spreadsheet formulas", () => {
|
||||
const csv = buildNacosContentSearchCsv([
|
||||
{
|
||||
namespace: "",
|
||||
group: "dev",
|
||||
dataId: '=IMPORTXML("https://example.test")',
|
||||
lineNumber: 12,
|
||||
snippet: 'url: "jdbc:mysql://db/a,b"\nuser: root',
|
||||
},
|
||||
]);
|
||||
expect(csv.startsWith("\uFEFF")).toBe(true);
|
||||
expect(csv).toContain('"public","dev","\'=IMPORTXML(""https://example.test"")","12"');
|
||||
expect(csv).toContain('"url: ""jdbc:mysql://db/a,b""\nuser: root"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNacosNamespaceRequestGuard, NACOS_NAMESPACES_CHANGED_EVENT, notifyNacosNamespacesChanged, subscribeNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Nacos namespace request guard", () => {
|
||||
it("prevents an older response from replacing a newer namespace list", async () => {
|
||||
const guard = createNacosNamespaceRequestGuard();
|
||||
const committedLists: string[][] = [];
|
||||
let resolveOlder!: (value: string[]) => void;
|
||||
let resolveNewer!: (value: string[]) => void;
|
||||
const olderResponse = new Promise<string[]>((resolve) => {
|
||||
resolveOlder = resolve;
|
||||
});
|
||||
const newerResponse = new Promise<string[]>((resolve) => {
|
||||
resolveNewer = resolve;
|
||||
});
|
||||
const olderRequest = guard.start("conn-1");
|
||||
const commitOlder = olderResponse.then((namespaces) => {
|
||||
if (guard.isCurrent(olderRequest, "conn-1")) committedLists.push(namespaces);
|
||||
});
|
||||
const newerRequest = guard.start("conn-1");
|
||||
const commitNewer = newerResponse.then((namespaces) => {
|
||||
if (guard.isCurrent(newerRequest, "conn-1")) committedLists.push(namespaces);
|
||||
});
|
||||
|
||||
resolveNewer(["public", "new-space"]);
|
||||
await commitNewer;
|
||||
resolveOlder(["public"]);
|
||||
await commitOlder;
|
||||
|
||||
expect(committedLists).toEqual([["public", "new-space"]]);
|
||||
});
|
||||
|
||||
it("rejects pending responses after invalidation or a connection change", () => {
|
||||
const guard = createNacosNamespaceRequestGuard();
|
||||
const request = guard.start("conn-1");
|
||||
|
||||
expect(guard.isCurrent(request, "conn-2")).toBe(false);
|
||||
|
||||
guard.invalidate();
|
||||
|
||||
expect(guard.isCurrent(request, "conn-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nacos namespace change events", () => {
|
||||
it("notifies open views for the affected connection and supports unsubscribe", () => {
|
||||
const listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: (type: string, listener: (event: Event) => void) => {
|
||||
const handlers = listeners.get(type) ?? new Set();
|
||||
handlers.add(listener);
|
||||
listeners.set(type, handlers);
|
||||
},
|
||||
removeEventListener: (type: string, listener: (event: Event) => void) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.get(event.type)?.forEach((listener) => listener(event));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeNacosNamespacesChanged(listener);
|
||||
|
||||
notifyNacosNamespacesChanged("conn-1");
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith({ connectionId: "conn-1" });
|
||||
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
notifyNacosNamespacesChanged("conn-1");
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -330,6 +330,13 @@ export const nacosListConfigs = forward("nacosListConfigs");
|
||||
export const nacosGetConfig = forward("nacosGetConfig");
|
||||
export const nacosPublishConfig = forward("nacosPublishConfig");
|
||||
export const nacosDeleteConfig = forward("nacosDeleteConfig");
|
||||
export const nacosSearchConfigContent = forward("nacosSearchConfigContent");
|
||||
export const nacosCancelConfigContentSearch = forward("nacosCancelConfigContentSearch");
|
||||
export const nacosExportConfigs = forward("nacosExportConfigs");
|
||||
export const nacosPreviewConfigImport = forward("nacosPreviewConfigImport");
|
||||
export const nacosApplyConfigImport = forward("nacosApplyConfigImport");
|
||||
export const nacosPreviewConfigTransfer = forward("nacosPreviewConfigTransfer");
|
||||
export const nacosApplyConfigTransfer = forward("nacosApplyConfigTransfer");
|
||||
export const nacosListConfigHistory = forward("nacosListConfigHistory");
|
||||
export const nacosGetConfigHistory = forward("nacosGetConfigHistory");
|
||||
export const nacosRollbackConfig = forward("nacosRollbackConfig");
|
||||
|
||||
@@ -144,6 +144,13 @@ import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, Da
|
||||
import { apiUrl, apiWebSocketUrl } from "@/lib/common/webPath";
|
||||
import type { DataGridSavePreparation } from "@/lib/backend/tauri";
|
||||
import type {
|
||||
NacosBatchPreview,
|
||||
NacosBatchReport,
|
||||
NacosConfigSelector,
|
||||
NacosConfigTransferRequest,
|
||||
NacosConflictPolicy,
|
||||
NacosContentSearchRequest,
|
||||
NacosContentSearchResult,
|
||||
NacosConfigHistoryKey,
|
||||
NacosConfigHistoryList,
|
||||
NacosConfigHistoryQuery,
|
||||
@@ -165,6 +172,7 @@ import type {
|
||||
NacosRawResponse,
|
||||
NacosServiceList,
|
||||
NacosServiceQuery,
|
||||
NacosSearchProgress,
|
||||
} from "@/types/nacos";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
@@ -2111,6 +2119,93 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
|
||||
return post("/api/nacos/configs/delete", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
|
||||
const response = await fetch(apiUrl("/api/nacos/configs/search"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, req }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.body) throw new Error("Nacos content search did not return a response stream");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result: NacosContentSearchResult | null = null;
|
||||
|
||||
const consumeLine = (line: string) => {
|
||||
if (!line.startsWith("data:")) return;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data) return;
|
||||
const event = JSON.parse(data) as { type: "progress"; progress: NacosSearchProgress } | { type: "result"; result: NacosContentSearchResult } | { type: "error"; error: string };
|
||||
if (event.type === "progress") onProgress?.(event.progress);
|
||||
else if (event.type === "result") result = event.result;
|
||||
else throw new Error(event.error);
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) consumeLine(line);
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer) consumeLine(buffer);
|
||||
if (!result) throw new Error("Nacos content search stream ended without a final result");
|
||||
return result;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
|
||||
const result = await post<{ cancelled: boolean }>("/api/nacos/configs/search/cancel", { operationId });
|
||||
return result.cancelled;
|
||||
}
|
||||
|
||||
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, _destination: string, fileName = "nacos-configs.zip"): Promise<void> {
|
||||
const response = await fetch(apiUrl("/api/nacos/configs/export"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, selector, fileName }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
|
||||
if (!(archivePath instanceof File)) throw new Error("Nacos ZIP import in web mode requires a File object");
|
||||
const formData = new FormData();
|
||||
formData.append("connectionId", connectionId);
|
||||
formData.append("targetNamespace", targetNamespace);
|
||||
formData.append("file", archivePath, archivePath.name);
|
||||
const response = await fetch(apiUrl("/api/nacos/configs/import/preview"), { method: "POST", body: formData });
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, _archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, archiveToken?: string): Promise<NacosBatchReport> {
|
||||
if (!archiveToken) throw new Error("The Nacos import preview token is missing or expired");
|
||||
return post("/api/nacos/configs/import/apply", { connectionId, operationId, targetNamespace, archiveToken, planHash, conflictPolicy });
|
||||
}
|
||||
|
||||
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
|
||||
return post("/api/nacos/configs/copy/preview", { req });
|
||||
}
|
||||
|
||||
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
|
||||
return post("/api/nacos/configs/copy/apply", { req, planHash });
|
||||
}
|
||||
|
||||
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
|
||||
return post("/api/nacos/configs/history/list", { connectionId, query });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
NacosBatchPreview,
|
||||
NacosBatchReport,
|
||||
NacosConfigSelector,
|
||||
NacosConfigTransferRequest,
|
||||
NacosConflictPolicy,
|
||||
NacosContentSearchRequest,
|
||||
NacosContentSearchResult,
|
||||
NacosConfigHistoryKey,
|
||||
NacosConfigHistoryList,
|
||||
NacosConfigHistoryQuery,
|
||||
@@ -21,6 +28,7 @@ import type {
|
||||
NacosRawResponse,
|
||||
NacosServiceList,
|
||||
NacosServiceQuery,
|
||||
NacosSearchProgress,
|
||||
} from "@/types/nacos";
|
||||
|
||||
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
|
||||
@@ -55,6 +63,38 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
|
||||
return invoke("nacos_delete_config", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
|
||||
const channel = new Channel<NacosSearchProgress>();
|
||||
channel.onmessage = (progress) => onProgress?.(progress);
|
||||
return invoke("nacos_search_config_content", { connectionId, req, onProgress: channel });
|
||||
}
|
||||
|
||||
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
|
||||
return invoke("nacos_cancel_operation", { operationId });
|
||||
}
|
||||
|
||||
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, destination: string, _fileName?: string): Promise<void> {
|
||||
return invoke("nacos_export_configs", { connectionId, selector, destination });
|
||||
}
|
||||
|
||||
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
|
||||
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
|
||||
return invoke("nacos_preview_config_import", { connectionId, targetNamespace, archivePath });
|
||||
}
|
||||
|
||||
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, _archiveToken?: string): Promise<NacosBatchReport> {
|
||||
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
|
||||
return invoke("nacos_apply_config_import", { connectionId, operationId, targetNamespace, archivePath, planHash, conflictPolicy });
|
||||
}
|
||||
|
||||
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
|
||||
return invoke("nacos_preview_config_transfer", { req });
|
||||
}
|
||||
|
||||
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
|
||||
return invoke("nacos_apply_config_transfer", { req, planHash });
|
||||
}
|
||||
|
||||
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
|
||||
return invoke("nacos_list_config_history", { connectionId, query });
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && CLEARABLE_QUERY_SCHEMA_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos";
|
||||
}
|
||||
|
||||
export function usesFetchFirst(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && FETCH_FIRST_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NacosConfigHistoryItem, NacosConfigItem, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
|
||||
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
|
||||
import { diffChars, diffLines } from "diff";
|
||||
|
||||
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
|
||||
@@ -163,6 +163,11 @@ export function isNacosRawMutation(method: string): boolean {
|
||||
return method.trim().toUpperCase() !== "GET";
|
||||
}
|
||||
|
||||
export function isNacosErrorCode(error: unknown, code: string): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes(`NACOS_ERROR[${code}]`);
|
||||
}
|
||||
|
||||
export function formatNacosConfigIdentity(item: Pick<NacosConfigItem, "namespace" | "dataId" | "group">, fallbackNamespace = ""): string {
|
||||
return [`namespace=${item.namespace || fallbackNamespace || "public"}`, `dataId=${item.dataId}`, `group=${item.group || "DEFAULT_GROUP"}`].join("\n");
|
||||
}
|
||||
@@ -179,6 +184,232 @@ export function resolveNacosConfigCopyText(selectionText: string, editorText: st
|
||||
return selectionText || editorText || stateText;
|
||||
}
|
||||
|
||||
export interface NacosLatestRequestGuard {
|
||||
begin(): number;
|
||||
invalidate(): void;
|
||||
isCurrent(requestId: number): boolean;
|
||||
}
|
||||
|
||||
export function createNacosLatestRequestGuard(): NacosLatestRequestGuard {
|
||||
let sequence = 0;
|
||||
return {
|
||||
begin() {
|
||||
sequence += 1;
|
||||
return sequence;
|
||||
},
|
||||
invalidate() {
|
||||
sequence += 1;
|
||||
},
|
||||
isCurrent(requestId) {
|
||||
return requestId === sequence;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface NacosConfigSaveSnapshot {
|
||||
requestId: number;
|
||||
editorSessionId: number;
|
||||
connectionId: string;
|
||||
wasCreating: boolean;
|
||||
originalKey: NacosConfigKey | null;
|
||||
targetKey: NacosConfigKey;
|
||||
config: NacosConfigItem;
|
||||
content: string;
|
||||
configType: string;
|
||||
}
|
||||
|
||||
export interface NacosConfigSaveSnapshotInput {
|
||||
requestId: number;
|
||||
editorSessionId: number;
|
||||
connectionId: string;
|
||||
fallbackNamespace?: string;
|
||||
originalKey: NacosConfigKey | null;
|
||||
config: NacosConfigItem;
|
||||
content: string;
|
||||
configType: string;
|
||||
}
|
||||
|
||||
export function createNacosConfigSaveSnapshot(input: NacosConfigSaveSnapshotInput): NacosConfigSaveSnapshot {
|
||||
const dataId = input.config.dataId.trim();
|
||||
const group = input.config.group.trim() || "DEFAULT_GROUP";
|
||||
const namespace = input.originalKey?.namespace || input.config.namespace || input.fallbackNamespace || undefined;
|
||||
const configType = input.configType || "text";
|
||||
const config: NacosConfigItem = {
|
||||
...input.config,
|
||||
namespace: namespace || "",
|
||||
dataId,
|
||||
group,
|
||||
content: input.content,
|
||||
configType,
|
||||
};
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
editorSessionId: input.editorSessionId,
|
||||
connectionId: input.connectionId,
|
||||
wasCreating: !input.originalKey,
|
||||
originalKey: input.originalKey ? { ...input.originalKey } : null,
|
||||
targetKey: { namespace, dataId, group },
|
||||
config,
|
||||
content: input.content,
|
||||
configType,
|
||||
};
|
||||
}
|
||||
|
||||
export interface NacosConfigEditorComparableState {
|
||||
latestRequestId: number;
|
||||
editorSessionId: number;
|
||||
connectionId: string;
|
||||
originalKey: NacosConfigKey | null;
|
||||
config: NacosConfigItem | null;
|
||||
content: string;
|
||||
configType: string;
|
||||
}
|
||||
|
||||
function sameNacosConfigKey(left: NacosConfigKey | null, right: NacosConfigKey | null): boolean {
|
||||
if (!left || !right) return left === right;
|
||||
return (left.namespace || "") === (right.namespace || "") && left.dataId === right.dataId && (left.group || "DEFAULT_GROUP") === (right.group || "DEFAULT_GROUP");
|
||||
}
|
||||
|
||||
export function isNacosConfigSaveSnapshotSameEditor(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
|
||||
const config = current.config;
|
||||
return (
|
||||
snapshot.requestId === current.latestRequestId &&
|
||||
snapshot.editorSessionId === current.editorSessionId &&
|
||||
snapshot.connectionId === current.connectionId &&
|
||||
sameNacosConfigKey(snapshot.originalKey, current.originalKey) &&
|
||||
!!config &&
|
||||
snapshot.config.dataId === config.dataId.trim() &&
|
||||
snapshot.config.group === (config.group.trim() || "DEFAULT_GROUP") &&
|
||||
(snapshot.config.namespace || "") === (config.namespace || snapshot.targetKey.namespace || "")
|
||||
);
|
||||
}
|
||||
|
||||
export function isNacosConfigSaveSnapshotCurrent(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
|
||||
const config = current.config;
|
||||
return (
|
||||
isNacosConfigSaveSnapshotSameEditor(snapshot, current) &&
|
||||
!!config &&
|
||||
snapshot.content === current.content &&
|
||||
snapshot.configType === current.configType &&
|
||||
(snapshot.config.appName || "") === (config.appName || "") &&
|
||||
(snapshot.config.desc || "") === (config.desc || "") &&
|
||||
(snapshot.config.tags || "") === (config.tags || "")
|
||||
);
|
||||
}
|
||||
|
||||
export type NacosConfigSaveCompletion =
|
||||
| { kind: "stale" }
|
||||
| {
|
||||
kind: "saved" | "saved-with-later-edits";
|
||||
originalKey: NacosConfigKey;
|
||||
savedConfig: NacosConfigItem;
|
||||
baseline: {
|
||||
content: string;
|
||||
configType: string;
|
||||
appName: string;
|
||||
desc: string;
|
||||
tags: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveNacosConfigSaveCompletion(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): NacosConfigSaveCompletion {
|
||||
if (!isNacosConfigSaveSnapshotSameEditor(snapshot, current)) return { kind: "stale" };
|
||||
return {
|
||||
kind: isNacosConfigSaveSnapshotCurrent(snapshot, current) ? "saved" : "saved-with-later-edits",
|
||||
originalKey: { ...snapshot.targetKey },
|
||||
savedConfig: { ...snapshot.config },
|
||||
baseline: {
|
||||
content: snapshot.content,
|
||||
configType: snapshot.configType,
|
||||
appName: snapshot.config.appName || "",
|
||||
desc: snapshot.config.desc || "",
|
||||
tags: snapshot.config.tags || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canDeleteNacosConfig(readOnly: boolean, originalKey: NacosConfigKey | null): boolean {
|
||||
return !readOnly && !!originalKey;
|
||||
}
|
||||
|
||||
export interface NacosConfigMutationGuardState {
|
||||
readOnly: boolean;
|
||||
saving: boolean;
|
||||
deleting: boolean;
|
||||
hasPendingDelete: boolean;
|
||||
hasPendingSave: boolean;
|
||||
}
|
||||
|
||||
export function canStartNacosConfigSave(state: NacosConfigMutationGuardState): boolean {
|
||||
return !state.readOnly && !state.saving && !state.deleting && !state.hasPendingDelete;
|
||||
}
|
||||
|
||||
export function canStartNacosConfigDelete(state: NacosConfigMutationGuardState, originalKey: NacosConfigKey | null): boolean {
|
||||
return canDeleteNacosConfig(state.readOnly, originalKey) && !state.saving && !state.deleting && !state.hasPendingSave && !state.hasPendingDelete;
|
||||
}
|
||||
|
||||
export interface NacosConfigDeleteSnapshot {
|
||||
connectionId: string;
|
||||
key: NacosConfigKey;
|
||||
config: NacosConfigItem;
|
||||
}
|
||||
|
||||
export function createNacosConfigDeleteSnapshot(connectionId: string, key: NacosConfigKey, config: NacosConfigItem): NacosConfigDeleteSnapshot {
|
||||
return {
|
||||
connectionId,
|
||||
key: {
|
||||
namespace: key.namespace || undefined,
|
||||
dataId: key.dataId,
|
||||
group: key.group || "DEFAULT_GROUP",
|
||||
},
|
||||
config: {
|
||||
...config,
|
||||
namespace: key.namespace || "",
|
||||
dataId: key.dataId,
|
||||
group: key.group || "DEFAULT_GROUP",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isNacosConfigDeleteSnapshotInScope(snapshot: NacosConfigDeleteSnapshot, connectionId: string, namespace?: string): boolean {
|
||||
return snapshot.connectionId === connectionId && (snapshot.key.namespace || "") === (namespace || "");
|
||||
}
|
||||
|
||||
export interface NacosLiteralMatchSegment {
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export function splitNacosContentLiteralMatches(text: string, query: string): NacosLiteralMatchSegment[] {
|
||||
if (!text || !query) return text ? [{ text, matched: false }] : [];
|
||||
const segments: NacosLiteralMatchSegment[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const matchIndex = text.indexOf(query, cursor);
|
||||
if (matchIndex < 0) {
|
||||
segments.push({ text: text.slice(cursor), matched: false });
|
||||
break;
|
||||
}
|
||||
if (matchIndex > cursor) {
|
||||
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
|
||||
}
|
||||
segments.push({ text: text.slice(matchIndex, matchIndex + query.length), matched: true });
|
||||
cursor = matchIndex + query.length;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function nacosSearchCsvCell(value: string | number): string {
|
||||
let text = String(value);
|
||||
if (/^[\t\r ]*[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function buildNacosContentSearchCsv(matches: readonly NacosContentMatch[]): string {
|
||||
const rows: Array<Array<string | number>> = [["namespace", "group", "dataId", "lineNumber", "snippet"], ...matches.map((match) => [match.namespace || "public", match.group || "DEFAULT_GROUP", match.dataId, match.lineNumber, match.snippet])];
|
||||
return `\uFEFF${rows.map((row) => row.map(nacosSearchCsvCell).join(",")).join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
export function sanitizeNacosConfigFileNameSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export const NACOS_NAMESPACES_CHANGED_EVENT = "dbx:nacos-namespaces-changed";
|
||||
|
||||
export interface NacosNamespacesChangedDetail {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export interface NacosNamespaceRequestGuard {
|
||||
invalidate: () => void;
|
||||
start: (connectionId: string) => number;
|
||||
isCurrent: (requestId: number, connectionId: string) => boolean;
|
||||
}
|
||||
|
||||
export function createNacosNamespaceRequestGuard(): NacosNamespaceRequestGuard {
|
||||
let latestRequestId = 0;
|
||||
let latestConnectionId = "";
|
||||
|
||||
return {
|
||||
invalidate: () => {
|
||||
latestRequestId++;
|
||||
latestConnectionId = "";
|
||||
},
|
||||
start: (connectionId) => {
|
||||
latestConnectionId = connectionId;
|
||||
return ++latestRequestId;
|
||||
},
|
||||
isCurrent: (requestId, connectionId) => requestId === latestRequestId && connectionId === latestConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyNacosNamespacesChanged(connectionId: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<NacosNamespacesChangedDetail>(NACOS_NAMESPACES_CHANGED_EVENT, { detail: { connectionId } }));
|
||||
}
|
||||
|
||||
export function subscribeNacosNamespacesChanged(listener: (detail: NacosNamespacesChangedDetail) => void): () => void {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const handleEvent = (event: Event) => listener((event as CustomEvent<NacosNamespacesChangedDetail>).detail);
|
||||
window.addEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
|
||||
return () => window.removeEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
|
||||
}
|
||||
Reference in new issue
Block a user