diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index ff6f8ab153..d794c33c16 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -9,7 +9,14 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, SshTunnelConfig } from "@/types/database"; +import type { + ConnectionConfig, + DatabaseType, + JdbcDriverInfo, + ProxyTunnelConfig, + SshTunnelConfig, + TransportLayerConfig, +} from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -45,7 +52,28 @@ type DbOption = { value: string; label: string }; type DbCategory = { key: string; title: string; options: DbOption[] }; type DialogStep = "select" | "config"; type DbPickerView = "icon" | "list"; -type ConfigTab = "connection" | "advanced" | "tls" | "ssh" | "proxy"; +type ConfigTab = "connection" | "advanced" | "tls" | "transport"; + +type LegacyTransportFields = { + ssh_enabled?: boolean; + ssh_host?: string; + ssh_port?: number; + ssh_user?: string; + ssh_password?: string; + ssh_key_path?: string; + ssh_key_passphrase?: string; + ssh_expose_lan?: boolean; + ssh_connect_timeout_secs?: number; + ssh_tunnels?: SshTunnelConfig[]; + proxy_enabled?: boolean; + proxy_type?: "socks5" | "http"; + proxy_host?: string; + proxy_port?: number; + proxy_username?: string; + proxy_password?: string; +}; +type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields; +type ConnectionForm = Omit; const { t } = useI18n(); const { toast } = useToast(); @@ -71,7 +99,7 @@ const testResult = ref<{ ok: boolean; message: string } | null>(null); const editingId = ref(null); let testRunId = 0; -const defaultForm = (): Omit => ({ +const defaultForm = (): ConnectionForm => ({ name: "", db_type: "mysql", driver_profile: "mysql", @@ -83,24 +111,9 @@ const defaultForm = (): Omit => ({ password: "", database: undefined, color: "", - ssh_enabled: false, - ssh_host: "", - ssh_port: 22, - ssh_user: "", - ssh_password: "", - ssh_key_path: "", - ssh_key_passphrase: "", - ssh_expose_lan: false, - ssh_connect_timeout_secs: 5, - ssh_tunnels: [], + transport_layers: [], connect_timeout_secs: 5, query_timeout_secs: 30, - proxy_enabled: false, - proxy_type: "socks5", - proxy_host: "", - proxy_port: 1080, - proxy_username: "", - proxy_password: "", ssl: false, ca_cert_path: "", sysdba: false, @@ -149,7 +162,62 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { }; } -function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] { +function defaultProxyTunnel(): ProxyTunnelConfig { + return { + id: uuid(), + name: "", + enabled: true, + proxy_type: "socks5", + host: "", + port: 1080, + username: "", + password: "", + }; +} + +function normalizeProxyTunnel(layer: Partial): ProxyTunnelConfig { + return { + id: layer.id || uuid(), + name: layer.name || "", + enabled: layer.enabled !== false, + proxy_type: layer.proxy_type || "socks5", + host: layer.host || "", + port: Number(layer.port) || 1080, + username: layer.username || "", + password: layer.password || "", + }; +} + +function normalizeTransportLayer(layer: Partial): TransportLayerConfig { + if (layer.type === "proxy") { + return { type: "proxy", ...normalizeProxyTunnel(layer) }; + } + return { type: "ssh", ...normalizeSshTunnel(layer as Partial) }; +} + +function transportLayersForConfig(config: LegacyConnectionConfig): TransportLayerConfig[] { + if (config.transport_layers?.length) { + return config.transport_layers.map(normalizeTransportLayer); + } + const layers: TransportLayerConfig[] = sshLayersForConfig(config).map((hop) => ({ type: "ssh", ...hop })); + if (config.proxy_enabled || config.proxy_host || config.proxy_username || config.proxy_password) { + layers.push({ + type: "proxy", + ...normalizeProxyTunnel({ + id: "legacy-proxy", + enabled: true, + proxy_type: config.proxy_type || "socks5", + host: config.proxy_host || "", + port: config.proxy_port || 1080, + username: config.proxy_username || "", + password: config.proxy_password || "", + }), + }); + } + return layers; +} + +function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] { if (config.ssh_tunnels?.length) { return config.ssh_tunnels.map(normalizeSshTunnel); } @@ -180,8 +248,8 @@ function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] { } const form = ref(defaultForm()); -const selectedSshTunnelId = ref(null); -const draggedSshTunnelId = ref(null); +const selectedTransportLayerId = ref(null); +const draggedTransportLayerId = ref(null); const selectedType = ref("mysql"); const customDriverName = ref(""); const mongoUseUrl = ref(false); @@ -422,6 +490,7 @@ watch( () => props.editConfig, (config) => { if (config) { + const legacyConfig = config as LegacyConnectionConfig; const profile = profileForConfig(config); editingId.value = config.id; const profileConfig = driverProfiles[profile]; @@ -437,24 +506,9 @@ watch( password: config.password, database: config.database, color: config.color || "", - ssh_enabled: config.ssh_enabled || false, - ssh_host: config.ssh_host || "", - ssh_port: config.ssh_port || 22, - ssh_user: config.ssh_user || "", - ssh_password: config.ssh_password || "", - ssh_key_path: config.ssh_key_path || "", - ssh_key_passphrase: config.ssh_key_passphrase || "", - ssh_expose_lan: config.ssh_expose_lan || false, - ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, - ssh_tunnels: sshTunnelsForConfig(config), + transport_layers: transportLayersForConfig(legacyConfig), connect_timeout_secs: config.connect_timeout_secs || 5, query_timeout_secs: config.query_timeout_secs ?? 30, - proxy_enabled: config.proxy_enabled || false, - proxy_type: config.proxy_type || "socks5", - proxy_host: config.proxy_host || "", - proxy_port: config.proxy_port || 1080, - proxy_username: config.proxy_username || "", - proxy_password: config.proxy_password || "", ssl: config.ssl || false, ca_cert_path: config.ca_cert_path || "", sysdba: config.sysdba || isOracleSysUser(config), @@ -470,7 +524,7 @@ watch( redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", }; - selectedSshTunnelId.value = form.value.ssh_tunnels?.[0]?.id || null; + selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null; selectedType.value = profile; if (profile === "oceanbase") { oceanbaseSubMode.value = config.driver_profile === "oceanbase-oracle" ? "oracle" : "mysql"; @@ -486,7 +540,7 @@ watch( } else { editingId.value = null; form.value = defaultForm(); - selectedSshTunnelId.value = null; + selectedTransportLayerId.value = null; selectedType.value = "mysql"; customDriverName.value = ""; oceanbaseSubMode.value = "mysql"; @@ -516,16 +570,25 @@ const databasePlaceholder = computed(() => { return t("connection.databasePlaceholderWithDefault", { database: fallback }); }); -const sshTunnels = computed(() => form.value.ssh_tunnels || []); -const selectedSshTunnel = computed(() => { - const tunnels = sshTunnels.value; - return tunnels.find((hop) => hop.id === selectedSshTunnelId.value) || tunnels[0] || null; +const transportLayers = computed(() => form.value.transport_layers || []); +const selectedTransportLayer = computed(() => { + const layers = transportLayers.value; + return layers.find((layer) => layer.id === selectedTransportLayerId.value) || layers[0] || null; }); -const sshPathSegments = computed(() => { - const hops = sshTunnels.value.filter((hop) => hop.enabled !== false); +const selectedSshLayer = computed(() => + selectedTransportLayer.value?.type === "ssh" ? selectedTransportLayer.value : null, +); +const selectedProxyLayer = computed(() => + selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null, +); +const transportPathSegments = computed(() => { + const layers = transportLayers.value.filter((layer) => layer.enabled !== false); return [ "DBX", - ...hops.map((hop, index) => hop.name?.trim() || hop.host?.trim() || `SSH ${index + 1}`), + ...layers.map((layer, index) => { + const fallback = layer.type === "proxy" ? `Proxy ${index + 1}` : `SSH ${index + 1}`; + return layer.name?.trim() || layer.host?.trim() || fallback; + }), form.value.host || "Database", ]; }); @@ -784,10 +847,7 @@ const redisTlsInsecure = computed({ form.value.url_params = setUrlParam(form.value.url_params, "insecure", value ? "true" : ""); }, }); -const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access"); -const canUseProxy = computed( - () => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "access", -); +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), ); @@ -881,36 +941,23 @@ function generateConnectionName(): string { } function connectionConfigForSubmit(id: string): ConnectionConfig { - const config: ConnectionConfig = { ...form.value, id }; + const config = { ...form.value, id } as LegacyConnectionConfig; if (!config.name?.trim()) { config.name = generateConnectionName(); } - const sshTimeout = Number(config.ssh_connect_timeout_secs); - config.ssh_connect_timeout_secs = Number.isFinite(sshTimeout) && sshTimeout > 0 ? sshTimeout : 5; - config.ssh_tunnels = (config.ssh_tunnels || []).map((hop) => { - const normalized = normalizeSshTunnel(hop); + config.transport_layers = (config.transport_layers || []).map(normalizeTransportLayer); + config.transport_layers = config.transport_layers.map((layer) => { + if (layer.type !== "ssh") return layer; + const normalized = normalizeSshTunnel(layer); const timeout = Number(normalized.connect_timeout_secs); normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5; - return normalized; + return { type: "ssh", ...normalized }; }); - const firstSshTunnel = config.ssh_tunnels[0]; - if (firstSshTunnel) { - config.ssh_host = firstSshTunnel.host; - config.ssh_port = firstSshTunnel.port; - config.ssh_user = firstSshTunnel.user; - config.ssh_password = firstSshTunnel.password || ""; - config.ssh_key_path = firstSshTunnel.key_path || ""; - config.ssh_key_passphrase = firstSshTunnel.key_passphrase || ""; - config.ssh_expose_lan = !!firstSshTunnel.expose_lan; - config.ssh_connect_timeout_secs = firstSshTunnel.connect_timeout_secs || config.ssh_connect_timeout_secs; - } - validateSshTunnels(config); + validateTransportLayers(config); const connectTimeout = Number(config.connect_timeout_secs); config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 5; const queryTimeout = Number(config.query_timeout_secs); config.query_timeout_secs = Number.isFinite(queryTimeout) && queryTimeout >= 0 ? queryTimeout : 30; - const proxyPort = Number(config.proxy_port); - config.proxy_port = Number.isFinite(proxyPort) && proxyPort > 0 ? proxyPort : 1080; if (!config.one_time) config.one_time = undefined; if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; @@ -978,7 +1025,24 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { .map((path) => path.trim()) .filter(Boolean); } - return config; + const legacy = config as LegacyConnectionConfig; + delete legacy.ssh_enabled; + delete legacy.ssh_host; + delete legacy.ssh_port; + delete legacy.ssh_user; + delete legacy.ssh_password; + delete legacy.ssh_key_path; + delete legacy.ssh_key_passphrase; + delete legacy.ssh_expose_lan; + delete legacy.ssh_connect_timeout_secs; + delete legacy.ssh_tunnels; + delete legacy.proxy_enabled; + delete legacy.proxy_type; + delete legacy.proxy_host; + delete legacy.proxy_port; + delete legacy.proxy_username; + delete legacy.proxy_password; + return config as ConnectionConfig; } function getUrlParam(params: string | undefined, key: string): string { @@ -1174,8 +1238,8 @@ async function copyTestResult() { function resetForm() { editingId.value = null; form.value = defaultForm(); - selectedSshTunnelId.value = null; - draggedSshTunnelId.value = null; + selectedTransportLayerId.value = null; + draggedTransportLayerId.value = null; selectedType.value = "mysql"; customDriverName.value = ""; mongoUseUrl.value = false; @@ -1290,14 +1354,8 @@ watch([() => form.value.db_type, () => form.value.username], () => { if (isOracleSysUser(form.value)) form.value.sysdba = true; }); -watch(canUseSsh, (value) => { - if (!value && configTab.value === "ssh") { - configTab.value = "connection"; - } -}); - -watch(canUseProxy, (value) => { - if (!value && configTab.value === "proxy") { +watch(canUseTransportLayers, (value) => { + if (!value && configTab.value === "transport") { configTab.value = "connection"; } }); @@ -1308,76 +1366,105 @@ watch(supportsTlsToggle, (value) => { } }); -function ensureSelectedSshTunnel() { - if (!selectedSshTunnelId.value || !sshTunnels.value.some((hop) => hop.id === selectedSshTunnelId.value)) { - selectedSshTunnelId.value = sshTunnels.value[0]?.id || null; +function ensureSelectedTransportLayer() { + if ( + !selectedTransportLayerId.value || + !transportLayers.value.some((layer) => layer.id === selectedTransportLayerId.value) + ) { + selectedTransportLayerId.value = transportLayers.value[0]?.id || null; } } function addSshTunnel() { - const next = defaultSshTunnel(); - next.name = t("connection.sshHopDefaultName", { index: sshTunnels.value.length + 1 }); - form.value.ssh_tunnels = [...sshTunnels.value, next]; - form.value.ssh_enabled = true; - selectedSshTunnelId.value = next.id; + const next: TransportLayerConfig = { type: "ssh", ...defaultSshTunnel() }; + next.name = t("connection.sshHopDefaultName", { index: transportLayers.value.length + 1 }); + form.value.transport_layers = [...transportLayers.value, next]; + selectedTransportLayerId.value = next.id; resetTestState(); } -function duplicateSshTunnel(hop: SshTunnelConfig) { - const next = { ...normalizeSshTunnel(hop), id: uuid(), name: hop.name ? `${hop.name} copy` : "" }; - form.value.ssh_tunnels = [...sshTunnels.value, next]; - selectedSshTunnelId.value = next.id; +function addProxyTunnel() { + const next: TransportLayerConfig = { type: "proxy", ...defaultProxyTunnel() }; + next.name = `Proxy ${transportLayers.value.length + 1}`; + form.value.transport_layers = [...transportLayers.value, next]; + selectedTransportLayerId.value = next.id; resetTestState(); } -function removeSshTunnel(id: string) { - form.value.ssh_tunnels = sshTunnels.value.filter((hop) => hop.id !== id); - ensureSelectedSshTunnel(); +function duplicateTransportLayer(layer: TransportLayerConfig) { + const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" }); + form.value.transport_layers = [...transportLayers.value, next]; + selectedTransportLayerId.value = next.id; resetTestState(); } -function moveSshTunnel(id: string, direction: -1 | 1) { - const tunnels = [...sshTunnels.value]; - const index = tunnels.findIndex((hop) => hop.id === id); +function removeTransportLayer(id: string) { + form.value.transport_layers = transportLayers.value.filter((layer) => layer.id !== id); + ensureSelectedTransportLayer(); + resetTestState(); +} + +function moveTransportLayer(id: string, direction: -1 | 1) { + const layers = [...transportLayers.value]; + const index = layers.findIndex((layer) => layer.id === id); const target = index + direction; - if (index < 0 || target < 0 || target >= tunnels.length) return; - [tunnels[index], tunnels[target]] = [tunnels[target], tunnels[index]]; - form.value.ssh_tunnels = tunnels; + if (index < 0 || target < 0 || target >= layers.length) return; + [layers[index], layers[target]] = [layers[target], layers[index]]; + form.value.transport_layers = layers; resetTestState(); } -function dropSshTunnel(targetId: string) { - const sourceId = draggedSshTunnelId.value; - draggedSshTunnelId.value = null; +function dropTransportLayer(targetId: string) { + const sourceId = draggedTransportLayerId.value; + draggedTransportLayerId.value = null; if (!sourceId || sourceId === targetId) return; - const tunnels = [...sshTunnels.value]; - const sourceIndex = tunnels.findIndex((hop) => hop.id === sourceId); - const targetIndex = tunnels.findIndex((hop) => hop.id === targetId); + const layers = [...transportLayers.value]; + const sourceIndex = layers.findIndex((layer) => layer.id === sourceId); + const targetIndex = layers.findIndex((layer) => layer.id === targetId); if (sourceIndex < 0 || targetIndex < 0) return; - const [source] = tunnels.splice(sourceIndex, 1); - tunnels.splice(targetIndex, 0, source); - form.value.ssh_tunnels = tunnels; + const [source] = layers.splice(sourceIndex, 1); + layers.splice(targetIndex, 0, source); + form.value.transport_layers = layers; resetTestState(); } -function validateSshTunnels(config: ConnectionConfig) { - if (!config.ssh_enabled) return; - const tunnels = config.ssh_tunnels || []; - tunnels.forEach((hop, index) => { - if (hop.enabled === false) return; - const label = hop.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 }); - if (!hop.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label })); - if (!hop.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label })); - const port = Number(hop.port); +function changeSelectedTransportLayerType(type: "ssh" | "proxy") { + const selected = selectedTransportLayer.value; + if (!selected || selected.type === type) return; + const replacement: TransportLayerConfig = + type === "proxy" + ? { type: "proxy", ...defaultProxyTunnel(), id: selected.id, name: selected.name } + : { type: "ssh", ...defaultSshTunnel(), id: selected.id, name: selected.name }; + form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? replacement : layer)); + resetTestState(); +} + +function updateSelectedProxyType(value: unknown) { + const layer = selectedProxyLayer.value; + if (!layer) return; + layer.proxy_type = value === "http" ? "http" : "socks5"; + resetTestState(); +} + +function validateTransportLayers(config: LegacyConnectionConfig) { + const layers = config.transport_layers || []; + layers.forEach((layer, index) => { + if (layer.enabled === false) return; + const label = layer.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 }); + if (!layer.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label })); + const port = Number(layer.port); if (!Number.isFinite(port) || port < 1 || port > 65535) { throw new Error(t("connection.sshHopInvalidPort", { hop: label })); } - if (!hop.password?.trim() && !hop.key_path?.trim()) { - throw new Error(t("connection.sshHopInvalidAuth", { hop: label })); - } - const timeout = Number(hop.connect_timeout_secs); - if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) { - throw new Error(t("connection.sshHopInvalidTimeout", { hop: label })); + if (layer.type === "ssh") { + if (!layer.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label })); + if (!layer.password?.trim() && !layer.key_path?.trim()) { + throw new Error(t("connection.sshHopInvalidAuth", { hop: label })); + } + const timeout = Number(layer.connect_timeout_secs); + if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) { + throw new Error(t("connection.sshHopInvalidTimeout", { hop: label })); + } } }); } @@ -1436,8 +1523,6 @@ async function browseSshKeyPath(target?: SshTunnelConfig | null) { if (selected && typeof selected === "string") { if (target) { target.key_path = selected; - } else { - form.value.ssh_key_path = selected; } } } @@ -1761,8 +1846,9 @@ function openExternalUrl(url: string) { {{ t("connection.basicTab") }} {{ t("connection.tlsTab") }} - {{ t("connection.sshTunnel") }} - {{ t("connection.proxy") }} + {{ + t("connection.sshTunnel") + }} {{ t("connection.advancedTab") }} @@ -2640,50 +2726,43 @@ function openExternalUrl(url: string) { - +
-
- - -
-
-