feat(connection): test SSH tunnel profiles

This commit is contained in:
rainhan authored and GitHub committed 2026-07-13 17:51:51 +08:00
1 parent 27c60f0d30
commit c7991b316e
19 files changed
+256 -4

No files matched your search

@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Loader2, Plus, Trash2 } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { createTunnelProfile, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
import type { TunnelProfile } from "@/types/database";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -21,6 +21,9 @@ const draft = ref<TunnelProfile[]>([]);
const selectedId = ref<string | null>(null);
const isSaving = ref(false);
const hasInitializedDraft = ref(false);
const isTesting = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const testGuard = createTunnelProfileTestGuard();
function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] {
return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[];
@@ -54,6 +57,22 @@ const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.va
const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null));
const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null));
function invalidateProfileTest() {
testGuard.invalidate();
isTesting.value = false;
testResult.value = null;
}
// Profile tests are asynchronous, so any selection or configuration change
// must invalidate the request before it can publish a stale result.
watch(
[selectedId, selectedSsh],
() => {
invalidateProfileTest();
},
{ deep: true },
);
function profileTypeLabel(profile: TunnelProfile): string {
if (profile.type === "proxy") return "Proxy";
if (profile.type === "http_tunnel") return t("connection.httpTunnel");
@@ -96,6 +115,7 @@ function updateProxyType(value: unknown) {
async function save() {
if (isSaving.value) return;
invalidateProfileTest();
isSaving.value = true;
try {
await store.saveProfiles(cloneProfiles(draft.value));
@@ -106,6 +126,25 @@ async function save() {
isSaving.value = false;
}
}
async function testSelected() {
const profile = selectedSsh.value;
if (!profile || isTesting.value) return;
const profileSnapshot = cloneProfiles([profile])[0];
const requestId = testGuard.start(profileSnapshot);
isTesting.value = true;
testResult.value = null;
try {
const message = await store.testProfile(profileSnapshot);
if (!testGuard.isCurrent(requestId, selectedSsh.value)) return;
testResult.value = { ok: true, message: message || t("settings.tunnelsTestSuccess") };
} catch (error) {
if (!testGuard.isCurrent(requestId, selectedSsh.value)) return;
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) };
} finally {
if (testGuard.isCurrent(requestId, selectedSsh.value)) isTesting.value = false;
}
}
</script>
<template>
@@ -244,7 +283,7 @@ async function save() {
</template>
</template>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" :disabled="!isDirty || isSaving" @click="save">
<Loader2 v-if="isSaving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("settings.tunnelsSave") }}
@@ -252,7 +291,15 @@ async function save() {
<Button type="button" variant="outline" size="sm" :disabled="!isDirty || isSaving" @click="resetDraft">
{{ t("settings.tunnelsReset") }}
</Button>
<Button v-if="selectedSsh" type="button" variant="outline" size="sm" :disabled="isTesting || isSaving || !selectedSsh.host.trim()" @click="testSelected">
<Loader2 v-if="isTesting" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ isTesting ? t("settings.tunnelsTesting") : t("settings.tunnelsTest") }}
</Button>
<p v-if="isDirty" class="text-xs text-muted-foreground">{{ t("settings.tunnelsUnsavedHint") }}</p>
</div>
<p v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'">
{{ testResult.message }}
</p>
</div>
</template>