From 8c0fe3a6e7c7a7fa299a1aa050be47d95b8f3a42 Mon Sep 17 00:00:00 2001 From: Caisin Date: Sun, 7 Jun 2026 01:00:34 +0800 Subject: [PATCH] Expose forwarded database port for local testing (#803) Database connection context menus now show a copy action when the connection has enabled transport layers. The action starts or reuses the ordered transport chain, copies the final local forwarding port, and keeps runtime config cached so later disconnect cleanup can stop the created tunnel. Constraint: Local testing sometimes needs external tools to connect through DBX's generated proxy/tunnel endpoint Rejected: Copy full host:port endpoint | user specifically asked for the port and existing tunnel host is always localhost Rejected: Require an active pool before copying | local test workflows may need the tunnel before opening the DBX connection Confidence: high Scope-risk: moderate Tested: pnpm test -- packages/app-tests/connectionTransport.test.ts (ran full app-tests: 862 passed) Tested: pnpm typecheck Tested: pnpm exec oxlint --vue-plugin apps/desktop/src/components/sidebar/TreeItem.vue apps/desktop/src/lib/connectionTransport.ts apps/desktop/src/lib/api.ts apps/desktop/src/lib/tauri.ts apps/desktop/src/lib/http.ts Tested: cargo check -p dbx-core -p dbx-web Tested: cargo check -p dbx Tested: cargo fmt --check --all Co-authored-by: caisin --- .../src/components/sidebar/TreeItem.vue | 22 +++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 2 ++ apps/desktop/src/i18n/locales/es.ts | 2 ++ apps/desktop/src/i18n/locales/it.ts | 2 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 2 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 2 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 2 ++ apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/connectionTransport.ts | 5 +++++ apps/desktop/src/lib/http.ts | 4 ++++ apps/desktop/src/lib/tauri.ts | 4 ++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/connection.rs | 21 ++++++++++++++++-- .../app-tests/connectionTransport.test.ts | 20 +++++++++++++++++ src-tauri/src/commands/connection.rs | 18 +++++++++++++++ src-tauri/src/lib.rs | 1 + 16 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/connectionTransport.ts create mode 100644 packages/app-tests/connectionTransport.test.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 76cb6db73..4903f148f 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -136,6 +136,7 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { copyToClipboard } from "@/lib/clipboard"; +import { hasEnabledTransportLayers } from "@/lib/connectionTransport"; import { formatShortcut } from "@/lib/shortcutRegistry"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue"; @@ -929,6 +930,20 @@ async function copyName() { } } +async function copyFinalProxyPort() { + const connectionId = props.node.connectionId; + const config = connectionId ? connectionStore.getConfig(connectionId) : undefined; + if (!config || !hasEnabledTransportLayers(config)) return; + + try { + const port = await api.connectionFinalProxyPort(config); + await copyToClipboard(String(port)); + toast(t("contextMenu.finalProxyPortCopied", { port }), 2000); + } catch (e: any) { + toast(t("grid.copyFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + async function copySelectedNames() { const selectedNodes = selectedTreeNodesInVisibleOrder(); const nodes = @@ -2475,6 +2490,10 @@ const canConfigureVisibleDatabases = computed(() => { if (props.node.type !== "connection" || !props.node.connectionId) return false; return connectionStore.getConfig(props.node.connectionId)?.db_type !== "elasticsearch"; }); +const canCopyFinalProxyPort = computed(() => { + if (props.node.type !== "connection" || !props.node.connectionId) return false; + return hasEnabledTransportLayers(connectionStore.getConfig(props.node.connectionId)); +}); function connectionIconType(connectionId?: string) { const config = connectionId ? connectionStore.getConfig(connectionId) : undefined; @@ -2845,6 +2864,9 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug }); } items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); + if (canCopyFinalProxyPort.value) { + items.push({ label: t("contextMenu.copyFinalProxyPort"), action: copyFinalProxyPort, icon: Network }); + } if (canOpenSqlFileExecution.value) { items.push({ label: t("sqlFile.title"), action: openSqlFileExecution, icon: FileCode }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f56b4b379..6c24206c9 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -954,6 +954,8 @@ export default { closeOtherTabs: "Close Other Tabs", closeAllTabs: "Close All Tabs", copyName: "Copy Name", + copyFinalProxyPort: "Copy Final Proxy Port", + finalProxyPortCopied: "Final proxy port {port} copied", setDefaultDatabase: "Set as Default Database", clearDefaultDatabase: "Clear Default Database", exportDatabase: "Export Database", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 3af625553..e57b4bbe2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -850,6 +850,8 @@ export default { closeOtherTabs: "Cerrar otras pestañas", closeAllTabs: "Cerrar todas las pestañas", copyName: "Copiar nombre", + copyFinalProxyPort: "Copiar puerto proxy final", + finalProxyPortCopied: "Puerto proxy final {port} copiado", setDefaultDatabase: "Establecer como base de datos predeterminada", clearDefaultDatabase: "Limpiar base de datos predeterminada", exportDatabase: "Exportar base de datos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6523c684c..d02faca00 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -973,6 +973,8 @@ export default { closeOtherTabs: "Chiudi Altre Schede", closeAllTabs: "Chiudi Tutte le Schede", copyName: "Copia Nome", + copyFinalProxyPort: "Copia porta proxy finale", + finalProxyPortCopied: "Porta proxy finale {port} copiata", setDefaultDatabase: "Imposta come Database Predefinito", clearDefaultDatabase: "Cancella Database Predefinito", exportDatabase: "Esporta Database", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0dde849b2..fffd3a805 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -968,6 +968,8 @@ export default { closeTab: "Fechar Aba", closeOtherTabs: "Fechar Outras Abas", closeAllTabs: "Fechar Todas as Abas", + copyFinalProxyPort: "Copiar porta final do proxy", + finalProxyPortCopied: "Porta final do proxy {port} copiada", copyName: "Copiar Nome", setDefaultDatabase: "Definir como Banco de Dados Padrão", clearDefaultDatabase: "Limpar Banco de Dados Padrão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4e4c4a997..89578f1e5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -934,6 +934,8 @@ export default { closeOtherTabs: "关闭其他标签页", closeAllTabs: "关闭全部标签页", copyName: "复制名称", + copyFinalProxyPort: "复制最终代理端口", + finalProxyPortCopied: "最终代理端口 {port} 已复制", setDefaultDatabase: "设为默认数据库", clearDefaultDatabase: "取消默认数据库", exportDatabase: "导出数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0a1cc09a9..d00d082fb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -914,6 +914,8 @@ export default { closeOtherTabs: "關閉其他分頁", closeAllTabs: "關閉全部分頁", copyName: "複製名稱", + copyFinalProxyPort: "複製最終代理連接埠", + finalProxyPortCopied: "最終代理連接埠 {port} 已複製", setDefaultDatabase: "設為預設資料庫", clearDefaultDatabase: "取消預設資料庫", exportDatabase: "匯出資料庫", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 5d7066d14..63bcde895 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -33,6 +33,7 @@ function forward(name: K): Backend[K] { // Connection export const testConnection = forward("testConnection"); export const connectDb = forward("connectDb"); +export const connectionFinalProxyPort = forward("connectionFinalProxyPort"); export const disconnectDb = forward("disconnectDb"); export const closeDatabaseConnection = forward("closeDatabaseConnection"); export const refreshConnections = forward("refreshConnections"); diff --git a/apps/desktop/src/lib/connectionTransport.ts b/apps/desktop/src/lib/connectionTransport.ts new file mode 100644 index 000000000..5520d3008 --- /dev/null +++ b/apps/desktop/src/lib/connectionTransport.ts @@ -0,0 +1,5 @@ +import type { ConnectionConfig } from "@/types/database"; + +export function hasEnabledTransportLayers(connection?: Pick | null): boolean { + return !!connection?.transport_layers?.some((layer) => layer.enabled !== false); +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 17bc1e5b7..fc525ae33 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -151,6 +151,10 @@ export async function connectDb(config: ConnectionConfig): Promise { return post("/api/connection/connect", { config }); } +export async function connectionFinalProxyPort(config: ConnectionConfig): Promise { + return post("/api/connection/final-proxy-port", { config }); +} + export async function disconnectDb(connectionId: string): Promise { return post("/api/connection/disconnect", { connectionId }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 3a3fe3d51..a26e0ec07 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -414,6 +414,10 @@ export async function connectDb(config: ConnectionConfig): Promise { return invoke("connect_db", { config }); } +export async function connectionFinalProxyPort(config: ConnectionConfig): Promise { + return invoke("connection_final_proxy_port", { config }); +} + export async function disconnectDb(connectionId: string): Promise { return invoke("disconnect_db", { connectionId }); } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index b59781acf..e47cb7c2b 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -94,6 +94,7 @@ async fn main() { // Connection .route("/connection/test", post(routes::connection::test_connection)) .route("/connection/connect", post(routes::connection::connect_db)) + .route("/connection/final-proxy-port", post(routes::connection::connection_final_proxy_port)) .route("/connection/disconnect", post(routes::connection::disconnect_db)) .route("/connection/close-database", post(routes::connection::close_database_connection)) .route("/connection/save", post(routes::connection::save_connections)) diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 52e8477a8..66945466c 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -78,6 +78,24 @@ pub async fn connect_db( Ok(Json(connection_id)) } +pub async fn connection_final_proxy_port( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + let runtime_config = body.config.canonicalized(); + if !runtime_config.has_effective_transport_layers() { + return Err(AppError("Connection has no configured transport layers".to_string())); + } + + let app = &state.app; + let connection_id = runtime_config.id.clone(); + let db_config = dbx_core::connection::metadata_connection_config(&runtime_config); + app.configs.write().await.insert(connection_id.clone(), runtime_config); + + let (_, port) = app.connection_host_port(&connection_id, &db_config).await.map_err(AppError)?; + Ok(Json(port)) +} + pub async fn disconnect_db( State(state): State>, Json(body): Json, @@ -93,8 +111,7 @@ pub async fn disconnect_db( } drop(connections); - app.tunnels.stop_tunnel(&body.connection_id).await; - app.proxy_tunnels.stop_tunnel(&body.connection_id).await; + app.reset_connection_transport(&body.connection_id).await; Ok(Json(())) } diff --git a/packages/app-tests/connectionTransport.test.ts b/packages/app-tests/connectionTransport.test.ts new file mode 100644 index 000000000..6d7f95bcd --- /dev/null +++ b/packages/app-tests/connectionTransport.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { hasEnabledTransportLayers } from "../../apps/desktop/src/lib/connectionTransport.ts"; + +test("hasEnabledTransportLayers matches effective transport layer visibility", () => { + assert.equal(hasEnabledTransportLayers(undefined), false); + assert.equal(hasEnabledTransportLayers({ transport_layers: [] }), false); + assert.equal( + hasEnabledTransportLayers({ + transport_layers: [{ type: "proxy", id: "proxy", enabled: false, host: "127.0.0.1", port: 1080 }], + }), + false, + ); + assert.equal( + hasEnabledTransportLayers({ + transport_layers: [{ type: "proxy", id: "proxy", host: "127.0.0.1", port: 1080 }], + }), + true, + ); +}); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index f714149f1..e33d5ff36 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -573,6 +573,24 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi Ok(id) } +#[tauri::command] +pub async fn connection_final_proxy_port( + state: State<'_, Arc>, + config: ConnectionConfig, +) -> Result { + let runtime_config = config.canonicalized(); + if !runtime_config.has_effective_transport_layers() { + return Err("Connection has no configured transport layers".to_string()); + } + + let connection_id = runtime_config.id.clone(); + let db_config = metadata_connection_config(&runtime_config); + state.configs.write().await.insert(connection_id.clone(), runtime_config); + + let (_, port) = state.connection_host_port(&connection_id, &db_config).await?; + Ok(port) +} + #[tauri::command] pub async fn disconnect_db(state: State<'_, Arc>, connection_id: String) -> Result<(), String> { let mut conns = state.connections.write().await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 962ce8dcc..a32a1a0c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -363,6 +363,7 @@ pub fn run() { commands::cloud_sync::webdav_sync_download, commands::connection::test_connection, commands::connection::connect_db, + commands::connection::connection_final_proxy_port, commands::connection::disconnect_db, commands::connection::close_database_connection, commands::connection::refresh_connections,