🐛 fix(nacos-connection): 支持受限命名空间与权限保护

- 在连接表单中独立维护 Namespace ID 与 contextPath
- 为无命名空间列表权限的账号回退到已配置作用域
- 细分数据编辑、结构管理与导入保护并阻止陈旧操作
- 补齐侧边栏请求代际和连接 URI 回归测试
This commit is contained in:
Syngnat
2026-07-28 23:51:36 +08:00
parent e5e80cbbe8
commit 12e410aead
18 changed files with 1635 additions and 122 deletions

View File

@@ -1626,4 +1626,89 @@ describe("ConnectionModal i18n", () => {
pageText = textContent(renderer!.toJSON());
expect(pageText).toContain("Svc");
});
it("renders and restores the Nacos scoped namespace without requiring a username", async () => {
setCurrentLanguage("en-US");
const { default: ConnectionModal } = await import("./ConnectionModal");
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<ConnectionModal
open
onClose={vi.fn()}
initialValues={initialConnection("nacos", {
port: 8848,
user: "",
connectionParams:
"contextPath=%2Fnacos&namespaceId=public&custom=value",
})}
/>,
);
});
const pageText = textContent(renderer!.toJSON());
expect(pageText).toContain("Namespace ID");
expect(pageText).toContain(
"Administrators can leave this empty to discover all namespaces.",
);
expect(mockFormValues.nacosNamespaceId).toBe("public");
expect(new URLSearchParams(mockFormValues.connectionParams)).toEqual(
new URLSearchParams("contextPath=%2Fnacos&custom=value"),
);
expect(
findInputByPlaceholder(
renderer!,
"Leave empty when authentication is disabled",
),
).toBeDefined();
});
it("restores a legacy Nacos namespace stored only in the connection URI", async () => {
setCurrentLanguage("en-US");
const { default: ConnectionModal } = await import("./ConnectionModal");
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<ConnectionModal
open
onClose={vi.fn()}
initialValues={initialConnection("nacos", {
port: 8848,
connectionParams: "",
uri: "https://nacos.example.test:8848/registry?namespaceId=dev-team&custom=value",
})}
/>,
);
});
expect(mockFormValues.nacosNamespaceId).toBe("dev-team");
const params = new URLSearchParams(mockFormValues.connectionParams);
expect(params.get("contextPath")).toBe("/registry");
expect(params.get("custom")).toBe("value");
expect(params.has("namespaceId")).toBe(false);
});
it("starts a new Nacos connection with optional authentication and no namespace scope", async () => {
setCurrentLanguage("en-US");
const { default: ConnectionModal } = await import("./ConnectionModal");
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<ConnectionModal open onClose={vi.fn()} />);
});
await act(async () => {
findClickableCard(renderer!, "Nacos").props.onClick();
});
expect(mockFormValues.user).toBe("");
expect(mockFormValues.nacosNamespaceId).toBe("");
expect(
findInputByPlaceholder(
renderer!,
"Leave empty when authentication is disabled",
),
).toBeDefined();
});
});

View File

