feat: add SSH config aliases and PostgreSQL extensions

This commit is contained in:
zergmk2 authored and GitHub committed 2026-07-05 20:29:49 +08:00
1 parent 8b035d0989
commit f800d80f90
35 files changed
+1191 -33

No files matched your search

@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
@@ -200,9 +200,24 @@ function defaultSshTunnel(): SshTunnelConfig {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
};
}
/**
* Infers a login method for connections saved before `auth_method` existed
* (or imported from a source that never set it), so the dropdown shows a
* sensible current state instead of defaulting blindly to "password".
* Mirrors the priority `connect_and_authenticate` actually uses at connect
* time (key > password > agent > none) — see `db/ssh_tunnel.rs`.
*/
function inferSshAuthMethod(hop: Partial<SshTunnelConfig>): "password" | "key" | "agent" | "none" {
if (hop.key_path?.trim()) return "key";
if (hop.password) return "password";
if (hop.use_ssh_agent) return "agent";
return "none";
}
function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
return {
id: hop.id || uuid(),
@@ -218,6 +233,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
expose_lan: !!hop.expose_lan,
use_ssh_agent: !!hop.use_ssh_agent,
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
auth_method: hop.auth_method || inferSshAuthMethod(hop),
};
}
@@ -346,6 +362,7 @@ const mongoUseUrl = ref(false);
const jdbcDriverPathsInput = ref("");
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
const sshConfigHosts = ref<SshConfigHostEntry[]>([]);
const agentDrivers = ref<AgentDriverInstallState[]>([]);
const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false);
@@ -2796,6 +2813,7 @@ watch(
if (!props.prefillConfig?.oneTime) {
void loadJdbcDrivers();
void loadAgentDrivers();
void loadSshConfigHosts();
}
// Preload database names so the summary count is accurate right away.
void nextTick(() => {
@@ -2930,6 +2948,33 @@ function updateSelectedProxyType(value: unknown) {
resetTestState();
}
/**
* "agent" is legacy-only: it's never chosen from this dropdown, only ever
* inherited from a connection saved before this selector existed. Once the
* user picks something else, the option (and its underlying checkbox) is
* gone from the form for good.
*/
function isLegacySshAgentMethod(hop: Partial<SshTunnelConfig> | null | undefined) {
return hop?.auth_method === "agent";
}
function updateSelectedSshAuthMethod(value: unknown) {
const layer = selectedSshLayer.value;
if (!layer) return;
layer.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password";
// Scrub credential fields that do not apply to the selected method so
// they are not accidentally submitted or used by the backend fallback.
if (layer.auth_method !== "password") layer.password = "";
if (layer.auth_method !== "key") {
layer.key_path = "";
layer.key_passphrase = "";
}
if (layer.auth_method !== "key") {
layer.use_ssh_agent = false;
}
resetTestState();
}
function validateTransportLayers(config: LegacyConnectionConfig) {
const layers = config.transport_layers || [];
layers.forEach((layer, index) => {
@@ -3013,6 +3058,28 @@ watch([() => editingId.value, () => open.value], () => {
dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title");
});
const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias));
/**
* Prefills user/port/key_path from a matching ~/.ssh/config alias, without
* overwriting values the user already changed away from the form defaults.
* This is a UX preview only — the authoritative resolution happens in the
* Rust backend at connect time (see resolve_ssh_tunnel_config), so imported
* configs that never touched this UI still resolve correctly.
*/
function applySshConfigHostAliasPrefill(target: SshTunnelConfig) {
const entry = sshConfigHosts.value.find((candidate) => candidate.alias === target.host);
if (!entry) return;
if (target.user === DEFAULT_SSH_USER && entry.user) target.user = entry.user;
if (target.port === 22 && entry.port) target.port = entry.port;
if (!target.key_path && entry.identity_file) {
target.key_path = entry.identity_file;
if ((!target.auth_method || target.auth_method === "password") && !target.password?.trim()) {
target.auth_method = "key";
}
}
}
async function browseSshKeyPath(target?: SshTunnelConfig | null) {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -3229,6 +3296,14 @@ async function loadJdbcDrivers() {
}
}
async function loadSshConfigHosts() {
try {
sshConfigHosts.value = await api.listSshConfigHosts();
} catch {
sshConfigHosts.value = [];
}
}
async function loadAgentDrivers() {
try {
agentDrivers.value = await api.listInstalledAgentsLocal();
@@ -4733,7 +4808,10 @@ function openExternalUrl(url: string) {
<template v-if="selectedSshLayer">
<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" placeholder="ssh.example.com" :disabled="selectedSshLayer.enabled === false" />
<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!)" />
<datalist id="ssh-config-host-aliases">
<option v-for="alias in sshConfigHostAliases" :key="alias" :value="alias" />
</datalist>
<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">
@@ -4741,10 +4819,20 @@ function openExternalUrl(url: string) {
<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="connectionLabelSmallClass">{{ t("connection.sshPassword") }}</Label>
<PasswordInput v-model="selectedSshLayer.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSshLayer.auth_method || 'password'" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAuthMethod">
<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>
<SelectItem v-if="isLegacySshAgentMethod(selectedSshLayer)" value="agent" disabled>{{ t("connection.sshAuthMethodAgentLegacy") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="selectedSshLayer.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ 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" />
@@ -4758,21 +4846,31 @@ function openExternalUrl(url: string) {
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="selectedSshLayer.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPassphrase") }}</Label>
<PasswordInput v-model="selectedSshLayer.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="!selectedSshLayer.auth_method || selectedSshLayer.auth_method === 'password'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshPassword") }}</Label>
<PasswordInput v-model="selectedSshLayer.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div v-if="selectedSshLayer.auth_method === 'none'" 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.use_ssh_agent" class="mr-0" :disabled="selectedSshLayer.enabled === false" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshUseAgent") }}</span>
</label>
</div>
<div v-if="selectedSshLayer.use_ssh_agent" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
</div>
<template v-if="isLegacySshAgentMethod(selectedSshLayer)">
<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.use_ssh_agent" class="mr-0" :disabled="selectedSshLayer.enabled === false" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshUseAgent") }}</span>
</label>
</div>
<div v-if="selectedSshLayer.use_ssh_agent" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 cursor-pointer">