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 <caisin@caisins-Mac-mini.local>
This commit is contained in:
Caisin 2026-06-07 01:00:34 +08:00 committed by GitHub
parent 2c404e73c2
commit 8c0fe3a6e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 107 additions and 2 deletions

View File

@ -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 });
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -934,6 +934,8 @@ export default {
closeOtherTabs: "关闭其他标签页",
closeAllTabs: "关闭全部标签页",
copyName: "复制名称",
copyFinalProxyPort: "复制最终代理端口",
finalProxyPortCopied: "最终代理端口 {port} 已复制",
setDefaultDatabase: "设为默认数据库",
clearDefaultDatabase: "取消默认数据库",
exportDatabase: "导出数据库",

View File

@ -914,6 +914,8 @@ export default {
closeOtherTabs: "關閉其他分頁",
closeAllTabs: "關閉全部分頁",
copyName: "複製名稱",
copyFinalProxyPort: "複製最終代理連接埠",
finalProxyPortCopied: "最終代理連接埠 {port} 已複製",
setDefaultDatabase: "設為預設資料庫",
clearDefaultDatabase: "取消預設資料庫",
exportDatabase: "匯出資料庫",

View File

@ -33,6 +33,7 @@ function forward<K extends keyof Backend>(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");

View File

@ -0,0 +1,5 @@
import type { ConnectionConfig } from "@/types/database";
export function hasEnabledTransportLayers(connection?: Pick<ConnectionConfig, "transport_layers"> | null): boolean {
return !!connection?.transport_layers?.some((layer) => layer.enabled !== false);
}

View File

@ -151,6 +151,10 @@ export async function connectDb(config: ConnectionConfig): Promise<string> {
return post("/api/connection/connect", { config });
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return post("/api/connection/final-proxy-port", { config });
}
export async function disconnectDb(connectionId: string): Promise<void> {
return post("/api/connection/disconnect", { connectionId });
}

View File

@ -414,6 +414,10 @@ export async function connectDb(config: ConnectionConfig): Promise<string> {
return invoke("connect_db", { config });
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return invoke("connection_final_proxy_port", { config });
}
export async function disconnectDb(connectionId: string): Promise<void> {
return invoke("disconnect_db", { connectionId });
}

View File

@ -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))

View File

@ -78,6 +78,24 @@ pub async fn connect_db(
Ok(Json(connection_id))
}
pub async fn connection_final_proxy_port(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectRequest>,
) -> Result<Json<u16>, 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<Arc<WebState>>,
Json(body): Json<DisconnectRequest>,
@ -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(()))
}

View File

@ -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,
);
});

View File

@ -573,6 +573,24 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
Ok(id)
}
#[tauri::command]
pub async fn connection_final_proxy_port(
state: State<'_, Arc<AppState>>,
config: ConnectionConfig,
) -> Result<u16, String> {
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<AppState>>, connection_id: String) -> Result<(), String> {
let mut conns = state.connections.write().await;

View File

@ -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,