feat(connection): add reusable tunnel profiles
This commit is contained in:
1 parent
4dcee1aca0
commit
977e7f78ca
34 files changed
+1335
-22
No files matched your search
@@ -18,6 +18,8 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
|
||||
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -114,9 +116,11 @@ const emit = defineEmits<{
|
||||
connectSucceeded: [name: string];
|
||||
connectFailed: [message: string];
|
||||
openDriverStore: [];
|
||||
openTunnelProfileSettings: [];
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
const tunnelProfileStore = useTunnelProfileStore();
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
@@ -251,6 +255,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
|
||||
use_ssh_agent: !!hop.use_ssh_agent,
|
||||
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
|
||||
auth_method: hop.auth_method || inferSshAuthMethod(hop),
|
||||
profile_id: hop.profile_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +293,7 @@ function normalizeProxyTunnel(layer: Partial<ProxyTunnelConfig>): ProxyTunnelCon
|
||||
port: Number(layer.port) || 1080,
|
||||
username: layer.username || "",
|
||||
password: layer.password || "",
|
||||
profile_id: layer.profile_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,6 +305,7 @@ function normalizeHttpTunnel(layer: Partial<HttpTunnelConfig>): HttpTunnelConfig
|
||||
url: layer.url || "",
|
||||
token: layer.token || "",
|
||||
connect_timeout_secs: Number(layer.connect_timeout_secs) || 10,
|
||||
profile_id: layer.profile_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1534,6 +1541,32 @@ const selectedSshLayer = computed(() => (selectedTransportLayer.value?.type ===
|
||||
const selectedProxyLayer = computed(() => (selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null));
|
||||
const selectedHttpTunnelLayer = computed(() => (selectedTransportLayer.value?.type === "http_tunnel" ? selectedTransportLayer.value : null));
|
||||
|
||||
const tunnelProfiles = computed(() => tunnelProfileStore.profiles);
|
||||
const selectedLayerProfileId = computed(() => selectedTransportLayer.value?.profile_id || "");
|
||||
const selectedLayerProfile = computed(() => tunnelProfileStore.profileById(selectedLayerProfileId.value));
|
||||
|
||||
function tunnelProfileOptionLabel(profile: (typeof tunnelProfiles.value)[number]): string {
|
||||
const summary = tunnelProfileSummary(profile);
|
||||
if (!profile.name?.trim()) return summary || profile.id;
|
||||
return summary ? `${profile.name} (${summary})` : profile.name;
|
||||
}
|
||||
|
||||
function applyTunnelProfileSelection(value: unknown) {
|
||||
const selected = selectedTransportLayer.value;
|
||||
if (!selected) return;
|
||||
if (!value || value === "custom") {
|
||||
if (!selected.profile_id) return;
|
||||
const detached = detachTunnelProfileLayer(selected, tunnelProfileStore.profileById(selected.profile_id));
|
||||
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? detached : layer));
|
||||
} else {
|
||||
const profile = tunnelProfileStore.profileById(String(value));
|
||||
if (!profile) return;
|
||||
const stub = tunnelProfileReferenceLayer(profile, selected);
|
||||
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? stub : layer));
|
||||
}
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function transportLayerDefaultName(layer: TransportLayerConfig, index: number): string {
|
||||
if (layer.type === "proxy") return `Proxy ${index + 1}`;
|
||||
if (layer.type === "http_tunnel") return t("connection.httpTunnelDefaultName", { index: index + 1 });
|
||||
@@ -1541,6 +1574,11 @@ function transportLayerDefaultName(layer: TransportLayerConfig, index: number):
|
||||
}
|
||||
|
||||
function transportLayerDisplayName(layer: TransportLayerConfig, index: number): string {
|
||||
if (layer.profile_id) {
|
||||
const profile = tunnelProfileStore.profileById(layer.profile_id);
|
||||
if (profile) return profile.name?.trim() || tunnelProfileSummary(profile) || transportLayerDefaultName(layer, index);
|
||||
return layer.name?.trim() || t("connection.tunnelProfileMissingName");
|
||||
}
|
||||
const target = layer.type === "http_tunnel" ? layer.url?.trim() : layer.host?.trim();
|
||||
return layer.name?.trim() || target || transportLayerDefaultName(layer, index);
|
||||
}
|
||||
@@ -3306,6 +3344,9 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
const layers = config.transport_layers || [];
|
||||
layers.forEach((layer, index) => {
|
||||
if (layer.enabled === false) return;
|
||||
// Profile-referencing layers are stubs: the shared profile supplies the
|
||||
// whole configuration at connect time, so there is nothing to validate.
|
||||
if (layer.profile_id) return;
|
||||
const label = layer.name?.trim() || transportLayerDefaultName(layer, index);
|
||||
if (layer.type === "http_tunnel") {
|
||||
if (index !== 0) throw new Error(t("connection.httpTunnelInvalidOrder", { hop: label }));
|
||||
@@ -3713,6 +3754,7 @@ function onJdbcDriverSelect(id: any) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void tunnelProfileStore.init();
|
||||
unlistenAgentInstallProgress = await api.listenAgentInstallProgress(handleAgentInstallProgress);
|
||||
});
|
||||
|
||||
@@ -5372,7 +5414,35 @@ function openExternalUrl(url: string) {
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHopName") }}</Label>
|
||||
<Input v-model="selectedTransportLayer.name" class="col-span-3" :placeholder="t('connection.sshHopNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="tunnelProfiles.length || selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.tunnelProfile") }}</Label>
|
||||
<div class="col-span-3 flex min-w-0 items-center gap-2">
|
||||
<Select :model-value="selectedLayerProfileId || 'custom'" @update:model-value="applyTunnelProfileSelection">
|
||||
<SelectTrigger class="h-9 min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">{{ t("connection.tunnelProfileCustom") }}</SelectItem>
|
||||
<SelectItem v-for="profile in tunnelProfiles" :key="profile.id" :value="profile.id">{{ tunnelProfileOptionLabel(profile) }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" size="sm" class="shrink-0" @click="emit('openTunnelProfileSettings')">
|
||||
{{ t("connection.tunnelProfileManage") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedLayerProfileId" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 grid min-w-0 gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<template v-if="selectedLayerProfile">
|
||||
<span class="truncate font-medium text-foreground">{{ selectedLayerProfile.name || tunnelProfileSummary(selectedLayerProfile) }}</span>
|
||||
<span v-if="selectedLayerProfile.name && tunnelProfileSummary(selectedLayerProfile)" class="truncate">{{ tunnelProfileSummary(selectedLayerProfile) }}</span>
|
||||
<span>{{ t("connection.tunnelProfileManaged") }}</span>
|
||||
</template>
|
||||
<span v-else class="text-red-500">{{ t("connection.tunnelProfileMissing") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">Type</Label>
|
||||
<Select :model-value="selectedTransportLayer.type" @update:model-value="(value: any) => changeSelectedTransportLayerType(value)">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
@@ -5385,7 +5455,7 @@ function openExternalUrl(url: string) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<template v-if="selectedSshLayer">
|
||||
<template v-if="selectedSshLayer && !selectedLayerProfileId">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHost") }}</Label>
|
||||
<Input v-model="selectedSshLayer.host" class="col-span-2" list="ssh-config-host-aliases" :placeholder="t('connection.sshHostPlaceholder')" :disabled="selectedSshLayer.enabled === false" @change="applySshConfigHostAliasPrefill(selectedSshLayer!)" />
|
||||
@@ -5463,7 +5533,7 @@ function openExternalUrl(url: string) {
|
||||
<Input v-model.number="selectedSshLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedSshLayer.enabled === false" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedProxyLayer">
|
||||
<template v-else-if="selectedProxyLayer && !selectedLayerProfileId">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyType") }}</Label>
|
||||
<Select :model-value="selectedProxyLayer.proxy_type || 'socks5'" :disabled="selectedProxyLayer.enabled === false" @update:model-value="updateSelectedProxyType">
|
||||
@@ -5490,7 +5560,7 @@ function openExternalUrl(url: string) {
|
||||
<PasswordInput v-model="selectedProxyLayer.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" :disabled="selectedProxyLayer.enabled === false" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedHttpTunnelLayer">
|
||||
<template v-else-if="selectedHttpTunnelLayer && !selectedLayerProfileId">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelUrl") }}</Label>
|
||||
<Input v-model="selectedHttpTunnelLayer.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" :disabled="selectedHttpTunnelLayer.enabled === false" />
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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 type { TunnelProfile } from "@/types/database";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const store = useTunnelProfileStore();
|
||||
|
||||
const draft = ref<TunnelProfile[]>([]);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const isSaving = ref(false);
|
||||
|
||||
function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] {
|
||||
return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[];
|
||||
}
|
||||
|
||||
function resetDraft() {
|
||||
draft.value = cloneProfiles(store.profiles);
|
||||
if (!draft.value.some((profile) => profile.id === selectedId.value)) {
|
||||
selectedId.value = draft.value[0]?.id || null;
|
||||
}
|
||||
}
|
||||
|
||||
const isDirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(store.profiles));
|
||||
|
||||
void store.init();
|
||||
watch(
|
||||
() => store.isLoaded,
|
||||
(loaded) => {
|
||||
if (loaded && !isDirty.value) resetDraft();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const selected = computed(() => draft.value.find((profile) => profile.id === selectedId.value) || null);
|
||||
const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.value : null));
|
||||
const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null));
|
||||
const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null));
|
||||
|
||||
function profileTypeLabel(profile: TunnelProfile): string {
|
||||
if (profile.type === "proxy") return "Proxy";
|
||||
if (profile.type === "http_tunnel") return t("connection.httpTunnel");
|
||||
return "SSH";
|
||||
}
|
||||
|
||||
function profileDisplayName(profile: TunnelProfile): string {
|
||||
return profile.name?.trim() || tunnelProfileSummary(profile) || t("settings.tunnelsUnnamedProfile");
|
||||
}
|
||||
|
||||
function addProfile(type: TunnelProfileType) {
|
||||
const profile = createTunnelProfile(type);
|
||||
draft.value = [...draft.value, profile];
|
||||
selectedId.value = profile.id;
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
const current = selected.value;
|
||||
if (!current) return;
|
||||
draft.value = draft.value.filter((profile) => profile.id !== current.id);
|
||||
selectedId.value = draft.value[0]?.id || null;
|
||||
}
|
||||
|
||||
function updateSshAuthMethod(value: unknown) {
|
||||
const profile = selectedSsh.value;
|
||||
if (!profile) return;
|
||||
profile.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password";
|
||||
if (profile.auth_method !== "password") profile.password = "";
|
||||
if (profile.auth_method !== "key") {
|
||||
profile.key_path = "";
|
||||
profile.key_passphrase = "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateProxyType(value: unknown) {
|
||||
const profile = selectedProxy.value;
|
||||
if (!profile) return;
|
||||
profile.proxy_type = value === "http" ? "http" : "socks5";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving.value) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await store.saveProfiles(cloneProfiles(draft.value));
|
||||
toast(t("settings.tunnelsSaved"));
|
||||
} catch (error) {
|
||||
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, String(error)) }), 5000);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.tunnelsDescription") }}</p>
|
||||
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p v-if="!draft.length" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{{ t("settings.tunnelsEmpty") }}
|
||||
</p>
|
||||
<button v-for="profile in draft" :key="profile.id" type="button" class="flex min-h-10 items-center gap-2 rounded-md border px-3 text-left text-xs transition-colors" :class="profile.id === selectedId ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'" @click="selectedId = profile.id">
|
||||
<span class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{{ profileTypeLabel(profile) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ profileDisplayName(profile) }}</span>
|
||||
<span class="min-w-0 truncate text-muted-foreground">{{ tunnelProfileSummary(profile) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" @click="addProfile('ssh')">
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("settings.tunnelsAddSsh") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="addProfile('proxy')">
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("settings.tunnelsAddProxy") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="addProfile('http_tunnel')">
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("settings.tunnelsAddHttp") }}
|
||||
</Button>
|
||||
<Button v-if="selected" type="button" variant="outline" size="sm" @click="removeSelected">
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("settings.tunnelsDelete") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="selected">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("settings.tunnelsProfileName") }}</Label>
|
||||
<Input v-model="selected.name" class="col-span-3" :placeholder="t('settings.tunnelsProfileNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedSsh">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshHost") }}</Label>
|
||||
<Input v-model="selectedSsh.host" class="col-span-2" :placeholder="t('connection.sshHostPlaceholder')" />
|
||||
<Input v-model.number="selectedSsh.port" type="number" min="1" max="65535" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshUser") }}</Label>
|
||||
<Input v-model="selectedSsh.user" class="col-span-3" placeholder="root" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshAuthMethod") }}</Label>
|
||||
<Select :model-value="selectedSsh.auth_method || 'password'" @update:model-value="updateSshAuthMethod">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
|
||||
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
|
||||
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="!selectedSsh.auth_method || selectedSsh.auth_method === 'password'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshPassword") }}</Label>
|
||||
<PasswordInput v-model="selectedSsh.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" />
|
||||
</div>
|
||||
<div v-if="selectedSsh.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshKeyPath") }}</Label>
|
||||
<Input v-model="selectedSsh.key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
|
||||
</div>
|
||||
<div v-if="selectedSsh.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
|
||||
<PasswordInput v-model="selectedSsh.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
|
||||
</div>
|
||||
<div v-if="selectedSsh.auth_method === 'none'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex cursor-pointer items-center gap-2">
|
||||
<input v-model="selectedSsh.expose_lan" type="checkbox" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
|
||||
<Input v-model.number="selectedSsh.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedProxy">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.proxyType") }}</Label>
|
||||
<Select :model-value="selectedProxy.proxy_type || 'socks5'" @update:model-value="updateProxyType">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="http">HTTP CONNECT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.proxyHost") }}</Label>
|
||||
<Input v-model="selectedProxy.host" class="col-span-2" placeholder="127.0.0.1" />
|
||||
<Input v-model.number="selectedProxy.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.proxyUsername") }}</Label>
|
||||
<Input v-model="selectedProxy.username" class="col-span-3" :placeholder="t('connection.proxyUsernamePlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.proxyPassword") }}</Label>
|
||||
<PasswordInput v-model="selectedProxy.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectedHttp">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.httpTunnelUrl") }}</Label>
|
||||
<Input v-model="selectedHttp.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.httpTunnelToken") }}</Label>
|
||||
<PasswordInput v-model="selectedHttp.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-xs">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
|
||||
<Input v-model.number="selectedHttp.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="flex 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") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="!isDirty || isSaving" @click="resetDraft">
|
||||
{{ t("settings.tunnelsReset") }}
|
||||
</Button>
|
||||
<p v-if="isDirty" class="text-xs text-muted-foreground">{{ t("settings.tunnelsUnsavedHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user