diff --git a/apps/desktop/public/icons/database/etcd.svg b/apps/desktop/public/icons/database/etcd.svg new file mode 100644 index 000000000..a36ae073d --- /dev/null +++ b/apps/desktop/public/icons/database/etcd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e3a84dfd4..2032217ea 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -118,6 +118,8 @@ const defaultForm = (): ConnectionForm => ({ query_timeout_secs: 30, ssl: false, ca_cert_path: "", + client_cert_path: "", + client_key_path: "", sysdba: false, oracle_connection_type: "service_name", connection_string: undefined, @@ -130,6 +132,7 @@ const defaultForm = (): ConnectionForm => ({ redis_sentinel_password: "", redis_sentinel_tls: false, redis_cluster_nodes: "", + etcd_endpoints: "", }); function defaultSshTunnel(): SshTunnelConfig { @@ -405,6 +408,7 @@ const driverProfiles: Record< tdengine: { type: "tdengine", port: 6041, user: "root", label: "TDengine", icon: "tdengine" }, xugu: { type: "xugu", port: 5138, user: "", label: "虚谷 XuguDB", icon: "xugu" }, iotdb: { type: "iotdb", port: 6667, user: "root", label: "Apache IoTDB", icon: "iotdb" }, + etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" }, iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" }, custom_mysql: { type: "mysql", @@ -515,6 +519,8 @@ watch( query_timeout_secs: config.query_timeout_secs ?? 30, ssl: config.ssl || false, ca_cert_path: config.ca_cert_path || "", + client_cert_path: config.client_cert_path || "", + client_key_path: config.client_key_path || "", sysdba: config.sysdba || isOracleSysUser(config), oracle_connection_type: config.oracle_connection_type || "service_name", connection_string: config.connection_string, @@ -527,6 +533,7 @@ watch( redis_sentinel_password: config.redis_sentinel_password || "", redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", + etcd_endpoints: config.etcd_endpoints || "", }; selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null; selectedType.value = profile; @@ -664,6 +671,7 @@ const iconTypeMap: Record = { tdengine: "tdengine", xugu: "xugu", iotdb: "iotdb", + etcd: "etcd", dm: "dm", h2: "h2", snowflake: "snowflake", @@ -737,6 +745,7 @@ const dbOptions = [ { value: "sundb", label: "SunDB" }, { value: "xugu", label: "虚谷 XuguDB" }, { value: "iotdb", label: "Apache IoTDB" }, + { value: "etcd", label: "etcd" }, { value: "iris", label: "IRIS" }, { value: "jdbc", label: "JDBC" }, { value: "custom_mysql", label: "Custom (MySQL)" }, @@ -789,6 +798,7 @@ const tlsCapableDatabaseTypes = new Set([ "kwdb", "opengauss", "redis", + "etcd", "clickhouse", "elasticsearch", ]); @@ -856,6 +866,12 @@ const redisTlsInsecure = computed({ form.value.url_params = setUrlParam(form.value.url_params, "insecure", value ? "true" : ""); }, }); +const etcdEndpointsLines = computed({ + get: () => form.value.etcd_endpoints || "", + set: (value: string) => { + form.value.etcd_endpoints = normalizeEndpointLines(value); + }, +}); const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access"); const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile), @@ -1019,7 +1035,25 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.redis_sentinel_tls = undefined; config.redis_cluster_nodes = undefined; } - if (config.db_type !== "mysql" && config.db_type !== "clickhouse") { + if (config.db_type === "etcd") { + config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || ""); + const firstEndpoint = firstEtcdEndpoint(config.etcd_endpoints); + if (firstEndpoint) { + config.host = firstEndpoint.host; + config.port = firstEndpoint.port; + config.ssl = firstEndpoint.scheme === "https" || !!config.ssl; + } + config.client_cert_path = config.client_cert_path?.trim() || ""; + config.client_key_path = config.client_key_path?.trim() || ""; + if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) { + throw new Error(t("connection.etcdClientCertPairRequired")); + } + } else { + config.etcd_endpoints = undefined; + config.client_cert_path = undefined; + config.client_key_path = undefined; + } + if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd") { config.ca_cert_path = undefined; } else { config.ca_cert_path = config.ca_cert_path?.trim() || ""; @@ -1172,6 +1206,10 @@ function normalizeRedisClusterNodes(value: string): string { } function normalizeRedisNodeList(value: string): string { + return normalizeEndpointLines(value); +} + +function normalizeEndpointLines(value: string): string { return value .split(/[\n,;]+/) .map((node) => node.trim()) @@ -1218,6 +1256,36 @@ function parseRedisEndpoint(value: string, defaultPort: number): { host: string; return { host: endpoint, port: defaultPort }; } +function firstEtcdEndpoint(value?: string): { scheme?: string; host: string; port: number } | null { + const first = normalizeEndpointLines(value || "") + .split("\n") + .find(Boolean); + if (!first) return null; + return parseEtcdEndpoint(first); +} + +function parseEtcdEndpoint(value: string): { scheme?: string; host: string; port: number } { + const trimmed = value.trim().replace(/^.*@/, ""); + const schemeMatch = trimmed.match(/^(https?):\/\//i); + const scheme = schemeMatch?.[1].toLowerCase(); + const endpoint = trimmed.replace(/^https?:\/\//i, "").replace(/[/?#].*$/, ""); + if (endpoint.startsWith("[")) { + const end = endpoint.indexOf("]"); + if (end > 0) { + const host = endpoint.slice(1, end); + const portText = endpoint.slice(end + 1).replace(/^:/, ""); + const port = Number(portText); + return { scheme, host, port: Number.isFinite(port) && port > 0 ? port : 2379 }; + } + } + const parts = endpoint.split(":"); + if (parts.length === 2) { + const port = Number(parts[1]); + return { scheme, host: parts[0], port: Number.isFinite(port) && port > 0 ? port : 2379 }; + } + return { scheme, host: endpoint, port: 2379 }; +} + function isOracleSysUser(config: Pick): boolean { return config.db_type === "oracle" && config.username.trim().toLowerCase() === "sys"; } @@ -1600,6 +1668,34 @@ async function browsePostgresTlsFile(target: "root" | "cert" | "key") { } } +async function browseEtcdTlsFile(target: "ca" | "cert" | "key") { + if (isTauriRuntime()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: + target === "ca" + ? t("connection.etcdCaCertBrowse") + : target === "cert" + ? t("connection.etcdClientCertBrowse") + : t("connection.etcdClientKeyBrowse"), + multiple: false, + filters: [ + { name: "PEM", extensions: ["pem", "crt", "cer", "key"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + if (selected && typeof selected === "string") { + if (target === "ca") { + form.value.ca_cert_path = selected; + } else if (target === "cert") { + form.value.client_cert_path = selected; + } else { + form.value.client_key_path = selected; + } + } + } +} + async function browseDbFilePath() { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -2222,6 +2318,37 @@ function openExternalUrl(url: string) { + +