@@ -125,6 +125,7 @@ import {
applyNoAutoCapAttributes,
noAutoCapInputProps,
} from "../utils/inputAutoCap";
import { extractNacosConnectionScope } from "../utils/nacosConnectionScope";
import {
buildDefaultJVMConnectionValues,
hasUnsupportedJVMEditableModes,
@@ -158,6 +159,7 @@ type ChoiceCardOption = {
const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240;
const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([
"redis",
"mongodb",
"elasticsearch",
"chroma",
@@ -1487,6 +1489,27 @@ const ConnectionModal: React.FC<{
const hasHttpTunnel = !!config.useHttpTunnel;
const hasProxy = !hasHttpTunnel && !!config.useProxy;
const protection = resolveConnectionProtectionConfig(config);
const parsedInitialUri = config.uri
? parseUriToValues(config.uri, configType)
: null;
const hasStoredConnectionParams =
String(config.connectionParams || "").trim() !== "";
const initialConnectionParams =
(hasStoredConnectionParams ? config.connectionParams : "") ||
parsedInitialUri?.connectionParams ||
"";
const nacosConnectionScope =
configType === "nacos"
? extractNacosConnectionScope(initialConnectionParams)
: null;
const initialNacosNamespaceId =
configType === "nacos" && !hasStoredConnectionParams
? String(
parsedInitialUri?.nacosNamespaceId ||
nacosConnectionScope?.scope.namespaceId ||
"",
).trim()
: nacosConnectionScope?.scope.namespaceId || "";
form.setFieldsValue({
type: configType,
name: initialValues.name,
@@ -1505,10 +1528,8 @@ const ConnectionModal: React.FC<{
restrictDataImport: protection.restrictDataImport === true,
uri: config.uri || "",
connectionParams:
config.connectionParams ||
(config.uri
? parseUriToValues(config.uri, configType)?.connectionParams || ""
: ""),
nacosConnectionScope?.connectionParams ?? initialConnectionParams,
nacosNamespaceId: initialNacosNamespaceId,
clickHouseProtocol:
configType === "clickhouse"
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
@@ -1752,6 +1773,8 @@ const ConnectionModal: React.FC<{
values,
forPersist: true,
initialValues,
nacosNamespaceIdTouched:
form.isFieldTouched?.("nacosNamespaceId") === true,
translate: t,
});
const payload = buildSavedConnectionInput({
@@ -1904,6 +1927,8 @@ const ConnectionModal: React.FC<{
values,
forPersist: false,
initialValues,
nacosNamespaceIdTouched:
form.isFieldTouched?.("nacosNamespaceId") === true,
translate: t,
});
if (!isCurrentTestRun()) return;
@@ -2304,13 +2329,18 @@ const ConnectionModal: React.FC<{
});
} else if (type !== "custom") {
const defaultUser =
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "milvus" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";
type === "clickhouse"
? "default"
: PRIMARY_USERNAME_OPTIONAL_TYPES.has(type)
? ""
: "root";
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
form.setFieldsValue({
user: defaultUser,
database: "",
nacosNamespaceId: "",
port: defaultPort,
useSSL: sslCapableType ? false : undefined,
sslMode: sslCapableType ? "preferred" : undefined,

View File

@@ -199,6 +199,7 @@ import {
resolveV2ConnectionGroup,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
resolveNacosNamespaceDiscoveryModeFromTreeNode,
resolveNacosServicesDoubleClickAction,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
@@ -1074,12 +1075,18 @@ const Sidebar: React.FC<{
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
const nacosNamespaceDiscoveryMode =
preserveChildren && conn.config.type === 'nacos'
? resolveNacosNamespaceDiscoveryModeFromTreeNode(existing)
: undefined;
return {
title: conn.name,
key: conn.id,
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
dataRef: conn,
dataRef: nacosNamespaceDiscoveryMode
? { ...conn, nacosNamespaceDiscoveryMode }
: conn,
isLeaf: false,
children: preserveChildren ? existing.children : undefined,
} as TreeNode;
@@ -2843,6 +2850,10 @@ const Sidebar: React.FC<{
deleteSavedQueryGroup,
moveSavedQueryToGroup,
treeDataRef,
getNacosNamespaceDiscoveryMode: (connectionId: string) =>
resolveNacosNamespaceDiscoveryModeFromTreeNode(
findTreeNodeByKeyRef.current(treeDataRef.current, connectionId),
),
setTreeData,
handleAddExternalSQLDirectory,
openCreateExternalSQLFileModal,

View File

@@ -89,6 +89,7 @@ const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([
"mqtt",
"kafka",
"rabbitmq",
"nacos",
]);
// URI 操作反馈统一保留 4 秒,便于用户读取后自动回收空间。
@@ -229,10 +230,13 @@ const ConnectionModalStep2: React.FC<ConnectionModalStep2Props> = (props) => {
Form.useWatch("restrictScriptExecution", form) === true;
const restrictDataImport =
Form.useWatch("restrictDataImport", form) === true;
const isNacosProtection =
String(dbType || "").trim().toLowerCase() === "nacos";
const supportsScriptExecutionProtection = !isNacosProtection;
const connectionProtectionEnabledCount = [
restrictDataEdit,
restrictStructureEdit,
restrictScriptExecution,
supportsScriptExecutionProtection && restrictScriptExecution,
restrictDataImport,
].filter(Boolean).length;
@@ -1184,6 +1188,31 @@ const ConnectionModalStep2: React.FC<ConnectionModalStep2Props> = (props) => {
</div>
</div>
{dbType === "nacos" && (
<div className="gn-conn-f-row" data-align="start">
{denseLabel(
t("connection.modal.field.nacosNamespaceId.label"),
)}
<div className="gn-conn-f-ctrl">
<Form.Item
name="nacosNamespaceId"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
maxLength={256}
placeholder={t(
"connection.modal.field.nacosNamespaceId.placeholder",
)}
/>
</Form.Item>
<div className="gn-conn-mode-hint">
{t("connection.modal.field.nacosNamespaceId.help")}
</div>
</div>
</div>
)}
{dbType === "clickhouse" && (
<div className="gn-conn-f-row">
{denseLabel(
@@ -2354,7 +2383,9 @@ const ConnectionModalStep2: React.FC<ConnectionModalStep2Props> = (props) => {
"connection.modal.field.readOnly.option.dataEdit.label",
),
help: t(
"connection.modal.field.readOnly.option.dataEdit.help",
isNacosProtection
? "connection.modal.field.readOnly.option.nacos.dataEdit.help"
: "connection.modal.field.readOnly.option.dataEdit.help",
),
},
{
@@ -2364,19 +2395,25 @@ const ConnectionModalStep2: React.FC<ConnectionModalStep2Props> = (props) => {
"connection.modal.field.readOnly.option.structureEdit.label",
),
help: t(
"connection.modal.field.readOnly.option.structureEdit.help",
),
},
{
field: "restrictScriptExecution",
checked: restrictScriptExecution,
label: t(
"connection.modal.field.readOnly.option.scriptExecution.label",
),
help: t(
"connection.modal.field.readOnly.option.scriptExecution.help",
isNacosProtection
? "connection.modal.field.readOnly.option.nacos.structureEdit.help"
: "connection.modal.field.readOnly.option.structureEdit.help",
),
},
...(supportsScriptExecutionProtection
? [
{
field: "restrictScriptExecution",
checked: restrictScriptExecution,
label: t(
"connection.modal.field.readOnly.option.scriptExecution.label",
),
help: t(
"connection.modal.field.readOnly.option.scriptExecution.help",
),
},
]
: []),
{
field: "restrictDataImport",
checked: restrictDataImport,
@@ -2384,7 +2421,9 @@ const ConnectionModalStep2: React.FC<ConnectionModalStep2Props> = (props) => {
"connection.modal.field.readOnly.option.dataImport.label",
),
help: t(
"connection.modal.field.readOnly.option.dataImport.help",
isNacosProtection
? "connection.modal.field.readOnly.option.nacos.dataImport.help"
: "connection.modal.field.readOnly.option.dataImport.help",
),
},
].map((item) => (

View File

@@ -168,10 +168,27 @@ describe("connectionModalConfig keepalive", () => {
forPersist: true,
translate,
});
const nacosConfig = await buildConnectionConfig({
values: {
...buildBaseValues(),
type: "nacos",
port: 8848,
...protection,
},
forPersist: true,
translate,
});
expect(sqlConfig.protection).toEqual(protection);
expect(sqlConfig.readOnly).toBe(true);
expect(redisConfig.protection).toBeUndefined();
expect(redisConfig.readOnly).toBe(false);
expect(nacosConfig.protection).toEqual({
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: false,
restrictDataImport: true,
});
expect(nacosConfig.readOnly).toBe(false);
});
});

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { buildConnectionConfig } from "./connectionModalConfig";
const translate = (key: string) => key;
const buildNacosValues = (overrides: Record<string, unknown> = {}) => ({
type: "nacos",
host: "nacos.example.test",
port: 8848,
user: "",
password: "",
database: "",
useSSL: false,
useSSH: false,
useProxy: false,
useHttpTunnel: false,
timeout: 30,
keepAliveEnabled: false,
keepAliveIntervalMinutes: 240,
keepAliveSQL: "",
savePassword: true,
uri: "",
connectionParams: "contextPath=/nacos",
sslMode: "preferred",
sslCAPath: "",
sslCertPath: "",
sslKeyPath: "",
sshHost: "",
sshPort: 22,
sshUser: "",
sshPassword: "",
sshKeyPath: "",
proxyType: "socks5",
proxyHost: "",
proxyPort: 1080,
proxyUser: "",
proxyPassword: "",
httpTunnelHost: "",
httpTunnelPort: 8080,
httpTunnelUser: "",
httpTunnelPassword: "",
...overrides,
});
describe("connectionModalConfig Nacos scope", () => {
it("persists the dedicated namespace field in connectionParams", async () => {
const config = await buildConnectionConfig({
values: buildNacosValues({
nacosNamespaceId: " dev-team ",
connectionParams: "contextPath=/registry&custom=value",
}),
forPersist: true,
translate,
});
const params = new URLSearchParams(config.connectionParams);
expect(params.get("contextPath")).toBe("/registry");
expect(params.get("custom")).toBe("value");
expect(params.get("namespaceId")).toBe("dev-team");
});
it("preserves an explicit public scope and removes a cleared scope", async () => {
const publicConfig = await buildConnectionConfig({
values: buildNacosValues({ nacosNamespaceId: "public" }),
forPersist: true,
translate,
});
expect(
new URLSearchParams(publicConfig.connectionParams).get("namespaceId"),
).toBe("public");
const clearedConfig = await buildConnectionConfig({
values: buildNacosValues({
nacosNamespaceId: "",
connectionParams: "contextPath=/nacos&namespaceId=old-scope",
uri: "http://nacos.example.test:8848/nacos?namespaceId=old-scope",
}),
nacosNamespaceIdTouched: true,
forPersist: true,
translate,
});
expect(
new URLSearchParams(clearedConfig.connectionParams).has("namespaceId"),
).toBe(false);
});
it("persists the namespace and context path parsed from a Nacos URI", async () => {
const config = await buildConnectionConfig({
values: buildNacosValues({
host: "",
nacosNamespaceId: "",
connectionParams: "",
uri: "http://nacos.example.test:8848/registry?namespaceId=dev&custom=value",
}),
nacosNamespaceIdTouched: false,
forPersist: true,
translate,
});
const params = new URLSearchParams(config.connectionParams);
expect(config.host).toBe("nacos.example.test");
expect(params.get("contextPath")).toBe("/registry");
expect(params.get("namespaceId")).toBe("dev");
expect(params.get("custom")).toBe("value");
});
});

View File

@@ -27,6 +27,7 @@ import {
normalizeEditableJVMModes,
} from "../../utils/jvmConnectionConfig";
import { resolveRedisConfigDraft } from "../../utils/redisConnectionUri";
import { setNacosConnectionScope } from "../../utils/nacosConnectionScope";
import {
normalizeAddressList,
normalizeClickHouseProtocolValue,
@@ -114,6 +115,7 @@ type BuildConnectionConfigParams = {
values: any;
forPersist: boolean;
initialValues?: SavedConnection | null;
nacosNamespaceIdTouched?: boolean;
translate: Translate;
};
@@ -347,6 +349,7 @@ export const buildConnectionConfig = async ({
values,
forPersist,
initialValues,
nacosNamespaceIdTouched = false,
translate: t,
}: BuildConnectionConfigParams): Promise<ConnectionConfig> => {
const mergedValues = { ...values };
@@ -455,6 +458,21 @@ export const buildConnectionConfig = async ({
jvmEndpointTimeoutSeconds: resolvedJvmTimeout,
});
}
const isNacosConfig =
String(mergedValues.type || "").trim().toLowerCase() === "nacos";
const hasStoredNacosConnectionParams =
isNacosConfig &&
String(initialValues?.config?.connectionParams || "").trim() !== "";
const hasExplicitNacosNamespaceId =
isNacosConfig &&
Object.prototype.hasOwnProperty.call(
mergedValues,
"nacosNamespaceId",
) &&
mergedValues.nacosNamespaceId !== undefined &&
(String(mergedValues.nacosNamespaceId || "").trim() !== "" ||
nacosNamespaceIdTouched ||
hasStoredNacosConnectionParams);
const parsedUriValues = parseUriToValues(
mergedValues.uri,
mergedValues.type,
@@ -467,6 +485,9 @@ export const buildConnectionConfig = async ({
(Array.isArray(value) && value.length === 0);
if (parsedUriValues) {
Object.entries(parsedUriValues).forEach(([key, value]) => {
if (key === "nacosNamespaceId" && hasExplicitNacosNamespaceId) {
return;
}
if (
key === "clickHouseProtocol" &&
normalizeClickHouseProtocolValue((mergedValues as any)[key]) ===
@@ -839,6 +860,15 @@ export const buildConnectionConfig = async ({
)
: normalizeConnectionParamsText(mergedValues.connectionParams)
: "";
if (
type === "nacos" &&
Object.prototype.hasOwnProperty.call(mergedValues, "nacosNamespaceId")
) {
normalizedConnectionParams = setNacosConnectionScope(
normalizedConnectionParams,
mergedValues.nacosNamespaceId,
);
}
if (type === "nacos" && !/(?:^|[&;])contextPath=/.test(normalizedConnectionParams)) {
normalizedConnectionParams = normalizedConnectionParams
? `${normalizedConnectionParams}&contextPath=/nacos`
@@ -853,7 +883,8 @@ export const buildConnectionConfig = async ({
? normalizeConnectionProtectionConfig({
restrictDataEdit: mergedValues.restrictDataEdit === true,
restrictStructureEdit: mergedValues.restrictStructureEdit === true,
restrictScriptExecution: mergedValues.restrictScriptExecution === true,
restrictScriptExecution:
type !== "nacos" && mergedValues.restrictScriptExecution === true,
restrictDataImport: mergedValues.restrictDataImport === true,
})
: undefined;

View File

@@ -77,3 +77,86 @@ describe('connectionModalUri Milvus support', () => {
expect(getConnectionParamsPlaceholder('milvus', 'mysql')).toBe('token=...');
});
});
describe('connectionModalUri Nacos support', () => {
it('treats the URI path as contextPath and the namespace query as the scoped namespace', () => {
expect(
parseUriToValues(
'https://alice:secret@nacos.example.test:8848/registry/api?namespaceId=dev-team&custom=value',
'nacos',
),
).toMatchObject({
host: 'nacos.example.test',
port: 8848,
user: 'alice',
password: 'secret',
useSSL: true,
sslMode: 'required',
nacosNamespaceId: 'dev-team',
connectionParams: 'custom=value&contextPath=%2Fregistry%2Fapi',
});
expect(
parseUriToValues(
'https://alice:secret@nacos.example.test:8848/registry/api?namespaceId=dev-team&custom=value',
'nacos',
),
).not.toHaveProperty('database');
});
it('builds a Nacos URI from contextPath and the dedicated namespace field', () => {
expect(
buildUriFromValues({
type: 'nacos',
host: 'nacos.example.test',
port: 8848,
user: 'alice',
password: 'secret',
useSSL: true,
sslMode: 'required',
nacosNamespaceId: 'public',
connectionParams: 'contextPath=/registry/api&custom=value',
}),
).toBe(
'https://alice:secret@nacos.example.test:8848/registry/api?custom=value&namespaceId=public',
);
});
it('falls back to a stored scope when the dedicated URI field is undefined', () => {
expect(
buildUriFromValues({
type: 'nacos',
host: 'nacos.example.test',
port: 8848,
nacosNamespaceId: undefined,
connectionParams: 'contextPath=/nacos&namespaceId=dev',
}),
).toBe('http://nacos.example.test:8848/nacos?namespaceId=dev');
});
it('preserves an explicit root context path through a Nacos URI round trip', () => {
const uri = buildUriFromValues({
type: 'nacos',
host: 'nacos.example.test',
port: 8848,
nacosNamespaceId: 'public',
connectionParams: 'contextPath=/',
});
expect(uri).toBe(
'http://nacos.example.test:8848/?namespaceId=public',
);
expect(parseUriToValues(uri, 'nacos')).toMatchObject({
nacosNamespaceId: 'public',
connectionParams: 'contextPath=%2F',
});
});
it('uses Nacos-specific URI and advanced parameter placeholders', () => {
expect(getUriPlaceholder('nacos')).toBe(
'http://nacos:nacos@127.0.0.1:8848/nacos?namespaceId=dev',
);
expect(getConnectionParamsPlaceholder('nacos', 'mysql')).toBe(
'contextPath=/nacos',
);
});
});

View File

@@ -19,10 +19,13 @@ import {
resolveOceanBaseProtocolFromQueryText as resolveOceanBaseProtocolQueryText,
type OceanBaseProtocol,
} from "../../utils/oceanBaseProtocol";
import {
buildRedisUriFromValues,
parseRedisUriToFormValues,
} from "../../utils/redisConnectionUri";
import {
buildRedisUriFromValues,
parseRedisUriToFormValues,
} from "../../utils/redisConnectionUri";
import {
extractNacosConnectionScope,
} from "../../utils/nacosConnectionScope";
export type ClickHouseProtocolChoice = "auto" | "http" | "native";
export type OceanBaseProtocolChoice = OceanBaseProtocol;
@@ -155,7 +158,7 @@ export const normalizeMongoSrvHostList = (
const seen = new Set<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ""), defaultPort);
const parsed = parseHostPort(String(entry || ""), defaultPort);
if (!parsed?.host) {
return;
}
@@ -259,11 +262,12 @@ const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf("#");
if (hashIndex >= 0) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf("#");
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = "";
const queryIndex = rest.indexOf("?");
@@ -289,12 +293,13 @@ const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
hostText = rest.slice(atIndex + 1);
const colonIndex = userInfo.indexOf(":");
if (colonIndex >= 0) {
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
const hosts = hostText
.split(",")
.map((item) => item.trim())
@@ -303,11 +308,12 @@ const parseSingleHostUri = (
return {
username,
password,
password,
hosts,
database: safeDecode(pathText),
params: new URLSearchParams(queryText),
};
hosts,
database: safeDecode(pathText),
hasExplicitPath,
params: new URLSearchParams(queryText),
};
};
const parseSingleHostUri = (
uriText: string,
@@ -335,12 +341,13 @@ const parseSingleHostUri = (
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${defaultPort}`,
defaultPort,
@@ -382,6 +389,25 @@ export const parseClickHouseHTTPUriToValues = (
return null;
}
const skipVerify = normalizeUriBool(parsed.params.get("skip_verify"));
return {
host: parsed.host,
port: parsed.port,
user: parsed.username,
password: parsed.password,
database: parsed.database || "",
clickHouseProtocol: "http",
useSSL: isHttps,
sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable",
...extractSSLPathValuesFromParams(parsed.params, "clickhouse"),
connectionParams: serializeConnectionParams(parsed.params),
};
};
const normalizeNacosContextPath = (raw: unknown): string => {
const text = String(raw ?? "").trim();
if (!text || text === "/") {
return text === "/" ? "/" : "/nacos";
}
return `/${text.replace(/^\/+|\/+$/g, "")}`;
};
@@ -938,10 +964,45 @@ export const parseUriToValues = (
...extractSSLPathValuesFromParams(parsed.params, type),
connectionParams: serializeConnectionParams(parsed.params),
timeout:
Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue))
: undefined,
};
Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue))
: undefined,
};
}
if (type === "trino") {
return parseTrinoUriToValues(trimmedUri);
}
if (type === "clickhouse") {
const httpValues = parseClickHouseHTTPUriToValues(trimmedUri);
if (httpValues) {
return httpValues;
}
}
if (type === "nacos") {
const parsed = parseSingleHostUri(
trimmedUri,
["http", "https", "nacos"],
getDefaultPortByType(type),
);
if (!parsed) {
return null;
}
const {
connectionParams: paramsWithoutScope,
scope,
} = extractNacosConnectionScope(parsed.params.toString());
const params = new URLSearchParams(paramsWithoutScope);
const contextPath = normalizeNacosContextPath(
parsed.database
? `/${parsed.database}`
: parsed.hasExplicitPath
? "/"
: params.get("contextPath") || "/nacos",
);
params.set("contextPath", contextPath);
const useSSL = trimmedUri.toLowerCase().startsWith("https://");
return {
host: parsed.host,
@@ -1164,7 +1225,7 @@ export const getUriPlaceholder = (dbType: string) => {
return "http://127.0.0.1:6333";
}
if (dbType === "milvus") {
return "http://127.0.0.1:19530/default";
return "http://127.0.0.1:19530/default";
}
if (dbType === "iotdb") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
@@ -1175,6 +1236,9 @@ export const getUriPlaceholder = (dbType: string) => {
if (dbType === "mqtt") {
return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";
}
if (dbType === "kafka") {
return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";
}
if (dbType === "rabbitmq") {
return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";
}
@@ -1242,9 +1306,11 @@ export const getConnectionParamsPlaceholder = (
return "tenant=default_tenant&apiKey=...";
case "qdrant":
return "apiKey=...";
case "milvus":
return "token=...";
case "dameng":
case "milvus":
return "token=...";
case "dameng":
return "schema=SYSDBA";
case "tdengine":
return "timezone=Asia%2FShanghai";
case "iotdb":
return "fetchSize=1024&timeZone=Asia%2FShanghai";
@@ -1302,6 +1368,32 @@ export const buildUriFromValues = (values: any) => {
.trim()
.toLowerCase();
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
} else {
params.delete("skip_verify");
}
appendSSLPathParamsForUri(params, type, values);
} else {
params.delete("skip_verify");
}
const query = params.toString();
const scheme = values.useSSL ? "https" : "http";
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${query ? `?${query}` : ""}`;
}
if (type === "nacos") {
const {
connectionParams: paramsWithoutStoredScope,
scope: storedScope,
} = extractNacosConnectionScope(values.connectionParams);
const params = new URLSearchParams(paramsWithoutStoredScope);
const contextPath = encodeNacosContextPath(
params.get("contextPath") || "/nacos",
);
params.delete("contextPath");
const hasDedicatedNamespaceId =
Object.prototype.hasOwnProperty.call(values, "nacosNamespaceId") &&
values.nacosNamespaceId !== undefined;
const namespaceId = String(
hasDedicatedNamespaceId

View File

@@ -1,8 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const modalState = vi.hoisted(() => ({
confirm: vi.fn(),
}));
const antdState = vi.hoisted(() => ({
message: {
error: vi.fn(),
success: vi.fn(),
},
}));
const nacosBackend = vi.hoisted(() => ({
NacosCreateNamespace: vi.fn(),
NacosUpdateNamespace: vi.fn(),
NacosDeleteNamespace: vi.fn(),
}));
vi.mock('../common/ResizableDraggableModal', () => ({
default: {
confirm: modalState.confirm,
},
}));
vi.mock('antd', async (importOriginal) => {
const actual = await importOriginal<typeof import('antd')>();
return {
...actual,
message: antdState.message,
};
});
import { useStore } from '../../store';
import { buildSidebarLegacyNodeMenuItems } from './sidebarLegacyNodeMenu';
const createNacosConnection = (protection: {
readOnly?: boolean;
restrictDataEdit?: boolean;
restrictStructureEdit?: boolean;
} = {}) => ({
id: 'nacos-permission-test',
name: 'Nacos permission test',
config: {
type: 'nacos',
host: '127.0.0.1',
port: 8848,
readOnly: protection.readOnly === true,
protection: {
restrictDataEdit: protection.restrictDataEdit === true,
restrictStructureEdit: protection.restrictStructureEdit === true,
restrictScriptExecution: false,
restrictDataImport: false,
},
},
} as any);
const buildNacosRootItems = (
connection: any,
loadDatabases = vi.fn(),
context: Record<string, any> = {},
) =>
buildSidebarLegacyNodeMenuItems({
key: connection.id,
type: 'connection',
dataRef: connection,
}, {
loadDatabases,
setExpandedKeys: vi.fn(),
setLoadedKeys: vi.fn(),
...context,
}) as any[];
const buildNacosNamespaceItems = (
connection: any,
loadDatabases = vi.fn(),
context: Record<string, any> = {},
) =>
buildSidebarLegacyNodeMenuItems({
type: 'nacos-namespace',
dataRef: {
...connection,
nacosNamespaceId: 'mkefu-dev',
nacosNamespaceName: 'mkefu development',
},
}, {
addTab: vi.fn(),
loadDatabases,
...context,
}) as any[];
const findItem = (items: any[], key: string) => items.find((item) => item?.key === key);
describe('Nacos service group context menu', () => {
const originalConnections = useStore.getState().connections;
beforeEach(() => {
vi.clearAllMocks();
useStore.setState({ connections: [] });
nacosBackend.NacosCreateNamespace.mockResolvedValue({ success: true });
nacosBackend.NacosUpdateNamespace.mockResolvedValue({ success: true });
nacosBackend.NacosDeleteNamespace.mockResolvedValue({ success: true });
vi.stubGlobal('window', {
go: {
app: {
App: nacosBackend,
},
},
});
});
afterEach(() => {
useStore.setState({ connections: originalConnections });
vi.unstubAllGlobals();
});
it('opens the selected service group with its group filter', () => {
const addTab = vi.fn();
const items = buildSidebarLegacyNodeMenuItems({
@@ -42,4 +153,183 @@ describe('Nacos service group context menu', () => {
expect(tab?.id).toBe('nacos-services-nacos-1-ns-mkefu-dev');
expect(tab).not.toHaveProperty('nacosGroup');
});
it.each([
{
name: 'legacy readOnly',
connection: createNacosConnection({ readOnly: true }),
disabled: true,
},
{
name: 'structure edit protection',
connection: createNacosConnection({ restrictStructureEdit: true }),
disabled: true,
},
{
name: 'data edit protection only',
connection: createNacosConnection({ restrictDataEdit: true }),
disabled: false,
},
])('applies $name only to Nacos namespace structure actions', ({
connection,
disabled,
}) => {
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(connection);
const namespaceItems = buildNacosNamespaceItems(connection);
expect(findItem(rootItems, 'create-nacos-namespace')?.disabled).toBe(disabled);
expect(findItem(namespaceItems, 'edit-nacos-namespace')?.disabled).toBe(disabled);
expect(findItem(namespaceItems, 'delete-nacos-namespace')?.disabled).toBe(disabled);
});
it('rechecks structure protection before opening a stale namespace menu action', () => {
const connection = createNacosConnection();
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(connection);
const namespaceItems = buildNacosNamespaceItems(connection);
useStore.setState({
connections: [createNacosConnection({ restrictStructureEdit: true })],
});
findItem(rootItems, 'create-nacos-namespace')?.onClick?.();
findItem(namespaceItems, 'edit-nacos-namespace')?.onClick?.();
findItem(namespaceItems, 'delete-nacos-namespace')?.onClick?.();
expect(modalState.confirm).not.toHaveBeenCalled();
});
it('rechecks structure protection in namespace create, edit, and delete confirmations', async () => {
const connection = createNacosConnection();
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(connection);
const namespaceItems = buildNacosNamespaceItems(connection);
findItem(rootItems, 'create-nacos-namespace')?.onClick?.();
const createConfirmation = modalState.confirm.mock.calls[0]?.[0];
expect(createConfirmation).toBeDefined();
useStore.setState({
connections: [createNacosConnection({ restrictStructureEdit: true })],
});
await expect(createConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosCreateNamespace).not.toHaveBeenCalled();
useStore.setState({ connections: [connection] });
findItem(namespaceItems, 'edit-nacos-namespace')?.onClick?.();
const editConfirmation = modalState.confirm.mock.calls[1]?.[0];
expect(editConfirmation).toBeDefined();
useStore.setState({
connections: [createNacosConnection({ readOnly: true })],
});
await expect(editConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosUpdateNamespace).not.toHaveBeenCalled();
useStore.setState({ connections: [connection] });
findItem(namespaceItems, 'delete-nacos-namespace')?.onClick?.();
const deleteConfirmation = modalState.confirm.mock.calls[2]?.[0];
expect(deleteConfirmation).toBeDefined();
useStore.setState({
connections: [createNacosConnection({ restrictStructureEdit: true })],
});
await expect(deleteConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosDeleteNamespace).not.toHaveBeenCalled();
expect(antdState.message.error).toHaveBeenCalledTimes(3);
});
it('allows namespace edits when only data edits are restricted', async () => {
const connection = createNacosConnection({ restrictDataEdit: true });
const loadDatabases = vi.fn();
useStore.setState({ connections: [connection] });
const namespaceItems = buildNacosNamespaceItems(connection, loadDatabases);
findItem(namespaceItems, 'edit-nacos-namespace')?.onClick?.();
const confirmation = modalState.confirm.mock.calls[0]?.[0];
await confirmation.onOk();
expect(nacosBackend.NacosUpdateNamespace).toHaveBeenCalledTimes(1);
expect(loadDatabases).toHaveBeenCalledTimes(1);
});
it('disables namespace CRUD for a configured-scope fallback tree', () => {
const connection = createNacosConnection();
const configuredScopeConnection = {
...connection,
nacosNamespaceDiscoveryMode: 'configured',
};
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(configuredScopeConnection);
const namespaceItems = buildNacosNamespaceItems(configuredScopeConnection);
expect(findItem(rootItems, 'create-nacos-namespace')?.disabled).toBe(true);
expect(findItem(namespaceItems, 'edit-nacos-namespace')?.disabled).toBe(true);
expect(findItem(namespaceItems, 'delete-nacos-namespace')?.disabled).toBe(true);
findItem(rootItems, 'create-nacos-namespace')?.onClick?.();
findItem(namespaceItems, 'edit-nacos-namespace')?.onClick?.();
findItem(namespaceItems, 'delete-nacos-namespace')?.onClick?.();
expect(modalState.confirm).not.toHaveBeenCalled();
});
it('uses the live runtime discovery mode when rebuilt nodes no longer carry the root marker', () => {
const connection = createNacosConnection();
const context = {
getNacosNamespaceDiscoveryMode: vi.fn(() => 'configured'),
};
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(connection, vi.fn(), context);
const namespaceItems = buildNacosNamespaceItems(connection, vi.fn(), context);
expect(connection).not.toHaveProperty('nacosNamespaceDiscoveryMode');
expect(findItem(rootItems, 'create-nacos-namespace')?.disabled).toBe(true);
expect(findItem(namespaceItems, 'edit-nacos-namespace')?.disabled).toBe(true);
expect(findItem(namespaceItems, 'delete-nacos-namespace')?.disabled).toBe(true);
});
it('rechecks the live discovery mode before stale namespace CRUD actions and confirmations', async () => {
const connection = createNacosConnection();
let discoveryMode: 'listed' | 'configured' = 'listed';
const context = {
getNacosNamespaceDiscoveryMode: () => discoveryMode,
};
useStore.setState({ connections: [connection] });
const rootItems = buildNacosRootItems(connection, vi.fn(), context);
const namespaceItems = buildNacosNamespaceItems(connection, vi.fn(), context);
const createItem = findItem(rootItems, 'create-nacos-namespace');
expect(createItem?.disabled).toBe(false);
createItem?.onClick?.();
const createConfirmation = modalState.confirm.mock.calls[0]?.[0];
expect(createConfirmation).toBeDefined();
const createContentChildren = createConfirmation.content.props.children;
createContentChildren[1].props.children[1].props.onChange({
target: { value: 'Scoped namespace' },
});
discoveryMode = 'configured';
await expect(createConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosCreateNamespace).not.toHaveBeenCalled();
createItem?.onClick?.();
expect(modalState.confirm).toHaveBeenCalledTimes(1);
discoveryMode = 'listed';
findItem(namespaceItems, 'edit-nacos-namespace')?.onClick?.();
const editConfirmation = modalState.confirm.mock.calls[1]?.[0];
discoveryMode = 'configured';
await expect(editConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosUpdateNamespace).not.toHaveBeenCalled();
discoveryMode = 'listed';
findItem(namespaceItems, 'delete-nacos-namespace')?.onClick?.();
const deleteConfirmation = modalState.confirm.mock.calls[2]?.[0];
discoveryMode = 'configured';
await expect(deleteConfirmation.onOk()).rejects.toThrow();
expect(nacosBackend.NacosDeleteNamespace).not.toHaveBeenCalled();
});
});

View File

@@ -46,16 +46,68 @@ import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { supportsTableTruncateAction } from '../tableDataDangerActions';
import { normalizeConnectionEnvironmentType } from '../../utils/connectionEnvironment';
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import { buildNacosServicesTabData } from '../sidebarV2Utils';
import {
buildNacosServicesTabData,
resolveNacosNamespaceDiscoveryModeFromTreeNode,
type NacosNamespaceDiscoveryMode,
} from '../sidebarV2Utils';
type NacosNamespaceFormMode = 'create' | 'edit';
const isNacosNamespaceStructureRestricted = (config: SavedConnection['config'] | undefined) =>
config?.readOnly === true || config?.protection?.restrictStructureEdit === true;
const resolveCurrentNacosConnection = (connection: SavedConnection): SavedConnection => {
const current = useStore.getState().connections.find((item) => item.id === connection.id);
return current || connection;
};
const assertNacosNamespaceStructureEditable = (connection: SavedConnection) => {
const current = resolveCurrentNacosConnection(connection);
if (!isNacosNamespaceStructureRestricted(current.config)) {
return current;
}
const error = new Error(t('nacos.backend.error.read_only'));
message.error(error.message);
throw error;
};
const resolveCurrentNacosNamespaceDiscoveryMode = (
connectionId: unknown,
node: any,
resolver?: (id: string) => unknown,
): NacosNamespaceDiscoveryMode | undefined => {
const liveMode = resolver?.(String(connectionId || ''));
if (liveMode === 'listed' || liveMode === 'configured') {
return liveMode;
}
return resolveNacosNamespaceDiscoveryModeFromTreeNode(node);
};
const assertNacosNamespaceDiscoveryAllowsCrud = (
isBlocked: (() => boolean) | undefined,
) => {
if (!isBlocked?.()) return;
const error = new Error(t('nacos.backend.error.read_only'));
message.error(error.message);
throw error;
};
const openNacosNamespaceFormModal = (options: {
mode: NacosNamespaceFormMode;
connection: SavedConnection;
initial?: { id?: string; showName?: string; description?: string };
onSuccess?: () => void;
isNamespaceManagementBlocked?: () => boolean;
}) => {
if (
options.isNamespaceManagementBlocked?.() ||
isNacosNamespaceStructureRestricted(
resolveCurrentNacosConnection(options.connection).config,
)
) {
return;
}
const draft = {
id: String(options.initial?.id || ''),
showName: String(options.initial?.showName || ''),
@@ -106,12 +158,16 @@ const openNacosNamespaceFormModal = (options: {
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
assertNacosNamespaceDiscoveryAllowsCrud(
options.isNamespaceManagementBlocked,
);
const currentConnection = assertNacosNamespaceStructureEditable(options.connection);
const showName = draft.showName.trim();
if (!showName) {
message.error(t('nacos.backend.error.namespace_name_required'));
throw new Error('namespace name required');
}
const rpcConfig = buildRpcConnectionConfig(options.connection.config as any);
const rpcConfig = buildRpcConnectionConfig(currentConnection.config as any);
if (isEdit) {
const res = await (window as any).go.app.App.NacosUpdateNamespace(rpcConfig, {
id: draft.id.trim(),
@@ -305,6 +361,7 @@ export const buildSidebarLegacyNodeMenuItems = (
deleteSavedQueryGroup,
moveSavedQueryToGroup,
treeDataRef,
getNacosNamespaceDiscoveryMode,
setTreeData,
handleAddExternalSQLDirectory,
openCreateExternalSQLFileModal,
@@ -606,6 +663,17 @@ export const buildSidebarLegacyNodeMenuItems = (
}
if (isNacos) {
const nacosStructureRestricted = isNacosNamespaceStructureRestricted(
resolveCurrentNacosConnection(conn).config,
);
const isNamespaceManagementBlocked = () =>
resolveCurrentNacosNamespaceDiscoveryMode(
conn.id,
node,
getNacosNamespaceDiscoveryMode,
) === 'configured';
const usesConfiguredNacosNamespace =
isNamespaceManagementBlocked();
return [
{
key: 'refresh',
@@ -622,11 +690,21 @@ export const buildSidebarLegacyNodeMenuItems = (
key: 'create-nacos-namespace',
label: t('nacos.namespace.menu.create'),
icon: <PlusOutlined />,
onClick: () => openNacosNamespaceFormModal({
mode: 'create',
connection: node.dataRef as SavedConnection,
onSuccess: () => loadDatabases(node),
}),
disabled:
nacosStructureRestricted || usesConfiguredNacosNamespace,
onClick: () => {
if (isNamespaceManagementBlocked()) return;
const currentConnection = resolveCurrentNacosConnection(
node.dataRef as SavedConnection,
);
if (isNacosNamespaceStructureRestricted(currentConnection.config)) return;
openNacosNamespaceFormModal({
mode: 'create',
connection: currentConnection,
onSuccess: () => loadDatabases(node),
isNamespaceManagementBlocked,
});
},
},
{ type: 'divider' },
{
@@ -770,6 +848,18 @@ export const buildSidebarLegacyNodeMenuItems = (
const nsName = nacosNamespaceName || nacosNamespaceId || 'public';
const nsKey = nacosNamespaceId || 'public';
const isPublicNs = !String(nacosNamespaceId || '').trim() || String(nacosNamespaceId).toLowerCase() === 'public';
const namespaceConnection = { id, config } as SavedConnection;
const nacosStructureRestricted = isNacosNamespaceStructureRestricted(
resolveCurrentNacosConnection(namespaceConnection).config,
);
const isNamespaceManagementBlocked = () =>
resolveCurrentNacosNamespaceDiscoveryMode(
id,
node,
getNacosNamespaceDiscoveryMode,
) === 'configured';
const usesConfiguredNacosNamespace =
isNamespaceManagementBlocked();
const parentConnectionNode = {
key: id,
type: 'connection',
@@ -810,25 +900,46 @@ export const buildSidebarLegacyNodeMenuItems = (
key: 'edit-nacos-namespace',
label: t('nacos.namespace.menu.edit'),
icon: <EditOutlined />,
disabled: isPublicNs,
onClick: () => openNacosNamespaceFormModal({
mode: 'edit',
connection: { id, config } as SavedConnection,
initial: {
id: nacosNamespaceId || '',
showName: nsName,
description: '',
},
onSuccess: () => loadDatabases(parentConnectionNode),
}),
disabled:
isPublicNs ||
nacosStructureRestricted ||
usesConfiguredNacosNamespace,
onClick: () => {
if (isNamespaceManagementBlocked()) return;
const currentConnection = resolveCurrentNacosConnection(namespaceConnection);
if (
isPublicNs
|| isNacosNamespaceStructureRestricted(currentConnection.config)
) return;
openNacosNamespaceFormModal({
mode: 'edit',
connection: currentConnection,
initial: {
id: nacosNamespaceId || '',
showName: nsName,
description: '',
},
onSuccess: () => loadDatabases(parentConnectionNode),
isNamespaceManagementBlocked,
});
},
},
{
key: 'delete-nacos-namespace',
label: t('nacos.namespace.menu.delete'),
icon: <DeleteOutlined />,
danger: true,
disabled: isPublicNs,
disabled:
isPublicNs ||
nacosStructureRestricted ||
usesConfiguredNacosNamespace,
onClick: () => {
if (isNamespaceManagementBlocked()) return;
const currentConnection = resolveCurrentNacosConnection(namespaceConnection);
if (
isPublicNs
|| isNacosNamespaceStructureRestricted(currentConnection.config)
) return;
Modal.confirm({
title: t('nacos.namespace.menu.delete'),
content: t('nacos.namespace.message.confirm_delete', {
@@ -837,7 +948,14 @@ export const buildSidebarLegacyNodeMenuItems = (
}),
okButtonProps: { danger: true },
onOk: async () => {
const rpcConfig = buildRpcConnectionConfig(config as any);
assertNacosNamespaceDiscoveryAllowsCrud(
isNamespaceManagementBlocked,
);
const latestConnection =
assertNacosNamespaceStructureEditable(currentConnection);
const rpcConfig = buildRpcConnectionConfig(
latestConnection.config as any,
);
const res = await (window as any).go.app.App.NacosDeleteNamespace(
rpcConfig,
nacosNamespaceId || '',

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { message } from 'antd';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -137,3 +138,317 @@ describe('useSidebarTreeLoaders Nacos service groups', () => {
expect(loadingNodesRef.current.size).toBe(0);
});
});
describe('useSidebarTreeLoaders Nacos namespace discovery', () => {
let renderer: ReactTestRenderer | null = null;
beforeEach(() => {
vi.clearAllMocks();
mocks.storeState.connections = [];
mocks.replaceTreeNodeChildren.mockImplementation((_key, children) => children || []);
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});
const renderNamespaceLoader = () => {
let loaders: ReturnType<typeof useSidebarTreeLoaders> | undefined;
let connectionStates: Record<string, string> = {};
const setConnectionStates = vi.fn((updater: any) => {
connectionStates =
typeof updater === 'function' ? updater(connectionStates) : updater;
});
const loadingNodesRef = { current: new Set<string>() };
const Harness = () => {
loaders = useSidebarTreeLoaders({
savedQueries: [],
tableSortPreference: {},
tableAccessCount: {},
pinnedSidebarTables: [],
isV2Ui: true,
loadingNodesRef,
setConnectionStates,
setLoadedKeys: mocks.setLoadedKeys,
replaceTreeNodeChildren: mocks.replaceTreeNodeChildren,
buildRuntimeConfig: (conn) => conn.config,
buildJVMRuntimeConfig: (conn) => conn.config,
buildJVMDiagnosticTreeNodes: () => [],
resolveSavedQueryDisplayName: (name) => String(name || ''),
});
return null;
};
act(() => {
renderer = create(<Harness />);
});
return {
get loaders() {
return loaders!;
},
get connectionStates() {
return connectionStates;
},
loadingNodesRef,
};
};
const buildNode = (connectionParams = '') => {
const dataRef = {
id: 'nacos-1',
name: 'Nacos',
config: {
type: 'nacos',
host: '127.0.0.1',
port: 8848,
connectionParams,
},
};
mocks.storeState.connections = [dataRef];
return {
key: 'nacos-1',
dataRef,
};
};
it('discards a stale namespace response without clearing the newer load marker', async () => {
const staleResponse = deferred<any>();
const currentResponse = deferred<any>();
const listNamespaces = vi.fn()
.mockReturnValueOnce(staleResponse.promise)
.mockReturnValueOnce(currentResponse.promise);
vi.stubGlobal('window', {
go: { app: { App: { NacosListNamespaces: listNamespaces } } },
});
const harness = renderNamespaceLoader();
const staleNode = buildNode('namespaceId=old-scope');
mocks.storeState.connections = [staleNode.dataRef];
const staleLoad = harness.loaders.loadDatabases(staleNode);
const duplicateLoad = harness.loaders.loadDatabases(staleNode);
expect(listNamespaces).toHaveBeenCalledTimes(1);
await expect(duplicateLoad).resolves.toBeUndefined();
const currentNode = buildNode('namespaceId=current-scope');
mocks.storeState.connections = [currentNode.dataRef];
const currentLoad = harness.loaders.loadDatabases(currentNode);
expect(listNamespaces).toHaveBeenCalledTimes(2);
expect(harness.loadingNodesRef.current.has('dbs-nacos-1')).toBe(true);
staleResponse.resolve({
success: false,
message: 'forbidden',
data: { errorCode: 'nacos_namespace_list_forbidden' },
});
await act(async () => {
await staleLoad;
});
expect(mocks.replaceTreeNodeChildren).not.toHaveBeenCalled();
expect(harness.loadingNodesRef.current.has('dbs-nacos-1')).toBe(true);
expect(message.warning).not.toHaveBeenCalled();
currentResponse.resolve({
success: false,
message: 'forbidden',
data: { errorCode: 'nacos_namespace_list_forbidden' },
});
await act(async () => {
await currentLoad;
});
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
const [, namespaces, rootDataRef] =
mocks.replaceTreeNodeChildren.mock.calls[0];
expect(namespaces[0]).toMatchObject({
title: 'current-scope',
dataRef: {
nacosNamespaceId: 'current-scope',
nacosNamespaceDiscoveryMode: 'configured',
},
});
expect(rootDataRef.config.connectionParams).toBe(
'namespaceId=current-scope',
);
expect(harness.loadingNodesRef.current.size).toBe(0);
expect(harness.connectionStates['nacos-1']).toBe('success');
});
it('keeps the full namespace list when discovery is allowed even if a scope is configured', async () => {
vi.stubGlobal('window', {
go: {
app: {
App: {
NacosListNamespaces: vi.fn().mockResolvedValue({
success: true,
data: [
{ id: '', showName: 'public' },
{ id: 'dev', showName: 'Development' },
],
}),
},
},
},
});
const harness = renderNamespaceLoader();
await act(async () => {
await harness.loaders.loadDatabases(buildNode('namespaceId=dev'));
});
const [, namespaces, rootDataRef] =
mocks.replaceTreeNodeChildren.mock.calls[0];
expect(namespaces).toHaveLength(2);
expect(
namespaces.map((namespace: any) => namespace.dataRef.nacosNamespaceId),
).toEqual(['', 'dev']);
expect(
namespaces.every(
(namespace: any) =>
namespace.dataRef.nacosNamespaceDiscoveryMode === 'listed',
),
).toBe(true);
expect(rootDataRef.nacosNamespaceDiscoveryMode).toBe('listed');
expect(harness.connectionStates['nacos-1']).toBe('success');
expect(message.warning).not.toHaveBeenCalled();
});
it('falls back to the explicitly configured namespace only for the stable forbidden code', async () => {
const listNamespaces = vi.fn().mockResolvedValue({
success: false,
message: 'forbidden',
data: { errorCode: 'nacos_namespace_list_forbidden' },
});
vi.stubGlobal('window', {
go: { app: { App: { NacosListNamespaces: listNamespaces } } },
});
const harness = renderNamespaceLoader();
await act(async () => {
await harness.loaders.loadDatabases(
buildNode('contextPath=%2Fnacos&namespaceId=dev'),
);
});
expect(mocks.replaceTreeNodeChildren).toHaveBeenCalledTimes(1);
const [key, namespaces, rootDataRef] =
mocks.replaceTreeNodeChildren.mock.calls[0];
expect(key).toBe('nacos-1');
expect(namespaces).toHaveLength(1);
expect(namespaces[0]).toMatchObject({
title: 'dev',
type: 'nacos-namespace',
dataRef: {
nacosNamespaceId: 'dev',
nacosNamespaceName: 'dev',
nacosNamespaceDiscoveryMode: 'configured',
},
children: [
{ type: 'nacos-config-entry' },
{ type: 'nacos-services-entry' },
],
});
expect(rootDataRef).toMatchObject({
id: 'nacos-1',
nacosNamespaceDiscoveryMode: 'configured',
});
expect(harness.connectionStates['nacos-1']).toBe('success');
expect(message.warning).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('dev'),
}),
);
expect(message.error).not.toHaveBeenCalled();
});
it('keeps explicit public scope configured while sending the Nacos public namespace id', async () => {
vi.stubGlobal('window', {
go: {
app: {
App: {
NacosListNamespaces: vi.fn().mockResolvedValue({
success: false,
data: { errorCode: 'nacos_namespace_list_forbidden' },
}),
},
},
},
});
const harness = renderNamespaceLoader();
await act(async () => {
await harness.loaders.loadDatabases(buildNode('namespaceId=public'));
});
const namespace = mocks.replaceTreeNodeChildren.mock.calls[0][1][0];
expect(namespace).toMatchObject({
title: 'public',
dataRef: {
nacosNamespaceId: '',
nacosNamespaceName: 'public',
nacosNamespaceDiscoveryMode: 'configured',
},
});
});
it('prompts for a scope instead of fabricating one when discovery is forbidden and none is configured', async () => {
vi.stubGlobal('window', {
go: {
app: {
App: {
NacosListNamespaces: vi.fn().mockResolvedValue({
success: false,
message: 'forbidden',
data: { errorCode: 'nacos_namespace_list_forbidden' },
}),
},
},
},
});
const harness = renderNamespaceLoader();
await act(async () => {
await harness.loaders.loadDatabases(buildNode('contextPath=/nacos'));
});
expect(mocks.replaceTreeNodeChildren).not.toHaveBeenCalled();
expect(harness.connectionStates['nacos-1']).toBe('error');
expect(message.error).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('Namespace ID'),
}),
);
expect(message.warning).not.toHaveBeenCalled();
});
it('does not use the configured namespace for unrelated namespace-list errors', async () => {
vi.stubGlobal('window', {
go: {
app: {
App: {
NacosListNamespaces: vi.fn().mockResolvedValue({
success: false,
message: 'server unavailable',
data: { errorCode: 'nacos_server_unavailable' },
}),
},
},
},
});
const harness = renderNamespaceLoader();
await act(async () => {
await harness.loaders.loadDatabases(buildNode('namespaceId=dev'));
});
expect(mocks.replaceTreeNodeChildren).not.toHaveBeenCalled();
expect(harness.connectionStates['nacos-1']).toBe('error');
expect(message.error).toHaveBeenCalledWith(
expect.objectContaining({ content: 'server unavailable' }),
);
expect(message.warning).not.toHaveBeenCalled();
});
});

View File

@@ -26,6 +26,7 @@ import { buildRedisDbNodeLabel, getRedisDbAlias } from '../../utils/redisDbAlias
import { buildJVMMonitoringActionDescriptors } from '../../utils/jvmSidebarActions';
import { getSchemaVisibilityRule, isSchemaVisible } from '../../utils/schemaVisibility';
import { type SidebarViewMetadataEntry } from '../../utils/sidebarMetadata';
import { resolveNacosConnectionScope } from '../../utils/nacosConnectionScope';
import {
buildQualifiedName,
buildSidebarObjectKeyName,
@@ -190,6 +191,10 @@ export const useSidebarTreeLoaders = ({
} | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const nacosServiceGroupRequestIdsRef = useRef<Record<string, number>>({});
const nacosNamespaceRequestIdsRef = useRef<Record<string, number>>({});
const nacosNamespaceActiveRequestsRef = useRef<
Record<string, { requestId: number; signature: string }>
>({});
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
@@ -250,8 +255,27 @@ export const useSidebarTreeLoaders = ({
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
let nacosNamespaceRequest:
| { requestId: number; signature: string }
| undefined;
if (conn.config.type === 'nacos') {
const signature = buildConnectionReloadSignature(conn);
const activeRequest =
nacosNamespaceActiveRequestsRef.current[conn.id];
if (activeRequest?.signature === signature) {
return;
}
const requestId =
(nacosNamespaceRequestIdsRef.current[conn.id] || 0) + 1;
nacosNamespaceRequestIdsRef.current[conn.id] = requestId;
nacosNamespaceRequest = { requestId, signature };
nacosNamespaceActiveRequestsRef.current[conn.id] =
nacosNamespaceRequest;
loadingNodesRef.current.add(loadKey);
} else {
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
}
setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));
let shouldMarkConnectionSuccess = false;
const config = {
@@ -390,65 +414,159 @@ export const useSidebarTreeLoaders = ({
// Handle Nacos connections: expand namespaces
if (conn.config.type === 'nacos') {
const { requestId, signature: requestSignature } =
nacosNamespaceRequest!;
const isLatestNamespaceRequest = () =>
nacosNamespaceRequestIdsRef.current[conn.id] === requestId;
const resolveCurrentRequestConnection = (): SavedConnection | null => {
if (!isLatestNamespaceRequest()) {
return null;
}
const currentConnection = useStore.getState().connections.find(
(candidate) => candidate.id === conn.id,
);
if (
!currentConnection ||
buildConnectionReloadSignature(currentConnection) !== requestSignature
) {
return null;
}
return currentConnection;
};
type NacosNamespaceDiscoveryMode = 'listed' | 'configured';
const buildNamespaceNode = (
sourceConnection: SavedConnection,
namespaceId: string,
showName: string,
configCount: number,
discoveryMode: NacosNamespaceDiscoveryMode,
): TreeNode => {
const nodeKeyId = namespaceId || 'public';
const nsDataRef = {
...sourceConnection,
nacosNamespaceId: namespaceId,
nacosNamespaceName: showName,
nacosConfigCount: Number.isFinite(configCount) ? configCount : 0,
nacosNamespaceDiscoveryMode: discoveryMode,
};
return {
title: showName,
key: `${conn.id}-nacos-ns-${nodeKeyId}`,
icon: <DatabaseOutlined style={{ color: '#2E6BE6' }} />,
type: 'nacos-namespace',
dataRef: nsDataRef,
isLeaf: false,
children: [
{
title: t('nacos_viewer.title.config_explorer'),
key: `${conn.id}-nacos-ns-${nodeKeyId}-config`,
icon: <DatabaseOutlined style={{ color: '#2E6BE6' }} />,
type: 'nacos-config-entry',
dataRef: nsDataRef,
// Expand to load Group list.
isLeaf: false,
},
{
title: t('nacos_service.title.service_explorer'),
key: `${conn.id}-nacos-ns-${nodeKeyId}-services`,
icon: <CloudOutlined style={{ color: '#13C2C2' }} />,
type: 'nacos-services-entry',
dataRef: nsDataRef,
isLeaf: false,
},
],
};
};
try {
const res = await (window as any).go.app.App.NacosListNamespaces(buildRpcConnectionConfig(config));
const currentConnection = resolveCurrentRequestConnection();
if (!currentConnection) {
return;
}
if (res.success) {
const rows: any[] = Array.isArray(res.data) ? res.data : [];
const namespaces = rows.map((ns: any) => {
const namespaceId = String(ns.id ?? ns.ID ?? '');
const showName = String(ns.showName || ns.ShowName || (namespaceId || 'public'));
const configCount = Number(ns.configCount ?? ns.ConfigCount ?? 0);
const nodeKeyId = namespaceId || 'public';
const nsDataRef = {
...conn,
nacosNamespaceId: namespaceId,
nacosNamespaceName: showName,
nacosConfigCount: Number.isFinite(configCount) ? configCount : 0,
};
return {
title: showName,
key: `${conn.id}-nacos-ns-${nodeKeyId}`,
icon: <DatabaseOutlined style={{ color: '#2E6BE6' }} />,
type: 'nacos-namespace' as const,
dataRef: nsDataRef,
isLeaf: false,
children: [
{
title: t('nacos_viewer.title.config_explorer'),
key: `${conn.id}-nacos-ns-${nodeKeyId}-config`,
icon: <DatabaseOutlined style={{ color: '#2E6BE6' }} />,
type: 'nacos-config-entry' as const,
dataRef: nsDataRef,
// Expand to load Group list.
isLeaf: false,
},
{
title: t('nacos_service.title.service_explorer'),
key: `${conn.id}-nacos-ns-${nodeKeyId}-services`,
icon: <CloudOutlined style={{ color: '#13C2C2' }} />,
type: 'nacos-services-entry' as const,
dataRef: nsDataRef,
isLeaf: false,
},
],
};
return buildNamespaceNode(
currentConnection,
namespaceId,
showName,
configCount,
'listed',
);
});
replaceTreeNodeChildren(node.key, namespaces, {
...currentConnection,
nacosNamespaceDiscoveryMode: 'listed',
});
replaceTreeNodeChildren(node.key, namespaces, conn);
shouldMarkConnectionSuccess = true;
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-nacos-ns` });
const errorCode = String(res?.data?.errorCode || '');
const scope = resolveNacosConnectionScope(
currentConnection.config.connectionParams,
);
if (
errorCode === 'nacos_namespace_list_forbidden' &&
scope.configured
) {
const namespace = buildNamespaceNode(
currentConnection,
scope.requestNamespaceId,
scope.namespaceId,
0,
'configured',
);
replaceTreeNodeChildren(node.key, [namespace], {
...currentConnection,
nacosNamespaceDiscoveryMode: 'configured',
});
shouldMarkConnectionSuccess = true;
message.warning({
content: t('nacos.namespace.message.scoped_fallback', {
id: scope.namespaceId,
}),
key: `conn-${currentConnection.id}-nacos-ns`,
});
} else {
setConnectionStates(prev => ({ ...prev, [currentConnection.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content:
errorCode === 'nacos_namespace_list_forbidden'
? t('nacos.namespace.message.scope_required')
: res.message,
key: `conn-${currentConnection.id}-nacos-ns`,
});
}
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
const currentConnection = resolveCurrentRequestConnection();
if (!currentConnection) {
return;
}
setConnectionStates(prev => ({ ...prev, [currentConnection.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-nacos-ns`,
key: `conn-${currentConnection.id}-nacos-ns`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
if (shouldMarkConnectionSuccess) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const activeRequest =
nacosNamespaceActiveRequestsRef.current[conn.id];
if (activeRequest?.requestId === requestId) {
delete nacosNamespaceActiveRequestsRef.current[conn.id];
loadingNodesRef.current.delete(loadKey);
const currentConnection = resolveCurrentRequestConnection();
if (shouldMarkConnectionSuccess) {
if (currentConnection) {
setConnectionStates(prev => ({
...prev,
[currentConnection.id]: 'success',
}));
}
}
}
}
return;

View File

@@ -9,6 +9,7 @@ import {
isSingleReadOnlyConnectionQuery,
resolveConnectionProtectionConfig,
supportsConnectionKeepAliveSQL,
supportsConnectionReadOnlyMode,
} from './connectionReadOnly';
describe('connectionReadOnly', () => {
@@ -28,6 +29,13 @@ describe('connectionReadOnly', () => {
expect(supportsConnectionKeepAliveSQL({ type: 'redis' } as any)).toBe(false);
});
it('supports Nacos production protection without treating it as a SQL keepalive source', () => {
const config = { type: 'nacos' } as any;
expect(supportsConnectionReadOnlyMode(config)).toBe(true);
expect(supportsConnectionKeepAliveSQL(config)).toBe(false);
});
it('maps legacy readOnly connections to the full production protection set', () => {
expect(resolveConnectionProtectionConfig({
type: 'postgres',

View File

@@ -65,6 +65,11 @@ const CONNECTION_READ_ONLY_TYPES = new Set([
"mongodb",
]);
const CONNECTION_PROTECTION_TYPES = new Set([
...CONNECTION_READ_ONLY_TYPES,
"nacos",
]);
export const MAX_CONNECTION_KEEPALIVE_SQL_LENGTH = 4096;
const SQL_READ_ONLY_KEYWORDS = new Set([
@@ -446,7 +451,7 @@ export const isSingleReadOnlyConnectionQuery = (
export const supportsConnectionReadOnlyMode = (
config: ConnectionReadOnlyLike,
): boolean => {
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
return CONNECTION_PROTECTION_TYPES.has(resolveConnectionReadOnlyType(config));
};
export const normalizeConnectionProtectionConfig = (

View File

@@ -220,6 +220,40 @@ describe('buildRpcConnectionConfig', () => {
});
});
it('preserves Nacos legacy readOnly for backend mutation guards', () => {
const result = buildRpcConnectionConfig({
id: 'conn-nacos-readonly',
type: 'nacos',
host: 'nacos.local',
port: 8848,
readOnly: true,
} as any);
expect(result.readOnly).toBe(true);
});
it('preserves explicit Nacos protection flags for backend mutation guards', () => {
const result = buildRpcConnectionConfig({
id: 'conn-nacos-protected',
type: 'nacos',
host: 'nacos.local',
port: 8848,
protection: {
restrictDataEdit: true,
restrictStructureEdit: true,
restrictDataImport: true,
},
} as any);
expect(result.readOnly).toBe(false);
expect(result.protection).toEqual({
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: false,
restrictDataImport: true,
});
});
it('ignores the legacy readOnly flag for unsupported connection types', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis-readonly',

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
extractNacosConnectionScope,
resolveNacosConnectionScope,
setNacosConnectionScope,
} from "./nacosConnectionScope";
describe("nacosConnectionScope", () => {
it("keeps an explicitly configured public namespace distinct from no scope", () => {
expect(resolveNacosConnectionScope("contextPath=/nacos")).toEqual({
configured: false,
namespaceId: "",
requestNamespaceId: "",
});
expect(
resolveNacosConnectionScope(
"contextPath=%2Fnacos&namespaceId=public",
),
).toEqual({
configured: true,
namespaceId: "public",
requestNamespaceId: "",
});
});
it("sets and clears a namespace without dropping other Nacos parameters", () => {
const scoped = setNacosConnectionScope(
"contextPath=/registry&custom=value",
" team/dev ",
);
expect(new URLSearchParams(scoped).get("contextPath")).toBe("/registry");
expect(new URLSearchParams(scoped).get("custom")).toBe("value");
expect(new URLSearchParams(scoped).get("namespaceId")).toBe("team/dev");
const cleared = setNacosConnectionScope(scoped, "");
expect(new URLSearchParams(cleared).get("contextPath")).toBe("/registry");
expect(new URLSearchParams(cleared).get("custom")).toBe("value");
expect(new URLSearchParams(cleared).has("namespaceId")).toBe(false);
});
it("extracts the dedicated scope from the advanced connection parameters", () => {
expect(
extractNacosConnectionScope(
"contextPath=/nacos;namespaceId=dev-team\ncustom=value",
),
).toEqual({
connectionParams: "contextPath=%2Fnacos&custom=value",
scope: {
configured: true,
namespaceId: "dev-team",
requestNamespaceId: "dev-team",
},
});
});
});

View File

@@ -0,0 +1,73 @@
export const NACOS_NAMESPACE_ID_PARAM = "namespaceId";
export type NacosConnectionScope = {
configured: boolean;
namespaceId: string;
requestNamespaceId: string;
};
const normalizeNacosConnectionParamsText = (raw: unknown): string => {
let text = String(raw ?? "").trim();
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
return text
.replace(/^[?&]+/, "")
.replace(/[;\r\n]+/g, "&")
.trim();
};
const parseNacosConnectionParams = (raw: unknown): URLSearchParams =>
new URLSearchParams(normalizeNacosConnectionParamsText(raw));
export const resolveNacosConnectionScope = (
connectionParams: unknown,
): NacosConnectionScope => {
const params = parseNacosConnectionParams(connectionParams);
const namespaceId = String(
params.get(NACOS_NAMESPACE_ID_PARAM) ?? "",
).trim();
const configured = namespaceId !== "";
return {
configured,
namespaceId,
requestNamespaceId:
configured && namespaceId.toLowerCase() !== "public"
? namespaceId
: "",
};
};
export const setNacosConnectionScope = (
connectionParams: unknown,
namespaceId: unknown,
): string => {
const params = parseNacosConnectionParams(connectionParams);
const normalizedNamespaceId = String(namespaceId ?? "").trim();
if (normalizedNamespaceId) {
params.set(NACOS_NAMESPACE_ID_PARAM, normalizedNamespaceId);
} else {
params.delete(NACOS_NAMESPACE_ID_PARAM);
}
return params.toString();
};
export const extractNacosConnectionScope = (
connectionParams: unknown,
): {
connectionParams: string;
scope: NacosConnectionScope;
} => {
const params = parseNacosConnectionParams(connectionParams);
const scope = resolveNacosConnectionScope(params.toString());
params.delete(NACOS_NAMESPACE_ID_PARAM);
return {
connectionParams: params.toString(),
scope,
};
};