From 977e7f78ca6db7eda8a0bfa63c9c0259afbcf1c2 Mon Sep 17 00:00:00 2001 From: rainhan Date: Sun, 12 Jul 2026 22:03:46 +0800 Subject: [PATCH] feat(connection): add reusable tunnel profiles --- apps/desktop/src/App.vue | 4 + .../connection/ConnectionDialog.vue | 78 +++++- .../connection/TunnelProfileManager.vue | 252 ++++++++++++++++++ .../editor/EditorSettingsDialog.vue | 8 +- .../src/components/layout/AppDialogs.vue | 2 + apps/desktop/src/i18n/locales/en.ts | 21 ++ apps/desktop/src/i18n/locales/es.ts | 21 ++ apps/desktop/src/i18n/locales/it.ts | 21 ++ apps/desktop/src/i18n/locales/ja.ts | 21 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 21 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 21 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 21 ++ .../connection/tunnelProfiles.spec.ts | 83 ++++++ apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 9 + apps/desktop/src/lib/backend/tauri.ts | 9 + .../src/lib/connection/tunnelProfiles.ts | 95 +++++++ apps/desktop/src/stores/connectionStore.ts | 30 ++- apps/desktop/src/stores/tunnelProfileStore.ts | 48 ++++ apps/desktop/src/types/database.ts | 18 ++ crates/dbx-core/src/cloud_sync.rs | 84 +++++- crates/dbx-core/src/connection.rs | 192 ++++++++++++- crates/dbx-core/src/connection_secrets.rs | 2 + crates/dbx-core/src/db/ssh_tunnel.rs | 1 + .../dbx-core/src/db/transport_layer_tunnel.rs | 3 + crates/dbx-core/src/models/connection.rs | 72 +++++ crates/dbx-core/src/ssh_config.rs | 1 + crates/dbx-core/src/storage.rs | 163 ++++++++++- crates/dbx-web/src/main.rs | 3 + crates/dbx-web/src/routes/mod.rs | 1 + crates/dbx-web/src/routes/tunnel_profiles.rs | 28 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/tunnel_profiles.rs | 19 ++ src-tauri/src/lib.rs | 2 + 34 files changed, 1335 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/components/connection/TunnelProfileManager.vue create mode 100644 apps/desktop/src/lib/__tests__/connection/tunnelProfiles.spec.ts create mode 100644 apps/desktop/src/lib/connection/tunnelProfiles.ts create mode 100644 apps/desktop/src/stores/tunnelProfileStore.ts create mode 100644 crates/dbx-web/src/routes/tunnel_profiles.rs create mode 100644 src-tauri/src/commands/tunnel_profiles.rs diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9a80aa6a3..269eef243 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -2074,6 +2074,10 @@ onUnmounted(() => { setConnectionDialogOpen(false); openDriverStorePage(); " + @open-tunnel-profile-settings=" + setConnectionDialogOpen(false); + openSettings('tunnels'); + " @open-lineage-target="openLineageTarget" @open-database-search-target="openDatabaseSearchTarget" @open-diagram-target="openDiagramTarget" diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 5328cbcc3..a1f65da48 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -18,6 +18,8 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; +import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; +import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; @@ -114,9 +116,11 @@ const emit = defineEmits<{ connectSucceeded: [name: string]; connectFailed: [message: string]; openDriverStore: []; + openTunnelProfileSettings: []; }>(); const store = useConnectionStore(); +const tunnelProfileStore = useTunnelProfileStore(); const isTesting = ref(false); const isSaving = ref(false); const testResult = ref<{ ok: boolean; message: string } | null>(null); @@ -251,6 +255,7 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { use_ssh_agent: !!hop.use_ssh_agent, ssh_agent_sock_path: hop.ssh_agent_sock_path || "", auth_method: hop.auth_method || inferSshAuthMethod(hop), + profile_id: hop.profile_id || undefined, }; } @@ -288,6 +293,7 @@ function normalizeProxyTunnel(layer: Partial): ProxyTunnelCon port: Number(layer.port) || 1080, username: layer.username || "", password: layer.password || "", + profile_id: layer.profile_id || undefined, }; } @@ -299,6 +305,7 @@ function normalizeHttpTunnel(layer: Partial): HttpTunnelConfig url: layer.url || "", token: layer.token || "", connect_timeout_secs: Number(layer.connect_timeout_secs) || 10, + profile_id: layer.profile_id || undefined, }; } @@ -1534,6 +1541,32 @@ const selectedSshLayer = computed(() => (selectedTransportLayer.value?.type === const selectedProxyLayer = computed(() => (selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null)); const selectedHttpTunnelLayer = computed(() => (selectedTransportLayer.value?.type === "http_tunnel" ? selectedTransportLayer.value : null)); +const tunnelProfiles = computed(() => tunnelProfileStore.profiles); +const selectedLayerProfileId = computed(() => selectedTransportLayer.value?.profile_id || ""); +const selectedLayerProfile = computed(() => tunnelProfileStore.profileById(selectedLayerProfileId.value)); + +function tunnelProfileOptionLabel(profile: (typeof tunnelProfiles.value)[number]): string { + const summary = tunnelProfileSummary(profile); + if (!profile.name?.trim()) return summary || profile.id; + return summary ? `${profile.name} (${summary})` : profile.name; +} + +function applyTunnelProfileSelection(value: unknown) { + const selected = selectedTransportLayer.value; + if (!selected) return; + if (!value || value === "custom") { + if (!selected.profile_id) return; + const detached = detachTunnelProfileLayer(selected, tunnelProfileStore.profileById(selected.profile_id)); + form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? detached : layer)); + } else { + const profile = tunnelProfileStore.profileById(String(value)); + if (!profile) return; + const stub = tunnelProfileReferenceLayer(profile, selected); + form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? stub : layer)); + } + resetTestState(); +} + function transportLayerDefaultName(layer: TransportLayerConfig, index: number): string { if (layer.type === "proxy") return `Proxy ${index + 1}`; if (layer.type === "http_tunnel") return t("connection.httpTunnelDefaultName", { index: index + 1 }); @@ -1541,6 +1574,11 @@ function transportLayerDefaultName(layer: TransportLayerConfig, index: number): } function transportLayerDisplayName(layer: TransportLayerConfig, index: number): string { + if (layer.profile_id) { + const profile = tunnelProfileStore.profileById(layer.profile_id); + if (profile) return profile.name?.trim() || tunnelProfileSummary(profile) || transportLayerDefaultName(layer, index); + return layer.name?.trim() || t("connection.tunnelProfileMissingName"); + } const target = layer.type === "http_tunnel" ? layer.url?.trim() : layer.host?.trim(); return layer.name?.trim() || target || transportLayerDefaultName(layer, index); } @@ -3306,6 +3344,9 @@ function validateTransportLayers(config: LegacyConnectionConfig) { const layers = config.transport_layers || []; layers.forEach((layer, index) => { if (layer.enabled === false) return; + // Profile-referencing layers are stubs: the shared profile supplies the + // whole configuration at connect time, so there is nothing to validate. + if (layer.profile_id) return; const label = layer.name?.trim() || transportLayerDefaultName(layer, index); if (layer.type === "http_tunnel") { if (index !== 0) throw new Error(t("connection.httpTunnelInvalidOrder", { hop: label })); @@ -3713,6 +3754,7 @@ function onJdbcDriverSelect(id: any) { } onMounted(async () => { + void tunnelProfileStore.init(); unlistenAgentInstallProgress = await api.listenAgentInstallProgress(handleAgentInstallProgress); }); @@ -5372,7 +5414,35 @@ function openExternalUrl(url: string) { -
+
+ +
+ + +
+
+
+ +
+ + {{ t("connection.tunnelProfileMissing") }} +
+
+
-