mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-15 11:14:31 +08:00
🐛 fix(nacos-connection): 支持受限命名空间与权限保护
- 在连接表单中独立维护 Namespace ID 与 contextPath - 为无命名空间列表权限的账号回退到已配置作用域 - 细分数据编辑、结构管理与导入保护并阻止陈旧操作 - 补齐侧边栏请求代际和连接 URI 回归测试
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
57
frontend/src/utils/nacosConnectionScope.test.ts
Normal file
57
frontend/src/utils/nacosConnectionScope.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
73
frontend/src/utils/nacosConnectionScope.ts
Normal file
73
frontend/src/utils/nacosConnectionScope.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user