feat(connection): add dedicated ssh tunnel test button
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -794,6 +794,9 @@ export default withEnglishFallback({
|
||||
groupsDeleted: "{count} 個のグループを削除しました",
|
||||
loadFailed: "保存された接続の読み込みに失敗しました: {message}",
|
||||
sshTunnel: "SSHトンネル / プロキシ",
|
||||
sshTunnelTest: "SSH トンネルをテスト",
|
||||
sshTunnelTesting: "SSH トンネルをテスト中...",
|
||||
sshTunnelTestSuccess: "SSH トンネル接続に成功しました",
|
||||
advancedTab: "詳細",
|
||||
sshEnable: "SSHトンネル/プロキシを使用する",
|
||||
sshHost: "SSHホスト",
|
||||
|
||||
@@ -934,6 +934,9 @@ export default withEnglishFallback({
|
||||
groupsDeleted: "그룹 {count}개를 삭제했습니다",
|
||||
loadFailed: "저장된 연결을 불러오는 데 실패했습니다: {message}",
|
||||
sshTunnel: "터널 / 프록시",
|
||||
sshTunnelTest: "SSH 터널 테스트",
|
||||
sshTunnelTesting: "SSH 터널 테스트 중...",
|
||||
sshTunnelTestSuccess: "SSH 터널 연결 성공",
|
||||
advancedTab: "고급",
|
||||
sshEnable: "터널 / 프록시 사용",
|
||||
sshHost: "SSH 호스트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -969,6 +969,9 @@ export default withEnglishFallback({
|
||||
groupsDeleted: "已删除 {count} 个分组",
|
||||
loadFailed: "加载已保存连接失败:{message}",
|
||||
sshTunnel: "隧道/代理",
|
||||
sshTunnelTest: "测试 SSH 隧道",
|
||||
sshTunnelTesting: "正在测试 SSH 隧道...",
|
||||
sshTunnelTestSuccess: "SSH 隧道连接成功",
|
||||
advancedTab: "高级",
|
||||
sshEnable: "使用隧道/代理连接",
|
||||
sshHost: "SSH 主机",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user