feat(connection): support multi-hop ssh tunnels

This commit is contained in:
t8y2 committed 2026-06-01 14:48:33 +08:00
1 parent 7f5de70687
commit 46c05b811f
19 files changed
+1131 -152

No files matched your search

@@ -9,7 +9,7 @@ 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 } from "@/types/database";
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, SshTunnelConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@@ -23,17 +23,22 @@ import { copyToClipboard } from "@/lib/clipboard";
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
import {
ArrowLeft,
ArrowDown,
ArrowUp,
ChevronRight,
Copy,
ExternalLink,
FilePlus2,
FolderOpen,
GripVertical,
Grid3X3,
KeyRound,
Link2,
List,
Plus,
Search,
ShieldCheck,
Trash2,
} from "lucide-vue-next";
type DbOption = { value: string; label: string };
@@ -87,6 +92,7 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
ssh_key_passphrase: "",
ssh_expose_lan: false,
ssh_connect_timeout_secs: 5,
ssh_tunnels: [],
connect_timeout_secs: 5,
query_timeout_secs: 30,
proxy_enabled: false,
@@ -110,7 +116,71 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
redis_cluster_nodes: "",
});
function defaultSshTunnel(): SshTunnelConfig {
return {
id: uuid(),
name: "",
enabled: true,
host: "",
port: 22,
user: "",
password: "",
key_path: "",
key_passphrase: "",
connect_timeout_secs: 5,
expose_lan: false,
};
}
function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
return {
id: hop.id || uuid(),
name: hop.name || "",
enabled: hop.enabled !== false,
host: hop.host || "",
port: Number(hop.port) || 22,
user: hop.user || "",
password: hop.password || "",
key_path: hop.key_path || "",
key_passphrase: hop.key_passphrase || "",
connect_timeout_secs: Number(hop.connect_timeout_secs) || 5,
expose_lan: !!hop.expose_lan,
};
}
function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] {
if (config.ssh_tunnels?.length) {
return config.ssh_tunnels.map(normalizeSshTunnel);
}
if (
config.ssh_enabled ||
config.ssh_host ||
config.ssh_user ||
config.ssh_password ||
config.ssh_key_path ||
config.ssh_key_passphrase
) {
return [
normalizeSshTunnel({
id: "legacy",
enabled: true,
host: config.ssh_host || "",
port: config.ssh_port || 22,
user: config.ssh_user || "",
password: config.ssh_password || "",
key_path: config.ssh_key_path || "",
key_passphrase: config.ssh_key_passphrase || "",
connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
expose_lan: config.ssh_expose_lan || false,
}),
];
}
return [];
}
const form = ref(defaultForm());
const selectedSshTunnelId = ref<string | null>(null);
const draggedSshTunnelId = ref<string | null>(null);
const selectedType = ref("mysql");
const customDriverName = ref("");
const mongoUseUrl = ref(false);
@@ -366,6 +436,7 @@ watch(
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),
connect_timeout_secs: config.connect_timeout_secs || 5,
query_timeout_secs: config.query_timeout_secs ?? 30,
proxy_enabled: config.proxy_enabled || false,
@@ -388,6 +459,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;
selectedType.value = profile;
if (profile === "oceanbase") {
oceanbaseSubMode.value = config.driver_profile === "oceanbase-oracle" ? "oracle" : "mysql";
@@ -400,6 +472,7 @@ watch(
} else {
editingId.value = null;
form.value = defaultForm();
selectedSshTunnelId.value = null;
selectedType.value = "mysql";
customDriverName.value = "";
oceanbaseSubMode.value = "mysql";
@@ -429,6 +502,20 @@ 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 sshPathSegments = computed(() => {
const hops = sshTunnels.value.filter((hop) => hop.enabled !== false);
return [
"DBX",
...hops.map((hop, index) => hop.name?.trim() || hop.host?.trim() || `SSH ${index + 1}`),
form.value.host || "Database",
];
});
function defaultDatabaseForProfile() {
if (form.value.db_type === "redshift") return "dev";
if (form.value.db_type === "gaussdb") return "postgres";
@@ -774,6 +861,24 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
}
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);
const timeout = Number(normalized.connect_timeout_secs);
normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
return 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);
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);
@@ -1012,6 +1117,8 @@ async function copyTestResult() {
function resetForm() {
editingId.value = null;
form.value = defaultForm();
selectedSshTunnelId.value = null;
draggedSshTunnelId.value = null;
selectedType.value = "mysql";
customDriverName.value = "";
mongoUseUrl.value = false;
@@ -1137,6 +1244,80 @@ watch(supportsTlsToggle, (value) => {
}
});
function ensureSelectedSshTunnel() {
if (!selectedSshTunnelId.value || !sshTunnels.value.some((hop) => hop.id === selectedSshTunnelId.value)) {
selectedSshTunnelId.value = sshTunnels.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;
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;
resetTestState();
}
function removeSshTunnel(id: string) {
form.value.ssh_tunnels = sshTunnels.value.filter((hop) => hop.id !== id);
ensureSelectedSshTunnel();
resetTestState();
}
function moveSshTunnel(id: string, direction: -1 | 1) {
const tunnels = [...sshTunnels.value];
const index = tunnels.findIndex((hop) => hop.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;
resetTestState();
}
function dropSshTunnel(targetId: string) {
const sourceId = draggedSshTunnelId.value;
draggedSshTunnelId.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);
if (sourceIndex < 0 || targetIndex < 0) return;
const [source] = tunnels.splice(sourceIndex, 1);
tunnels.splice(targetIndex, 0, source);
form.value.ssh_tunnels = tunnels;
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);
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 }));
}
});
}
async function save() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
@@ -1181,7 +1362,7 @@ watch([() => editingId.value, () => open.value], () => {
dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title");
});
async function browseSshKeyPath() {
async function browseSshKeyPath(target?: SshTunnelConfig | null) {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
@@ -1189,7 +1370,11 @@ async function browseSshKeyPath() {
multiple: false,
});
if (selected && typeof selected === "string") {
form.value.ssh_key_path = selected;
if (target) {
target.key_path = selected;
} else {
form.value.ssh_key_path = selected;
}
}
}
}
@@ -2318,92 +2503,218 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.sshEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
<Input
v-model="form.ssh_host"
class="col-span-2"
placeholder="ssh.example.com"
:disabled="!form.ssh_enabled"
/>
<Input
v-model.number="form.ssh_port"
type="number"
class="col-span-1"
:disabled="!form.ssh_enabled"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
<Input
v-model="form.ssh_password"
type="password"
class="col-span-3"
:placeholder="t('connection.sshPasswordPlaceholder')"
:disabled="!form.ssh_enabled"
/>
</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="form.ssh_key_path"
class="flex-1"
placeholder="~/.ssh/id_rsa"
:disabled="!form.ssh_enabled"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
<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}`">
<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" />
</template>
</div>
<div class="grid gap-2">
<button
v-for="(hop, index) in sshTunnels"
: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"
@dragover.prevent
@drop="dropSshTunnel(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="browseSshKeyPath"
>
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
</Tooltip>
@click.stop
/>
<span class="min-w-0 flex-1 truncate">
{{ hop.name || hop.host || t("connection.sshHopDefaultName", { index: index + 1 }) }}
</span>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="index === 0 || !form.ssh_enabled"
@click.stop="moveSshTunnel(hop.id, -1)"
>
<ArrowUp class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveUp") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="index === sshTunnels.length - 1 || !form.ssh_enabled"
@click.stop="moveSshTunnel(hop.id, 1)"
>
<ArrowDown class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveDown") }}</TooltipContent>
</Tooltip>
</button>
</div>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="addSshTunnel"
>
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopAdd") }}
</Button>
<Button
v-if="selectedSshTunnel"
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="duplicateSshTunnel(selectedSshTunnel)"
>
<Copy class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDuplicate") }}
</Button>
<Button
v-if="selectedSshTunnel"
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="removeSshTunnel(selectedSshTunnel.id)"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDelete") }}
</Button>
</div>
</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="form.ssh_key_passphrase"
type="password"
class="col-span-3"
:placeholder="t('connection.sshKeyPassphrasePlaceholder')"
:disabled="!form.ssh_enabled"
/>
</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'"
>
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" :disabled="!form.ssh_enabled" />
<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="form.ssh_connect_timeout_secs"
type="number"
min="5"
max="300"
step="1"
class="col-span-3"
:disabled="!form.ssh_enabled"
/>
</div>
<template v-if="selectedSshTunnel">
<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"
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'"
>
<input
type="checkbox"
v-model="selectedSshTunnel.expose_lan"
class="mr-0"
:disabled="!form.ssh_enabled || selectedSshTunnel.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>
</template>
</div>
</TabsContent>
+14
View File
@@ -235,6 +235,20 @@ export default {
sshKeyPathBrowse: "Browse",
sshExposeLan: "Expose tunnel to LAN",
sshConnectTimeout: "SSH Timeout (seconds)",
sshHops: "SSH Hops",
sshHopAdd: "Add hop",
sshHopDuplicate: "Duplicate",
sshHopDelete: "Delete",
sshHopMoveUp: "Move up",
sshHopMoveDown: "Move down",
sshHopName: "Hop Name",
sshHopNamePlaceholder: "Bastion, jump host, production gateway",
sshHopDefaultName: "Hop {index}",
sshHopInvalidHost: "{hop}: SSH host is required",
sshHopInvalidUser: "{hop}: SSH user is required",
sshHopInvalidPort: "{hop}: SSH port must be between 1 and 65535",
sshHopInvalidAuth: "{hop}: password or key path is required",
sshHopInvalidTimeout: "{hop}: SSH timeout must be between 1 and 300 seconds",
connectTimeout: "Connection Timeout (seconds)",
queryTimeout: "Query Timeout (seconds)",
proxy: "Proxy",
+14
View File
@@ -232,6 +232,20 @@ export default {
sshKeyPathBrowse: "Examinar",
sshExposeLan: "Exponer túnel a la red local",
sshConnectTimeout: "Tiempo de espera SSH (segundos)",
sshHops: "Saltos SSH",
sshHopAdd: "Agregar salto",
sshHopDuplicate: "Duplicar",
sshHopDelete: "Eliminar",
sshHopMoveUp: "Subir",
sshHopMoveDown: "Bajar",
sshHopName: "Nombre del salto",
sshHopNamePlaceholder: "Bastión, jump host, puerta de producción",
sshHopDefaultName: "Salto {index}",
sshHopInvalidHost: "{hop}: el host SSH es obligatorio",
sshHopInvalidUser: "{hop}: el usuario SSH es obligatorio",
sshHopInvalidPort: "{hop}: el puerto SSH debe estar entre 1 y 65535",
sshHopInvalidAuth: "{hop}: se requiere contraseña o ruta de clave",
sshHopInvalidTimeout: "{hop}: el tiempo de espera SSH debe estar entre 1 y 300 segundos",
connectTimeout: "Tiempo de espera de conexión (segundos)",
queryTimeout: "Tiempo de espera de consulta (segundos)",
proxy: "Proxy",
+14
View File
@@ -231,6 +231,20 @@ export default {
sshKeyPathBrowse: "浏览",
sshExposeLan: "允许局域网访问隧道",
sshConnectTimeout: "SSH 超时时间(秒)",
sshHops: "SSH 层级",
sshHopAdd: "添加层级",
sshHopDuplicate: "复制",
sshHopDelete: "删除",
sshHopMoveUp: "上移",
sshHopMoveDown: "下移",
sshHopName: "层级名称",
sshHopNamePlaceholder: "堡垒机、跳板机、生产网关",
sshHopDefaultName: "第 {index} 跳",
sshHopInvalidHost: "{hop}:SSH 主机不能为空",
sshHopInvalidUser: "{hop}:SSH 用户不能为空",
sshHopInvalidPort: "{hop}:SSH 端口必须在 1 到 65535 之间",
sshHopInvalidAuth: "{hop}:需要填写密码或密钥路径",
sshHopInvalidTimeout: "{hop}:SSH 超时时间必须在 1 到 300 秒之间",
connectTimeout: "连接超时(秒)",
queryTimeout: "查询超时(秒)",
proxy: "代理",
@@ -246,6 +246,7 @@ export const useConnectionStore = defineStore("connection", () => {
? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim())
: [],
ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
ssh_tunnels: Array.isArray(config.ssh_tunnels) ? config.ssh_tunnels : [],
connect_timeout_secs: config.connect_timeout_secs || 5,
query_timeout_secs: config.query_timeout_secs ?? 30,
proxy_type: config.proxy_type || "socks5",
+15
View File
@@ -76,6 +76,7 @@ export interface ConnectionConfig {
ssh_key_passphrase?: string;
ssh_expose_lan?: boolean;
ssh_connect_timeout_secs?: number;
ssh_tunnels?: SshTunnelConfig[];
connect_timeout_secs?: number;
query_timeout_secs?: number;
proxy_enabled?: boolean;
@@ -101,6 +102,20 @@ export interface ConnectionConfig {
one_time?: boolean;
}
export interface SshTunnelConfig {
id: string;
name?: string;
enabled?: boolean;
host: string;
port: number;
user: string;
password?: string;
key_path?: string;
key_passphrase?: string;
connect_timeout_secs?: number;
expose_lan?: boolean;
}
export interface AttachedDatabaseConfig {
name: string;
path: string;