Preserve ordered SSH and proxy connection layers (#802)
* Preserve ordered SSH and proxy connection layers Replace separate SSH/proxy connection fields with ordered transport_layers while keeping legacy migration and secret fallback paths intact. The UI now edits SSH tunnel/proxy layers in configured order without a global SSH enable gate. Constraint: Existing saved SSH tunnels, proxy settings, and secret-store keys must continue to load through migration. Rejected: Folding proxy fields into SshTunnelConfig | mixes proxy semantics into an SSH-specific structure. Confidence: high Scope-risk: moderate Directive: Keep SSH/proxy structs provider-specific; put cross-layer chaining in transport_layer_tunnel orchestration. Tested: git diff --check; cargo fmt --check; cargo check --workspace; cargo test -p dbx-core --lib; vue-tsc --noEmit --project apps/desktop/tsconfig.json; tsc -p packages/node-core/tsconfig.json --noEmit; oxlint --vue-plugin apps/desktop/src; tsx --tsconfig apps/desktop/tsconfig.json --test packages/app-tests/*.test.ts; tsx --test packages/node-core/tests/*.test.ts Not-tested: Live external SSH/proxy/database endpoint integration. * Keep ordered transport changes CI-format clean Constraint: CI pnpm check failed only on oxfmt formatting for two desktop TypeScript files Confidence: high Scope-risk: narrow Directive: Keep generated/editor config changes out of this PR fix commit Tested: PATH="/Volumes/data/code/rust/dbx/node_modules/.bin:/Users/hekx/.codex/tmp/arg0/codex-arg0nuL34n:/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/Users/hekx/Library/pnpm:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/opt/X11/bin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/usr/local/go/bin:/opt/homebrew/bin:/opt/podman/bin:/Applications/Ghostty.app/Contents/MacOS" rtk node scripts/run-check.mjs Not-tested: GitHub Actions rerun not observed locally * Keep Rust checks warning-clean Constraint: cargo clippy --workspace --all-targets --all-features -- -D warnings failed across core, web, and tauri crates Rejected: Broad workspace-level clippy suppression | kept allows local to long-argument command/API boundaries Confidence: high Scope-risk: moderate Directive: Preserve src-tauri/tauri.conf.json as an unrelated local change outside this commit Tested: rtk cargo clippy --workspace --all-targets --all-features -- -D warnings Not-tested: Full GitHub Actions rerun not observed locally * Reuse existing proxy tunnels on retry Proxy tunnel startup now mirrors SSH tunnel behavior by returning the existing local port for an active connection id instead of replacing the managed handle. A second map check aborts a just-spawned duplicate handle if a concurrent retry won the race before insertion, preventing orphaned listeners while keeping the change narrow. Constraint: Reviewer requested proxy tunnel behavior align with SSH local-port reuse Rejected: Always overwrite and abort the previous handle | less consistent with SSH behavior and churns listeners during retries Confidence: high Scope-risk: narrow Tested: cargo fmt --check --all Tested: cargo test -p dbx-core db::proxy_tunnel::tests::start_tunnel_reuses_existing_local_port Tested: cargo clippy -p dbx-core --all-targets -- -D warnings --------- Co-authored-by: hekx <hekx@momandeMac-mini.local> Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
This commit is contained in:
54 files changed
+1869
-1188
No files matched your search
@@ -9,7 +9,14 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, SshTunnelConfig } from "@/types/database";
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
DatabaseType,
|
||||
JdbcDriverInfo,
|
||||
ProxyTunnelConfig,
|
||||
SshTunnelConfig,
|
||||
TransportLayerConfig,
|
||||
} from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
@@ -45,7 +52,28 @@ type DbOption = { value: string; label: string };
|
||||
type DbCategory = { key: string; title: string; options: DbOption[] };
|
||||
type DialogStep = "select" | "config";
|
||||
type DbPickerView = "icon" | "list";
|
||||
type ConfigTab = "connection" | "advanced" | "tls" | "ssh" | "proxy";
|
||||
type ConfigTab = "connection" | "advanced" | "tls" | "transport";
|
||||
|
||||
type LegacyTransportFields = {
|
||||
ssh_enabled?: boolean;
|
||||
ssh_host?: string;
|
||||
ssh_port?: number;
|
||||
ssh_user?: string;
|
||||
ssh_password?: string;
|
||||
ssh_key_path?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_expose_lan?: boolean;
|
||||
ssh_connect_timeout_secs?: number;
|
||||
ssh_tunnels?: SshTunnelConfig[];
|
||||
proxy_enabled?: boolean;
|
||||
proxy_type?: "socks5" | "http";
|
||||
proxy_host?: string;
|
||||
proxy_port?: number;
|
||||
proxy_username?: string;
|
||||
proxy_password?: string;
|
||||
};
|
||||
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
|
||||
type ConnectionForm = Omit<ConnectionConfig, "id">;
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -71,7 +99,7 @@ const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const editingId = ref<string | null>(null);
|
||||
let testRunId = 0;
|
||||
|
||||
const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
|
||||
const defaultForm = (): ConnectionForm => ({
|
||||
name: "",
|
||||
db_type: "mysql",
|
||||
driver_profile: "mysql",
|
||||
@@ -83,24 +111,9 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
|
||||
password: "",
|
||||
database: undefined,
|
||||
color: "",
|
||||
ssh_enabled: false,
|
||||
ssh_host: "",
|
||||
ssh_port: 22,
|
||||
ssh_user: "",
|
||||
ssh_password: "",
|
||||
ssh_key_path: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_expose_lan: false,
|
||||
ssh_connect_timeout_secs: 5,
|
||||
ssh_tunnels: [],
|
||||
transport_layers: [],
|
||||
connect_timeout_secs: 5,
|
||||
query_timeout_secs: 30,
|
||||
proxy_enabled: false,
|
||||
proxy_type: "socks5",
|
||||
proxy_host: "",
|
||||
proxy_port: 1080,
|
||||
proxy_username: "",
|
||||
proxy_password: "",
|
||||
ssl: false,
|
||||
ca_cert_path: "",
|
||||
sysdba: false,
|
||||
@@ -149,7 +162,62 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] {
|
||||
function defaultProxyTunnel(): ProxyTunnelConfig {
|
||||
return {
|
||||
id: uuid(),
|
||||
name: "",
|
||||
enabled: true,
|
||||
proxy_type: "socks5",
|
||||
host: "",
|
||||
port: 1080,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProxyTunnel(layer: Partial<ProxyTunnelConfig>): ProxyTunnelConfig {
|
||||
return {
|
||||
id: layer.id || uuid(),
|
||||
name: layer.name || "",
|
||||
enabled: layer.enabled !== false,
|
||||
proxy_type: layer.proxy_type || "socks5",
|
||||
host: layer.host || "",
|
||||
port: Number(layer.port) || 1080,
|
||||
username: layer.username || "",
|
||||
password: layer.password || "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransportLayer(layer: Partial<TransportLayerConfig>): TransportLayerConfig {
|
||||
if (layer.type === "proxy") {
|
||||
return { type: "proxy", ...normalizeProxyTunnel(layer) };
|
||||
}
|
||||
return { type: "ssh", ...normalizeSshTunnel(layer as Partial<SshTunnelConfig>) };
|
||||
}
|
||||
|
||||
function transportLayersForConfig(config: LegacyConnectionConfig): TransportLayerConfig[] {
|
||||
if (config.transport_layers?.length) {
|
||||
return config.transport_layers.map(normalizeTransportLayer);
|
||||
}
|
||||
const layers: TransportLayerConfig[] = sshLayersForConfig(config).map((hop) => ({ type: "ssh", ...hop }));
|
||||
if (config.proxy_enabled || config.proxy_host || config.proxy_username || config.proxy_password) {
|
||||
layers.push({
|
||||
type: "proxy",
|
||||
...normalizeProxyTunnel({
|
||||
id: "legacy-proxy",
|
||||
enabled: true,
|
||||
proxy_type: config.proxy_type || "socks5",
|
||||
host: config.proxy_host || "",
|
||||
port: config.proxy_port || 1080,
|
||||
username: config.proxy_username || "",
|
||||
password: config.proxy_password || "",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
|
||||
if (config.ssh_tunnels?.length) {
|
||||
return config.ssh_tunnels.map(normalizeSshTunnel);
|
||||
}
|
||||
@@ -180,8 +248,8 @@ function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] {
|
||||
}
|
||||
|
||||
const form = ref(defaultForm());
|
||||
const selectedSshTunnelId = ref<string | null>(null);
|
||||
const draggedSshTunnelId = ref<string | null>(null);
|
||||
const selectedTransportLayerId = ref<string | null>(null);
|
||||
const draggedTransportLayerId = ref<string | null>(null);
|
||||
const selectedType = ref("mysql");
|
||||
const customDriverName = ref("");
|
||||
const mongoUseUrl = ref(false);
|
||||
@@ -422,6 +490,7 @@ watch(
|
||||
() => props.editConfig,
|
||||
(config) => {
|
||||
if (config) {
|
||||
const legacyConfig = config as LegacyConnectionConfig;
|
||||
const profile = profileForConfig(config);
|
||||
editingId.value = config.id;
|
||||
const profileConfig = driverProfiles[profile];
|
||||
@@ -437,24 +506,9 @@ watch(
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
color: config.color || "",
|
||||
ssh_enabled: config.ssh_enabled || false,
|
||||
ssh_host: config.ssh_host || "",
|
||||
ssh_port: config.ssh_port || 22,
|
||||
ssh_user: config.ssh_user || "",
|
||||
ssh_password: config.ssh_password || "",
|
||||
ssh_key_path: config.ssh_key_path || "",
|
||||
ssh_key_passphrase: config.ssh_key_passphrase || "",
|
||||
ssh_expose_lan: config.ssh_expose_lan || false,
|
||||
ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
|
||||
ssh_tunnels: sshTunnelsForConfig(config),
|
||||
transport_layers: transportLayersForConfig(legacyConfig),
|
||||
connect_timeout_secs: config.connect_timeout_secs || 5,
|
||||
query_timeout_secs: config.query_timeout_secs ?? 30,
|
||||
proxy_enabled: config.proxy_enabled || false,
|
||||
proxy_type: config.proxy_type || "socks5",
|
||||
proxy_host: config.proxy_host || "",
|
||||
proxy_port: config.proxy_port || 1080,
|
||||
proxy_username: config.proxy_username || "",
|
||||
proxy_password: config.proxy_password || "",
|
||||
ssl: config.ssl || false,
|
||||
ca_cert_path: config.ca_cert_path || "",
|
||||
sysdba: config.sysdba || isOracleSysUser(config),
|
||||
@@ -470,7 +524,7 @@ watch(
|
||||
redis_sentinel_tls: config.redis_sentinel_tls || false,
|
||||
redis_cluster_nodes: config.redis_cluster_nodes || "",
|
||||
};
|
||||
selectedSshTunnelId.value = form.value.ssh_tunnels?.[0]?.id || null;
|
||||
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
|
||||
selectedType.value = profile;
|
||||
if (profile === "oceanbase") {
|
||||
oceanbaseSubMode.value = config.driver_profile === "oceanbase-oracle" ? "oracle" : "mysql";
|
||||
@@ -486,7 +540,7 @@ watch(
|
||||
} else {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
selectedSshTunnelId.value = null;
|
||||
selectedTransportLayerId.value = null;
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
oceanbaseSubMode.value = "mysql";
|
||||
@@ -516,16 +570,25 @@ const databasePlaceholder = computed(() => {
|
||||
return t("connection.databasePlaceholderWithDefault", { database: fallback });
|
||||
});
|
||||
|
||||
const sshTunnels = computed(() => form.value.ssh_tunnels || []);
|
||||
const selectedSshTunnel = computed(() => {
|
||||
const tunnels = sshTunnels.value;
|
||||
return tunnels.find((hop) => hop.id === selectedSshTunnelId.value) || tunnels[0] || null;
|
||||
const transportLayers = computed(() => form.value.transport_layers || []);
|
||||
const selectedTransportLayer = computed(() => {
|
||||
const layers = transportLayers.value;
|
||||
return layers.find((layer) => layer.id === selectedTransportLayerId.value) || layers[0] || null;
|
||||
});
|
||||
const sshPathSegments = computed(() => {
|
||||
const hops = sshTunnels.value.filter((hop) => hop.enabled !== false);
|
||||
const selectedSshLayer = computed(() =>
|
||||
selectedTransportLayer.value?.type === "ssh" ? selectedTransportLayer.value : null,
|
||||
);
|
||||
const selectedProxyLayer = computed(() =>
|
||||
selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null,
|
||||
);
|
||||
const transportPathSegments = computed(() => {
|
||||
const layers = transportLayers.value.filter((layer) => layer.enabled !== false);
|
||||
return [
|
||||
"DBX",
|
||||
...hops.map((hop, index) => hop.name?.trim() || hop.host?.trim() || `SSH ${index + 1}`),
|
||||
...layers.map((layer, index) => {
|
||||
const fallback = layer.type === "proxy" ? `Proxy ${index + 1}` : `SSH ${index + 1}`;
|
||||
return layer.name?.trim() || layer.host?.trim() || fallback;
|
||||
}),
|
||||
form.value.host || "Database",
|
||||
];
|
||||
});
|
||||
@@ -784,10 +847,7 @@ const redisTlsInsecure = computed({
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "insecure", value ? "true" : "");
|
||||
},
|
||||
});
|
||||
const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access");
|
||||
const canUseProxy = computed(
|
||||
() => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "access",
|
||||
);
|
||||
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access");
|
||||
const shouldShowAgentDriverInstallHint = computed(() =>
|
||||
showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile),
|
||||
);
|
||||
@@ -881,36 +941,23 @@ function generateConnectionName(): string {
|
||||
}
|
||||
|
||||
function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
const config: ConnectionConfig = { ...form.value, id };
|
||||
const config = { ...form.value, id } as LegacyConnectionConfig;
|
||||
if (!config.name?.trim()) {
|
||||
config.name = generateConnectionName();
|
||||
}
|
||||
const sshTimeout = Number(config.ssh_connect_timeout_secs);
|
||||
config.ssh_connect_timeout_secs = Number.isFinite(sshTimeout) && sshTimeout > 0 ? sshTimeout : 5;
|
||||
config.ssh_tunnels = (config.ssh_tunnels || []).map((hop) => {
|
||||
const normalized = normalizeSshTunnel(hop);
|
||||
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 normalized;
|
||||
return { type: "ssh", ...normalized };
|
||||
});
|
||||
const firstSshTunnel = config.ssh_tunnels[0];
|
||||
if (firstSshTunnel) {
|
||||
config.ssh_host = firstSshTunnel.host;
|
||||
config.ssh_port = firstSshTunnel.port;
|
||||
config.ssh_user = firstSshTunnel.user;
|
||||
config.ssh_password = firstSshTunnel.password || "";
|
||||
config.ssh_key_path = firstSshTunnel.key_path || "";
|
||||
config.ssh_key_passphrase = firstSshTunnel.key_passphrase || "";
|
||||
config.ssh_expose_lan = !!firstSshTunnel.expose_lan;
|
||||
config.ssh_connect_timeout_secs = firstSshTunnel.connect_timeout_secs || config.ssh_connect_timeout_secs;
|
||||
}
|
||||
validateSshTunnels(config);
|
||||
validateTransportLayers(config);
|
||||
const connectTimeout = Number(config.connect_timeout_secs);
|
||||
config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 5;
|
||||
const queryTimeout = Number(config.query_timeout_secs);
|
||||
config.query_timeout_secs = Number.isFinite(queryTimeout) && queryTimeout >= 0 ? queryTimeout : 30;
|
||||
const proxyPort = Number(config.proxy_port);
|
||||
config.proxy_port = Number.isFinite(proxyPort) && proxyPort > 0 ? proxyPort : 1080;
|
||||
if (!config.one_time) config.one_time = undefined;
|
||||
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
|
||||
config.connection_string = undefined;
|
||||
@@ -978,7 +1025,24 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return config;
|
||||
const legacy = config as LegacyConnectionConfig;
|
||||
delete legacy.ssh_enabled;
|
||||
delete legacy.ssh_host;
|
||||
delete legacy.ssh_port;
|
||||
delete legacy.ssh_user;
|
||||
delete legacy.ssh_password;
|
||||
delete legacy.ssh_key_path;
|
||||
delete legacy.ssh_key_passphrase;
|
||||
delete legacy.ssh_expose_lan;
|
||||
delete legacy.ssh_connect_timeout_secs;
|
||||
delete legacy.ssh_tunnels;
|
||||
delete legacy.proxy_enabled;
|
||||
delete legacy.proxy_type;
|
||||
delete legacy.proxy_host;
|
||||
delete legacy.proxy_port;
|
||||
delete legacy.proxy_username;
|
||||
delete legacy.proxy_password;
|
||||
return config as ConnectionConfig;
|
||||
}
|
||||
|
||||
function getUrlParam(params: string | undefined, key: string): string {
|
||||
@@ -1174,8 +1238,8 @@ async function copyTestResult() {
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
selectedSshTunnelId.value = null;
|
||||
draggedSshTunnelId.value = null;
|
||||
selectedTransportLayerId.value = null;
|
||||
draggedTransportLayerId.value = null;
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
mongoUseUrl.value = false;
|
||||
@@ -1290,14 +1354,8 @@ watch([() => form.value.db_type, () => form.value.username], () => {
|
||||
if (isOracleSysUser(form.value)) form.value.sysdba = true;
|
||||
});
|
||||
|
||||
watch(canUseSsh, (value) => {
|
||||
if (!value && configTab.value === "ssh") {
|
||||
configTab.value = "connection";
|
||||
}
|
||||
});
|
||||
|
||||
watch(canUseProxy, (value) => {
|
||||
if (!value && configTab.value === "proxy") {
|
||||
watch(canUseTransportLayers, (value) => {
|
||||
if (!value && configTab.value === "transport") {
|
||||
configTab.value = "connection";
|
||||
}
|
||||
});
|
||||
@@ -1308,76 +1366,105 @@ watch(supportsTlsToggle, (value) => {
|
||||
}
|
||||
});
|
||||
|
||||
function ensureSelectedSshTunnel() {
|
||||
if (!selectedSshTunnelId.value || !sshTunnels.value.some((hop) => hop.id === selectedSshTunnelId.value)) {
|
||||
selectedSshTunnelId.value = sshTunnels.value[0]?.id || null;
|
||||
function ensureSelectedTransportLayer() {
|
||||
if (
|
||||
!selectedTransportLayerId.value ||
|
||||
!transportLayers.value.some((layer) => layer.id === selectedTransportLayerId.value)
|
||||
) {
|
||||
selectedTransportLayerId.value = transportLayers.value[0]?.id || null;
|
||||
}
|
||||
}
|
||||
|
||||
function addSshTunnel() {
|
||||
const next = defaultSshTunnel();
|
||||
next.name = t("connection.sshHopDefaultName", { index: sshTunnels.value.length + 1 });
|
||||
form.value.ssh_tunnels = [...sshTunnels.value, next];
|
||||
form.value.ssh_enabled = true;
|
||||
selectedSshTunnelId.value = next.id;
|
||||
const next: TransportLayerConfig = { type: "ssh", ...defaultSshTunnel() };
|
||||
next.name = t("connection.sshHopDefaultName", { index: transportLayers.value.length + 1 });
|
||||
form.value.transport_layers = [...transportLayers.value, next];
|
||||
selectedTransportLayerId.value = next.id;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function duplicateSshTunnel(hop: SshTunnelConfig) {
|
||||
const next = { ...normalizeSshTunnel(hop), id: uuid(), name: hop.name ? `${hop.name} copy` : "" };
|
||||
form.value.ssh_tunnels = [...sshTunnels.value, next];
|
||||
selectedSshTunnelId.value = next.id;
|
||||
function addProxyTunnel() {
|
||||
const next: TransportLayerConfig = { type: "proxy", ...defaultProxyTunnel() };
|
||||
next.name = `Proxy ${transportLayers.value.length + 1}`;
|
||||
form.value.transport_layers = [...transportLayers.value, next];
|
||||
selectedTransportLayerId.value = next.id;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function removeSshTunnel(id: string) {
|
||||
form.value.ssh_tunnels = sshTunnels.value.filter((hop) => hop.id !== id);
|
||||
ensureSelectedSshTunnel();
|
||||
function duplicateTransportLayer(layer: TransportLayerConfig) {
|
||||
const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" });
|
||||
form.value.transport_layers = [...transportLayers.value, next];
|
||||
selectedTransportLayerId.value = next.id;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function moveSshTunnel(id: string, direction: -1 | 1) {
|
||||
const tunnels = [...sshTunnels.value];
|
||||
const index = tunnels.findIndex((hop) => hop.id === id);
|
||||
function removeTransportLayer(id: string) {
|
||||
form.value.transport_layers = transportLayers.value.filter((layer) => layer.id !== id);
|
||||
ensureSelectedTransportLayer();
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function moveTransportLayer(id: string, direction: -1 | 1) {
|
||||
const layers = [...transportLayers.value];
|
||||
const index = layers.findIndex((layer) => layer.id === id);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= tunnels.length) return;
|
||||
[tunnels[index], tunnels[target]] = [tunnels[target], tunnels[index]];
|
||||
form.value.ssh_tunnels = tunnels;
|
||||
if (index < 0 || target < 0 || target >= layers.length) return;
|
||||
[layers[index], layers[target]] = [layers[target], layers[index]];
|
||||
form.value.transport_layers = layers;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function dropSshTunnel(targetId: string) {
|
||||
const sourceId = draggedSshTunnelId.value;
|
||||
draggedSshTunnelId.value = null;
|
||||
function dropTransportLayer(targetId: string) {
|
||||
const sourceId = draggedTransportLayerId.value;
|
||||
draggedTransportLayerId.value = null;
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
const tunnels = [...sshTunnels.value];
|
||||
const sourceIndex = tunnels.findIndex((hop) => hop.id === sourceId);
|
||||
const targetIndex = tunnels.findIndex((hop) => hop.id === targetId);
|
||||
const layers = [...transportLayers.value];
|
||||
const sourceIndex = layers.findIndex((layer) => layer.id === sourceId);
|
||||
const targetIndex = layers.findIndex((layer) => layer.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||||
const [source] = tunnels.splice(sourceIndex, 1);
|
||||
tunnels.splice(targetIndex, 0, source);
|
||||
form.value.ssh_tunnels = tunnels;
|
||||
const [source] = layers.splice(sourceIndex, 1);
|
||||
layers.splice(targetIndex, 0, source);
|
||||
form.value.transport_layers = layers;
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function validateSshTunnels(config: ConnectionConfig) {
|
||||
if (!config.ssh_enabled) return;
|
||||
const tunnels = config.ssh_tunnels || [];
|
||||
tunnels.forEach((hop, index) => {
|
||||
if (hop.enabled === false) return;
|
||||
const label = hop.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 });
|
||||
if (!hop.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label }));
|
||||
if (!hop.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label }));
|
||||
const port = Number(hop.port);
|
||||
function changeSelectedTransportLayerType(type: "ssh" | "proxy") {
|
||||
const selected = selectedTransportLayer.value;
|
||||
if (!selected || selected.type === type) return;
|
||||
const replacement: TransportLayerConfig =
|
||||
type === "proxy"
|
||||
? { type: "proxy", ...defaultProxyTunnel(), id: selected.id, name: selected.name }
|
||||
: { type: "ssh", ...defaultSshTunnel(), id: selected.id, name: selected.name };
|
||||
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? replacement : layer));
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function updateSelectedProxyType(value: unknown) {
|
||||
const layer = selectedProxyLayer.value;
|
||||
if (!layer) return;
|
||||
layer.proxy_type = value === "http" ? "http" : "socks5";
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
const layers = config.transport_layers || [];
|
||||
layers.forEach((layer, index) => {
|
||||
if (layer.enabled === false) return;
|
||||
const label = layer.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 });
|
||||
if (!layer.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label }));
|
||||
const port = Number(layer.port);
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
throw new Error(t("connection.sshHopInvalidPort", { hop: label }));
|
||||
}
|
||||
if (!hop.password?.trim() && !hop.key_path?.trim()) {
|
||||
throw new Error(t("connection.sshHopInvalidAuth", { hop: label }));
|
||||
}
|
||||
const timeout = Number(hop.connect_timeout_secs);
|
||||
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
|
||||
throw new Error(t("connection.sshHopInvalidTimeout", { hop: label }));
|
||||
if (layer.type === "ssh") {
|
||||
if (!layer.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label }));
|
||||
if (!layer.password?.trim() && !layer.key_path?.trim()) {
|
||||
throw new Error(t("connection.sshHopInvalidAuth", { hop: label }));
|
||||
}
|
||||
const timeout = Number(layer.connect_timeout_secs);
|
||||
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
|
||||
throw new Error(t("connection.sshHopInvalidTimeout", { hop: label }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1436,8 +1523,6 @@ async function browseSshKeyPath(target?: SshTunnelConfig | null) {
|
||||
if (selected && typeof selected === "string") {
|
||||
if (target) {
|
||||
target.key_path = selected;
|
||||
} else {
|
||||
form.value.ssh_key_path = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1761,8 +1846,9 @@ function openExternalUrl(url: string) {
|
||||
<TabsList>
|
||||
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="supportsTlsToggle" value="tls">{{ t("connection.tlsTab") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canUseSsh" value="ssh">{{ t("connection.sshTunnel") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canUseProxy" value="proxy">{{ t("connection.proxy") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canUseTransportLayers" value="transport">{{
|
||||
t("connection.sshTunnel")
|
||||
}}</TabsTrigger>
|
||||
<TabsTrigger value="advanced">{{ t("connection.advancedTab") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -2640,50 +2726,43 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseSsh" value="ssh" class="m-0">
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshTunnel") }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.ssh_enabled" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshEnable") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("connection.sshHops") }}</Label>
|
||||
<div class="col-span-3 grid gap-3">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<template v-for="(segment, index) in sshPathSegments" :key="`${segment}-${index}`">
|
||||
<template v-for="(segment, index) in transportPathSegments" :key="`${segment}-${index}`">
|
||||
<span class="rounded border bg-muted/40 px-2 py-1">{{ segment }}</span>
|
||||
<ChevronRight v-if="index < sshPathSegments.length - 1" class="h-3 w-3" />
|
||||
<ChevronRight v-if="index < transportPathSegments.length - 1" class="h-3 w-3" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<button
|
||||
v-for="(hop, index) in sshTunnels"
|
||||
v-for="(hop, index) in transportLayers"
|
||||
:key="hop.id"
|
||||
type="button"
|
||||
draggable="true"
|
||||
class="flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
|
||||
:class="hop.id === selectedSshTunnel?.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click="selectedSshTunnelId = hop.id"
|
||||
@dragstart="draggedSshTunnelId = hop.id"
|
||||
:class="
|
||||
hop.id === selectedTransportLayer?.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||
"
|
||||
@click="selectedTransportLayerId = hop.id"
|
||||
@dragstart="draggedTransportLayerId = hop.id"
|
||||
@dragover.prevent
|
||||
@drop="dropSshTunnel(hop.id)"
|
||||
@drop="dropTransportLayer(hop.id)"
|
||||
>
|
||||
<GripVertical class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="w-5 shrink-0 text-muted-foreground">{{ index + 1 }}</span>
|
||||
<input
|
||||
v-model="hop.enabled"
|
||||
type="checkbox"
|
||||
class="mr-0"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click.stop
|
||||
/>
|
||||
<input v-model="hop.enabled" type="checkbox" class="mr-0" @click.stop />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ hop.name || hop.host || t("connection.sshHopDefaultName", { index: index + 1 }) }}
|
||||
{{
|
||||
hop.name ||
|
||||
hop.host ||
|
||||
(hop.type === "proxy"
|
||||
? `Proxy ${index + 1}`
|
||||
: t("connection.sshHopDefaultName", { index: index + 1 }))
|
||||
}}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -2691,8 +2770,8 @@ function openExternalUrl(url: string) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="index === 0 || !form.ssh_enabled"
|
||||
@click.stop="moveSshTunnel(hop.id, -1)"
|
||||
:disabled="index === 0"
|
||||
@click.stop="moveTransportLayer(hop.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -2705,8 +2784,8 @@ function openExternalUrl(url: string) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="index === sshTunnels.length - 1 || !form.ssh_enabled"
|
||||
@click.stop="moveSshTunnel(hop.id, 1)"
|
||||
:disabled="index === transportLayers.length - 1"
|
||||
@click.stop="moveTransportLayer(hop.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -2716,34 +2795,30 @@ function openExternalUrl(url: string) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click="addSshTunnel"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" @click="addSshTunnel">
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("connection.sshHopAdd") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="addProxyTunnel">
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("connection.proxy") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedSshTunnel"
|
||||
v-if="selectedTransportLayer"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click="duplicateSshTunnel(selectedSshTunnel)"
|
||||
@click="duplicateTransportLayer(selectedTransportLayer)"
|
||||
>
|
||||
<Copy class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("connection.sshHopDuplicate") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedSshTunnel"
|
||||
v-if="selectedTransportLayer"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!form.ssh_enabled"
|
||||
@click="removeSshTunnel(selectedSshTunnel.id)"
|
||||
@click="removeTransportLayer(selectedTransportLayer.id)"
|
||||
>
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("connection.sshHopDelete") }}
|
||||
@@ -2752,179 +2827,182 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedSshTunnel">
|
||||
<template v-if="selectedTransportLayer">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshHopName") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshTunnel.name"
|
||||
v-model="selectedTransportLayer.name"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshHopNamePlaceholder')"
|
||||
:disabled="!form.ssh_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshTunnel.host"
|
||||
class="col-span-2"
|
||||
placeholder="ssh.example.com"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
<Input
|
||||
v-model.number="selectedSshTunnel.port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="col-span-1"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshTunnel.user"
|
||||
class="col-span-3"
|
||||
placeholder="root"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshTunnel.password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshPasswordPlaceholder')"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPath") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input
|
||||
v-model="selectedSshTunnel.key_path"
|
||||
class="flex-1"
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
@click="browseSshKeyPath(selectedSshTunnel)"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshTunnel.key_passphrase"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshKeyPassphrasePlaceholder')"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label
|
||||
class="col-span-3 flex items-center gap-2"
|
||||
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
<Label class="text-right text-xs">Type</Label>
|
||||
<Select
|
||||
:model-value="selectedTransportLayer.type"
|
||||
@update:model-value="(value: any) => changeSelectedTransportLayerType(value)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="selectedSshTunnel.expose_lan"
|
||||
class="mr-0"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ssh">SSH</SelectItem>
|
||||
<SelectItem value="proxy">Proxy</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<template v-if="selectedSshLayer">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshLayer.host"
|
||||
class="col-span-2"
|
||||
placeholder="ssh.example.com"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
<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-right text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
|
||||
<Input
|
||||
v-model.number="selectedSshTunnel.connect_timeout_secs"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
step="1"
|
||||
class="col-span-3"
|
||||
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model.number="selectedSshLayer.port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="col-span-1"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshLayer.user"
|
||||
class="col-span-3"
|
||||
placeholder="root"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshLayer.password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshPasswordPlaceholder')"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPath") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input
|
||||
v-model="selectedSshLayer.key_path"
|
||||
class="flex-1"
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
@click="browseSshKeyPath(selectedSshLayer)"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
|
||||
<Input
|
||||
v-model="selectedSshLayer.key_passphrase"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.sshKeyPassphrasePlaceholder')"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="selectedSshLayer.expose_lan"
|
||||
class="mr-0"
|
||||
:disabled="selectedSshLayer.enabled === false"
|
||||
/>
|
||||
<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-right text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
|
||||
<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">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyType") }}</Label>
|
||||
<Select
|
||||
:model-value="selectedProxyLayer.proxy_type || 'socks5'"
|
||||
:disabled="selectedProxyLayer.enabled === false"
|
||||
@update:model-value="updateSelectedProxyType"
|
||||
>
|
||||
<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-right text-xs">{{ t("connection.proxyHost") }}</Label>
|
||||
<Input
|
||||
v-model="selectedProxyLayer.host"
|
||||
class="col-span-2"
|
||||
placeholder="127.0.0.1"
|
||||
:disabled="selectedProxyLayer.enabled === false"
|
||||
/>
|
||||
<Input
|
||||
v-model.number="selectedProxyLayer.port"
|
||||
type="number"
|
||||
class="col-span-1"
|
||||
:disabled="selectedProxyLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyUsername") }}</Label>
|
||||
<Input
|
||||
v-model="selectedProxyLayer.username"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.proxyUsernamePlaceholder')"
|
||||
:disabled="selectedProxyLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyPassword") }}</Label>
|
||||
<Input
|
||||
v-model="selectedProxyLayer.password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.proxyPasswordPlaceholder')"
|
||||
:disabled="selectedProxyLayer.enabled === false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseProxy" value="proxy" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxy") }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.proxy_enabled" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.proxyEnable") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyType") }}</Label>
|
||||
<Select
|
||||
:model-value="form.proxy_type || 'socks5'"
|
||||
:disabled="!form.proxy_enabled"
|
||||
@update:model-value="(value: any) => (form.proxy_type = value)"
|
||||
>
|
||||
<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-right text-xs">{{ t("connection.proxyHost") }}</Label>
|
||||
<Input
|
||||
v-model="form.proxy_host"
|
||||
class="col-span-2"
|
||||
placeholder="127.0.0.1"
|
||||
:disabled="!form.proxy_enabled"
|
||||
/>
|
||||
<Input
|
||||
v-model.number="form.proxy_port"
|
||||
type="number"
|
||||
class="col-span-1"
|
||||
:disabled="!form.proxy_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyUsername") }}</Label>
|
||||
<Input
|
||||
v-model="form.proxy_username"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.proxyUsernamePlaceholder')"
|
||||
:disabled="!form.proxy_enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.proxyPassword") }}</Label>
|
||||
<Input
|
||||
v-model="form.proxy_password"
|
||||
type="password"
|
||||
class="col-span-3"
|
||||
:placeholder="t('connection.proxyPasswordPlaceholder')"
|
||||
:disabled="!form.proxy_enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user