feat(connection): add dedicated ssh tunnel test button

This commit is contained in:
zipg authored and GitHub committed 2026-09-01 15:07:51 +08:00
1 parent f19439ad7a
commit 6588f27fbc
18 files changed
+228 -21

No files matched your search

@@ -230,7 +230,7 @@ type LegacyTransportFields = {
};
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
type ConnectionForm = Omit<ConnectionConfig, "id">;
type ConnectionTestState = ConnectionTestResult & { ok: boolean };
type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" };
const { t } = useI18n();
const { toast } = useToast();
@@ -276,6 +276,7 @@ function initialConnectionGroupId(): string | null {
}
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isTestingSshTunnel = ref(false);
const isSaving = ref(false);
const testResult = ref<ConnectionTestState | null>(null);
const testedConfigFingerprint = ref("");
@@ -2651,6 +2652,7 @@ const databasePlaceholder = computed(() => {
});
const transportLayers = computed(() => form.value.transport_layers || []);
const hasEnabledSshLayer = computed(() => transportLayers.value.some((layer) => layer.enabled !== false && layer.type === "ssh"));
const selectedTransportLayer = computed(() => {
const layers = transportLayers.value;
return layers.find((layer) => layer.id === selectedTransportLayerId.value) || layers[0] || null;
@@ -3361,7 +3363,8 @@ const databaseInfoCompactLabel = computed(() =>
);
const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message);
if (!testResult.value.ok) return translateBackendError(t, testResult.value.message);
return testResult.value.scope === "ssh" ? t("connection.sshTunnelTestSuccess") : t("connection.testSuccess");
});
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
@@ -3483,6 +3486,7 @@ watch(customDriverName, (value) => {
});
async function testConnection() {
if (isTestingSshTunnel.value) return;
if (!ensureConnectionHostResolvedFromUrl()) return;
const runId = ++testRunId;
@@ -3525,6 +3529,28 @@ async function testConnection() {
}
}
async function testSshTunnel() {
if (isTesting.value || isTestingSshTunnel.value) return;
const runId = ++testRunId;
isTestingSshTunnel.value = true;
testResult.value = null;
testResultCopied.value = false;
try {
const config = connectionConfigForSshTunnelTest(editingId.value || draftTestConnectionId.value);
const message = await api.testSshTunnel(config);
if (runId !== testRunId) return;
testResult.value = { ok: true, message, scope: "ssh" };
} catch (error) {
if (runId !== testRunId) return;
const message = errorMessage(error);
testResult.value = { ok: false, message, scope: "ssh" };
showConnectionError(message);
} finally {
if (runId === testRunId) isTestingSshTunnel.value = false;
}
}
function clearEditedConnectionErrorAfterSuccessfulTest() {
if (editingId.value) store.clearConnectionError(editingId.value);
}
@@ -3708,6 +3734,24 @@ function generateConnectionName(): string {
return `${label}_${rand}`;
}
function normalizeTransportLayersForSubmit(config: LegacyConnectionConfig) {
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 { type: "ssh", ...normalized };
});
validateTransportLayers(config);
}
function connectionConfigForSshTunnelTest(id: string): ConnectionConfig {
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
normalizeTransportLayersForSubmit(config);
return config;
}
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
config.database_info = undefined;
@@ -3751,18 +3795,10 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
throw new Error(t("connection.spannerFieldsRequired"));
}
}
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 { type: "ssh", ...normalized };
});
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers.some((layer) => layer.enabled !== false)) {
normalizeTransportLayersForSubmit(config);
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers?.some((layer) => layer.enabled !== false)) {
throw new Error(t("connection.oracleTnsTransportUnsupported"));
}
validateTransportLayers(config);
if (config.db_type === "oracle" && config.oracle_connection_type === "tns") {
const alias = config.database?.trim() || "";
const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value);
@@ -4364,6 +4400,7 @@ function isOracleSysUser(config: Pick<ConnectionConfig, "db_type" | "username">)
function resetTestState() {
testRunId += 1;
isTesting.value = false;
isTestingSshTunnel.value = false;
testResult.value = null;
clearTestedConnectionInfo();
showConnectionErrorDialog.value = false;
@@ -8708,6 +8745,16 @@ function openExternalUrl(url: string) {
</div>
</template>
</template>
<div v-if="hasEnabledSshLayer" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3">
<Button type="button" variant="outline" size="sm" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testSshTunnel">
<Loader2 v-if="isTestingSshTunnel" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<ShieldCheck v-else class="mr-1.5 h-3.5 w-3.5" />
{{ isTestingSshTunnel ? t("connection.sshTunnelTesting") : t("connection.sshTunnelTest") }}
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
@@ -8715,7 +8762,7 @@ function openExternalUrl(url: string) {
<DialogFooter class="connection-dialog-footer flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
<div class="connection-dialog-test-status mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving || isTestingSshTunnel" @click="backToDatabasePicker">
<ArrowLeft class="h-4 w-4" />
{{ t("connection.back") }}
</Button>
@@ -8729,25 +8776,25 @@ function openExternalUrl(url: string) {
</Button>
</template>
</div>
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
<Loader2 v-if="isLoadingVisibleNacosNamespaces" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t(nacosNamespacePickerTitleKey) }}
</Button>
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ hasVisibleObjectFilter ? visibleObjectSummary : visibleFilterUsesSchemas ? t("contextMenu.configureVisibleObjects") : t("contextMenu.selectVisibleDatabases") }}
</Button>
<Button v-if="canChooseVisibleSchemas && !visibleFilterUsesSchemas && hasVisibleSchemaFilter" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleSchemas || !hasRequiredConnectionTarget" @click="openVisibleSchemasPicker">
<Button v-if="canChooseVisibleSchemas && !visibleFilterUsesSchemas && hasVisibleSchemaFilter" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleSchemas || !hasRequiredConnectionTarget" @click="openVisibleSchemasPicker">
<Loader2 v-if="isLoadingVisibleSchemas" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ visibleSchemaSummary }}
</Button>
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
<Button variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
<Button class="shrink-0" @click="save" :disabled="isSaving || !hasRequiredConnectionTarget">
<Button class="shrink-0" @click="save" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
{{ isSaving ? t("common.loading") : editingId || isJdbcConnection ? t("connection.save") : t("connection.saveAndConnect") }}
</Button>
</DialogFooter>
+3
View File
@@ -1042,6 +1042,9 @@ export default {
groupsDeleted: "{count} groups deleted",
loadFailed: "Failed to load saved connections: {message}",
sshTunnel: "Tunnel / Proxy",
sshTunnelTest: "Test SSH tunnel",
sshTunnelTesting: "Testing SSH tunnel...",
sshTunnelTestSuccess: "SSH tunnel connection successful",
advancedTab: "Advanced",
sshEnable: "Use tunnel / proxy",
sshHost: "SSH Host",
+3
View File
@@ -830,6 +830,9 @@ export default withEnglishFallback({
groupsDeleted: "Se eliminaron {count} grupos",
loadFailed: "Error al cargar las conexiones guardadas: {message}",
sshTunnel: "Túnel SSH / Proxy",
sshTunnelTest: "Probar túnel SSH",
sshTunnelTesting: "Probando túnel SSH...",
sshTunnelTestSuccess: "Conexión del túnel SSH correcta",
advancedTab: "Avanzado",
sshEnable: "Usar túnel SSH / proxy",
sshHost: "Host SSH",
+3
View File
@@ -828,6 +828,9 @@ export default withEnglishFallback({
groupsDeleted: "Eliminati {count} gruppi",
loadFailed: "Impossibile caricare le connessioni salvate: {message}",
sshTunnel: "Tunnel SSH / Proxy",
sshTunnelTest: "Testa tunnel SSH",
sshTunnelTesting: "Test tunnel SSH in corso...",
sshTunnelTestSuccess: "Connessione al tunnel SSH riuscita",
advancedTab: "Avanzate",
sshEnable: "Usa tunnel SSH / proxy",
sshHost: "Host SSH",
+3
View File
@@ -794,6 +794,9 @@ export default withEnglishFallback({
groupsDeleted: "{count} 個のグループを削除しました",
loadFailed: "保存された接続の読み込みに失敗しました: {message}",
sshTunnel: "SSHトンネル / プロキシ",
sshTunnelTest: "SSH トンネルをテスト",
sshTunnelTesting: "SSH トンネルをテスト中...",
sshTunnelTestSuccess: "SSH トンネル接続に成功しました",
advancedTab: "詳細",
sshEnable: "SSHトンネル/プロキシを使用する",
sshHost: "SSHホスト",
+3
View File
@@ -934,6 +934,9 @@ export default withEnglishFallback({
groupsDeleted: "그룹 {count}개를 삭제했습니다",
loadFailed: "저장된 연결을 불러오는 데 실패했습니다: {message}",
sshTunnel: "터널 / 프록시",
sshTunnelTest: "SSH 터널 테스트",
sshTunnelTesting: "SSH 터널 테스트 중...",
sshTunnelTestSuccess: "SSH 터널 연결 성공",
advancedTab: "고급",
sshEnable: "터널 / 프록시 사용",
sshHost: "SSH 호스트",
+3
View File
@@ -829,6 +829,9 @@ export default withEnglishFallback({
groupsDeleted: "{count} grupos excluídos",
loadFailed: "Falha ao carregar as conexões salvas: {message}",
sshTunnel: "Túnel SSH / Proxy",
sshTunnelTest: "Testar túnel SSH",
sshTunnelTesting: "Testando túnel SSH...",
sshTunnelTestSuccess: "Conexão do túnel SSH bem-sucedida",
advancedTab: "Avançado",
sshEnable: "Usar túnel SSH / proxy",
sshHost: "Host SSH",
+3
View File
@@ -969,6 +969,9 @@ export default withEnglishFallback({
groupsDeleted: "已删除 {count} 个分组",
loadFailed: "加载已保存连接失败:{message}",
sshTunnel: "隧道/代理",
sshTunnelTest: "测试 SSH 隧道",
sshTunnelTesting: "正在测试 SSH 隧道...",
sshTunnelTestSuccess: "SSH 隧道连接成功",
advancedTab: "高级",
sshEnable: "使用隧道/代理连接",
sshHost: "SSH 主机",
+3
View File
@@ -828,6 +828,9 @@ export default withEnglishFallback({
groupsDeleted: "已刪除 {count} 個群組",
loadFailed: "載入已儲存連線失敗:{message}",
sshTunnel: "SSH 隧道/代理",
sshTunnelTest: "測試 SSH 隧道",
sshTunnelTesting: "正在測試 SSH 隧道...",
sshTunnelTestSuccess: "SSH 隧道連線成功",
advancedTab: "進階",
sshEnable: "使用 SSH 隧道/代理連線",
sshHost: "SSH 主機",
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("ConnectionDialog SSH tunnel test", () => {
it("exposes a dedicated SSH tunnel test without running the database test", () => {
expect(dialogSource).toContain("async function testSshTunnel()");
expect(dialogSource).toContain("await api.testSshTunnel(config)");
expect(dialogSource).toContain('@click="testSshTunnel"');
expect(dialogSource).toContain('t("connection.sshTunnelTest")');
});
it("builds the tunnel probe config without database-specific validation", () => {
const start = dialogSource.indexOf("function connectionConfigForSshTunnelTest");
const end = dialogSource.indexOf("function connectionConfigForSubmit", start);
const helperSource = dialogSource.slice(start, end);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
expect(helperSource).toContain("normalizeTransportLayersForSubmit(config)");
expect(helperSource).not.toContain("kingbaseDatabaseRequired");
expect(helperSource).not.toContain("dynamodbCredentialsRequired");
});
});
+1
View File
@@ -52,6 +52,7 @@ function forward<K extends keyof Backend>(name: K): Backend[K] {
// Connection
export const testConnection = forward("testConnection");
export const testSshTunnel = forward("testSshTunnel");
export const testConnectionWithInfo = forward("testConnectionWithInfo");
export const connectDb = forward("connectDb");
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
+4
View File
@@ -374,6 +374,10 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
return post("/api/connection/test", { config });
}
export async function testSshTunnel(config: ConnectionConfig): Promise<string> {
return post("/api/connection/test-ssh-tunnel", { config });
}
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
const response = await fetch(apiUrl("/api/connection/test-info"), {
method: "POST",
+4
View File
@@ -1068,6 +1068,10 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
return invokeBackend("test_connection", { config });
}
export async function testSshTunnel(config: ConnectionConfig): Promise<string> {
return invokeBackend("test_ssh_tunnel", { config });
}
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
try {
const result = await invoke<unknown>("test_connection_with_info", {