diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 96c7e2284..f372b777f 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -35,6 +35,7 @@ import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/con import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation"; import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection"; import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection"; +import { setZooKeeperAuthScheme, zooKeeperAuthScheme as resolveZooKeeperAuthScheme, type ZooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions"; import { isLocalFileTypeDb } from "@/lib/connection/connectionFile"; import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions"; import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions"; @@ -2662,6 +2663,13 @@ const zookeeperConnectString = computed({ form.value.connection_string = normalizeZooKeeperConnectString(value); }, }); +const zookeeperAuthScheme = computed({ + get: () => resolveZooKeeperAuthScheme(form.value.url_params), + set: (scheme) => { + form.value.url_params = setZooKeeperAuthScheme(form.value.url_params, scheme); + resetTestState(); + }, +}); const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value && !(form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns")); const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile)); const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true); @@ -4877,6 +4885,10 @@ function openExternalUrl(url: string) { +
+ +

{{ t("connection.zookeeperClusterInputHint") }}

+
@@ -5697,6 +5709,18 @@ function openExternalUrl(url: string) {

+
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 36794615f..47b058376 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -348,7 +348,11 @@ export default { etcdClientKeyBrowse: "Choose client private key", etcdClientCertPairRequired: "Client certificate and private key must be provided together.", zookeeperConnectString: "Connect String", - zookeeperConnectStringHint: "Comma or line separated host:port entries. Leave blank to use the host and port above.", + zookeeperConnectStringHint: "Comma or line separated host:port entries, with an optional shared /chroot. Leave blank to use the host and port above.", + zookeeperClusterInputHint: "For a ZooKeeper ensemble, enter all nodes either in the URL (for example zookeeper://zk-1:2181,zk-2:2181/app) or in Connect String below.", + zookeeperAuthMethod: "Authentication", + zookeeperAuthDigest: "Digest (default)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", zookeeperCreateModePersistent: "Persistent", zookeeperCreateModeEphemeral: "Ephemeral", zookeeperCreateModePersistentSequential: "Persistent Sequential", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fc8b6986f..c8c47b8cf 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -703,6 +703,10 @@ export default withEnglishFallback({ damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Una propiedad -Dkey o -Dkey=value por línea. Los valores con espacios no necesitan comillas del shell.", damengJvmOptionsInvalid: "La línea {line} debe ser una propiedad -Dkey o -Dkey=value sin comillas del shell.", + zookeeperClusterInputHint: "El clúster ZooKeeper puede completarse con todos los nodos en la URL superior (por ejemplo, zookeeper://zk-1:2181,zk-2:2181/app), o en el Connect String a continuación.", + zookeeperAuthMethod: "Método de autenticación", + zookeeperAuthDigest: "Digest (predeterminado)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", }, editor: { duckdbDraining: "La consulta anterior de DuckDB aún se está deteniendo. Vuelva a intentarlo en breve.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 73ee77146..4773deaf9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -701,6 +701,10 @@ export default withEnglishFallback({ damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Una proprietà -Dkey o -Dkey=value per riga. I valori con spazi non richiedono virgolette della shell.", damengJvmOptionsInvalid: "La riga {line} deve essere una proprietà -Dkey o -Dkey=value senza virgolette della shell.", + zookeeperClusterInputHint: "Il cluster ZooKeeper può essere specificato inserendo tutti i nodi nell'URL superiore (ad esempio zookeeper://zk-1:2181,zk-2:2181/app), oppure nella stringa di connessione sottostante.", + zookeeperAuthMethod: "Metodo di autenticazione", + zookeeperAuthDigest: "Digest (predefinito)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", }, editor: { duckdbDraining: "La query DuckDB precedente è ancora in fase di arresto. Riprova a breve.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8e8a33a80..3691157df 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -722,6 +722,10 @@ export default withEnglishFallback({ httpTunnelInvalidOrder: "{hop}: HTTP トンネルは最初のトンネル/プロキシレイヤーに配置する必要があります", httpTunnelInvalidUrl: "{hop}: トンネルスクリプト URL は必須です", httpTunnelInvalidTimeout: "{hop}: HTTP トンネルのタイムアウト時間は 1 から 300 秒の間で設定してください", + zookeeperClusterInputHint: "ZooKeeperクラスタは、上部のURLにすべてのノードを記入(例:zookeeper://zk-1:2181,zk-2:2181/app)、または下部のConnect Stringに記入できます。", + zookeeperAuthMethod: "認証方式", + zookeeperAuthDigest: "Digest(デフォルト)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", }, editor: { duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 06963fc9f..edd258e93 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -702,6 +702,10 @@ export default withEnglishFallback({ damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Uma propriedade -Dkey ou -Dkey=value por linha. Valores com espaços não precisam de aspas do shell.", damengJvmOptionsInvalid: "A linha {line} deve ser uma propriedade -Dkey ou -Dkey=value sem aspas do shell.", + zookeeperClusterInputHint: "O cluster ZooKeeper pode ser preenchido com todos os nós na URL acima (por exemplo, zookeeper://zk-1:2181,zk-2:2181/app), ou também na Connect String abaixo.", + zookeeperAuthMethod: "Método de autenticação", + zookeeperAuthDigest: "Digest (padrão)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", }, editor: { duckdbDraining: "A consulta anterior do DuckDB ainda está sendo interrompida. Tente novamente em breve.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b8b0f1db0..fc5a2e5d0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -350,7 +350,11 @@ export default withEnglishFallback({ etcdClientKeyBrowse: "选择客户端私钥", etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。", zookeeperConnectString: "Connect String", - zookeeperConnectStringHint: "可用逗号或换行分隔 host:port;留空时使用上面的 host 和端口。", + zookeeperConnectStringHint: "可用逗号或换行分隔 host:port,并可在末尾填写整个集群共用的 /chroot;留空时使用上面的 host 和端口。", + zookeeperClusterInputHint: "ZooKeeper 集群可在上方 URL 中填写全部节点(例如 zookeeper://zk-1:2181,zk-2:2181/app),也可在下方 Connect String 中填写。", + zookeeperAuthMethod: "认证方式", + zookeeperAuthDigest: "Digest(默认)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", zookeeperCreateModePersistent: "持久节点", zookeeperCreateModeEphemeral: "临时节点", zookeeperCreateModePersistentSequential: "持久顺序节点", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 82fb0b103..73d9e7605 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -701,6 +701,10 @@ export default withEnglishFallback({ damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "每行一個 -Dkey 或 -Dkey=value;值包含空格時也不要使用 shell 引號。", damengJvmOptionsInvalid: "第 {line} 行必須是未使用 shell 引號的 -Dkey 或 -Dkey=value 系統屬性。", + zookeeperClusterInputHint: "ZooKeeper 叢集可在上方 URL 中填寫全部節點(例如 zookeeper://zk-1:2181,zk-2:2181/app),也可在下方 Connect String 中填寫。", + zookeeperAuthMethod: "認證方式", + zookeeperAuthDigest: "Digest(預設)", + zookeeperAuthSaslDigest: "SASL DIGEST-MD5", }, editor: { duckdbDraining: "上一筆 DuckDB 查詢仍在停止,請稍後重試。", diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrl.zookeeper.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrl.zookeeper.spec.ts index efd1c0683..1a2f835bd 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionUrl.zookeeper.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrl.zookeeper.spec.ts @@ -2,7 +2,56 @@ import { describe, expect, it } from "vitest"; import { parseConnectionUrl } from "@/lib/connection/connectionUrl"; describe("ZooKeeper connection URLs", () => { + it("parses a two-node ensemble and uses the first endpoint for host and port", () => { + const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182"); + + expect(parsed.host).toBe("zk-1"); + expect(parsed.port).toBe(2181); + expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182"); + }); + + it("parses three nodes and preserves query parameters except name", () => { + const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182,zk-3:2183?session_timeout=30000&name=Production"); + + expect(parsed.name).toBe("Production"); + expect(parsed.urlParams).toBe("session_timeout=30000"); + expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182,zk-3:2183"); + }); + + it("preserves a shared chroot separately from query parameters", () => { + const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2181/services/app?connect_timeout=5000"); + + expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/services/app"); + expect(parsed.urlParams).toBe("connect_timeout=5000"); + }); + + it("decodes credentials that apply to the ensemble", () => { + const parsed = parseConnectionUrl("zookeeper://dbx%40ops:p%40ss@zk-1:2181,zk-2:2181/app"); + + expect(parsed.username).toBe("dbx@ops"); + expect(parsed.password).toBe("p@ss"); + expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/app"); + }); + + it("supports bracketed IPv6 endpoints without confusing address colons with separators", () => { + const parsed = parseConnectionUrl("zookeeper://[2001:db8::1]:2181,[2001:db8::2]:2281/app"); + + expect(parsed.host).toBe("2001:db8::1"); + expect(parsed.port).toBe(2181); + expect(parsed.connectionString).toBe("[2001:db8::1]:2181,[2001:db8::2]:2281/app"); + }); + + it("rejects malformed or empty ensemble endpoints", () => { + expect(() => parseConnectionUrl("zookeeper://zk-1:2181,,zk-2:2181")).toThrow("Invalid connection URL"); + expect(() => parseConnectionUrl("zookeeper://zk-1:not-a-port,zk-2:2181")).toThrow("Invalid connection URL"); + expect(() => parseConnectionUrl("zookeeper://2001:db8::1:2181,zk-2:2181")).toThrow("Invalid connection URL"); + }); + it("preserves the host:port chroot path as a ZooKeeper connect string", () => { - expect(parseConnectionUrl("zookeeper://zk-main:2181/app").connectionString).toBe("zk-main:2181/app"); + const parsed = parseConnectionUrl("zookeeper://zk-main:2181/app"); + + expect(parsed.host).toBe("zk-main"); + expect(parsed.port).toBe(2181); + expect(parsed.connectionString).toBe("zk-main:2181/app"); }); }); diff --git a/apps/desktop/src/lib/__tests__/connection/zookeeperConnectionDialog.spec.ts b/apps/desktop/src/lib/__tests__/connection/zookeeperConnectionDialog.spec.ts new file mode 100644 index 000000000..76ec7dde9 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/zookeeperConnectionDialog.spec.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); +const enSource = readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8"); +const zhCnSource = readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8"); +const fallbackLocaleSources = ["es", "it", "ja", "ko", "pt-BR", "zh-TW"].map((locale) => readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8")); + +describe("ZooKeeper connection dialog", () => { + it("binds the existing Select component to the ZooKeeper auth_scheme helper", () => { + expect(dialogSource).toContain("setZooKeeperAuthScheme"); + expect(dialogSource).toContain("resolveZooKeeperAuthScheme"); + expect(dialogSource).toContain("const zookeeperAuthScheme = computed"); + expect(dialogSource).toContain('