feat(connection): add reusable tunnel profiles

This commit is contained in:
rainhan authored and GitHub committed 2026-07-12 22:03:46 +08:00
1 parent 4dcee1aca0
commit 977e7f78ca
34 files changed
+1335 -22

No files matched your search

+4
View File
@@ -2074,6 +2074,10 @@ onUnmounted(() => {
setConnectionDialogOpen(false);
openDriverStorePage();
"
@open-tunnel-profile-settings="
setConnectionDialogOpen(false);
openSettings('tunnels');
"
@open-lineage-target="openLineageTarget"
@open-database-search-target="openDatabaseSearchTarget"
@open-diagram-target="openDiagramTarget"
@@ -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>
@@ -46,6 +46,7 @@ import {
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -1244,13 +1245,14 @@ const appSupportInfoLabels = computed<AppSupportInfoLabels>(() => ({
unknown: t("settings.supportInfoUnknown"),
}));
const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : []));
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "editor", label: t("settings.editorTab") },
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
{ value: "tunnels", label: t("settings.tunnelsTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
{ value: "snippets", label: t("settings.snippetsTab") },
...(isWeb ? [] : [{ value: "sync" as const, label: t("settings.syncTab") }]),
@@ -4496,6 +4498,10 @@ onUnmounted(cleanupPreviewEditor);
</div>
</section>
<section v-else-if="activeSettingsTab === 'tunnels'" class="flex flex-col gap-5 py-2">
<TunnelProfileManager />
</section>
<section v-else-if="activeSettingsTab === 'about'" class="flex flex-col gap-5 py-2">
<div class="rounded-lg border p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@@ -50,6 +50,7 @@ const emit = defineEmits<{
connectSucceeded: [name: string];
connectFailed: [message: string];
openDriverStore: [];
openTunnelProfileSettings: [];
openLineageTarget: [
target: {
connectionId: string;
@@ -136,6 +137,7 @@ watch(
@connect-succeeded="emit('connectSucceeded', $event)"
@connect-failed="emit('connectFailed', $event)"
@open-driver-store="emit('openDriverStore')"
@open-tunnel-profile-settings="emit('openTunnelProfileSettings')"
/>
<DangerConfirmDialog
v-if="showDangerDialog"
+21
View File
@@ -410,6 +410,12 @@ export default {
proxyUsernamePlaceholder: "Optional",
proxyPassword: "Proxy Password",
proxyPasswordPlaceholder: "Optional",
tunnelProfile: "Tunnel Profile",
tunnelProfileCustom: "Custom (this connection only)",
tunnelProfileManaged: "Managed by a shared tunnel profile. Edit it in Settings > Tunnels — changes apply to every connection using it on its next connection.",
tunnelProfileMissing: "The referenced tunnel profile no longer exists. Select another profile or switch to custom.",
tunnelProfileMissingName: "Missing tunnel profile",
tunnelProfileManage: "Manage",
httpTunnel: "HTTP Tunnel",
httpTunnelAdd: "Add HTTP tunnel",
httpTunnelUrl: "Tunnel Script URL",
@@ -3082,6 +3088,21 @@ export default {
appearanceTab: "Appearance",
navigationTab: "Navigation",
dataTab: "Data",
tunnelsTab: "Tunnels",
tunnelsDescription: "Reusable SSH / proxy / HTTP tunnel configurations. Configure once, then select the profile from a connection's tunnel tab; edits here apply to every connection using the profile.",
tunnelsEmpty: "No tunnel profiles yet. Create one here, then select it in a connection's tunnel settings.",
tunnelsAddSsh: "Add SSH",
tunnelsAddProxy: "Add Proxy",
tunnelsAddHttp: "Add HTTP Tunnel",
tunnelsProfileName: "Profile Name",
tunnelsProfileNamePlaceholder: "e.g. Office bastion",
tunnelsDelete: "Delete",
tunnelsUnnamedProfile: "Unnamed profile",
tunnelsSave: "Save",
tunnelsReset: "Reset",
tunnelsSaved: "Tunnel profiles saved",
tunnelsSaveFailed: "Failed to save tunnel profiles: {message}",
tunnelsUnsavedHint: "Unsaved changes",
redisTab: "Redis",
shortcutsTab: "Shortcuts",
snippetsTab: "Snippets",
+21
View File
@@ -391,6 +391,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Contraseña del proxy",
proxyPasswordPlaceholder: "Opcional",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (solo esta conexión)",
tunnelProfileManaged: "Gestionado por un perfil de túnel compartido. Edítalo en Ajustes > Túneles; los cambios se aplican a todas las conexiones que lo usan en su próxima conexión.",
tunnelProfileMissing: "El perfil de túnel referenciado ya no existe. Selecciona otro perfil o cambia a personalizado.",
tunnelProfileMissingName: "Perfil de túnel no encontrado",
tunnelProfileManage: "Gestionar",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC heredado",
jdbcUrl: "URL JDBC",
@@ -2934,6 +2940,21 @@ export default withEnglishFallback({
appearanceTab: "Apariencia",
navigationTab: "Navegación",
dataTab: "Datos",
tunnelsTab: "Túneles",
tunnelsDescription: "Configuraciones reutilizables de túneles SSH / proxy / HTTP. Configura una vez y selecciona el perfil desde la pestaña de túnel de una conexión; los cambios aquí se aplican a todas las conexiones que usan el perfil.",
tunnelsEmpty: "Aún no hay perfiles de túnel. Crea uno aquí y selecciónalo en la configuración de túnel de una conexión.",
tunnelsAddSsh: "Añadir SSH",
tunnelsAddProxy: "Añadir proxy",
tunnelsAddHttp: "Añadir túnel HTTP",
tunnelsProfileName: "Nombre del perfil",
tunnelsProfileNamePlaceholder: "p. ej. Bastión de la oficina",
tunnelsDelete: "Eliminar",
tunnelsUnnamedProfile: "Perfil sin nombre",
tunnelsSave: "Guardar",
tunnelsReset: "Restablecer",
tunnelsSaved: "Perfiles de túnel guardados",
tunnelsSaveFailed: "Error al guardar los perfiles de túnel: {message}",
tunnelsUnsavedHint: "Cambios sin guardar",
redisTab: "Redis",
shortcutsTab: "Atajos",
snippetsTab: "Fragmentos",
+21
View File
@@ -389,6 +389,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opzionale",
proxyPassword: "Password Proxy",
proxyPasswordPlaceholder: "Opzionale",
tunnelProfile: "Profilo tunnel",
tunnelProfileCustom: "Personalizzato (solo questa connessione)",
tunnelProfileManaged: "Gestito da un profilo tunnel condiviso. Modificalo in Impostazioni > Tunnel; le modifiche si applicano a tutte le connessioni che lo usano alla prossima connessione.",
tunnelProfileMissing: "Il profilo tunnel a cui si fa riferimento non esiste più. Seleziona un altro profilo o passa a personalizzato.",
tunnelProfileMissingName: "Profilo tunnel mancante",
tunnelProfileManage: "Gestisci",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC legacy",
jdbcUrl: "URL JDBC",
@@ -2932,6 +2938,21 @@ export default withEnglishFallback({
appearanceTab: "Aspetto",
navigationTab: "Navigazione",
dataTab: "Dati",
tunnelsTab: "Tunnel",
tunnelsDescription: "Configurazioni riutilizzabili di tunnel SSH / proxy / HTTP. Configura una volta, poi seleziona il profilo dalla scheda tunnel di una connessione; le modifiche qui si applicano a tutte le connessioni che usano il profilo.",
tunnelsEmpty: "Nessun profilo tunnel. Creane uno qui, poi selezionalo nelle impostazioni tunnel di una connessione.",
tunnelsAddSsh: "Aggiungi SSH",
tunnelsAddProxy: "Aggiungi proxy",
tunnelsAddHttp: "Aggiungi tunnel HTTP",
tunnelsProfileName: "Nome profilo",
tunnelsProfileNamePlaceholder: "es. Bastion dell'ufficio",
tunnelsDelete: "Elimina",
tunnelsUnnamedProfile: "Profilo senza nome",
tunnelsSave: "Salva",
tunnelsReset: "Ripristina",
tunnelsSaved: "Profili tunnel salvati",
tunnelsSaveFailed: "Impossibile salvare i profili tunnel: {message}",
tunnelsUnsavedHint: "Modifiche non salvate",
redisTab: "Redis",
shortcutsTab: "Scorciatoie",
snippetsTab: "Snippet",
+21
View File
@@ -383,6 +383,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "任意",
proxyPassword: "プロキシパスワード",
proxyPasswordPlaceholder: "任意",
tunnelProfile: "トンネルプロファイル",
tunnelProfileCustom: "カスタム(この接続のみ)",
tunnelProfileManaged: "共有トンネルプロファイルで管理されています。設定 > トンネル で編集すると、次回接続時にこのプロファイルを使用するすべての接続に反映されます。",
tunnelProfileMissing: "参照しているトンネルプロファイルが存在しません。別のプロファイルを選択するか、カスタムに切り替えてください。",
tunnelProfileMissingName: "プロファイルが見つかりません",
tunnelProfileManage: "管理",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "Legacy JDBC",
jdbcUrl: "JDBC URL",
@@ -2933,6 +2939,21 @@ export default withEnglishFallback({
appearanceTab: "外観",
navigationTab: "ナビゲーション",
dataTab: "データ",
tunnelsTab: "トンネル",
tunnelsDescription: "再利用可能な SSH / プロキシ / HTTP トンネル設定です。一度設定すれば、接続のトンネルタブからプロファイルを選択するだけで使えます。ここでの編集は、このプロファイルを使用するすべての接続に反映されます。",
tunnelsEmpty: "トンネルプロファイルがまだありません。ここで作成し、接続のトンネル設定で選択してください。",
tunnelsAddSsh: "SSH を追加",
tunnelsAddProxy: "プロキシを追加",
tunnelsAddHttp: "HTTP トンネルを追加",
tunnelsProfileName: "プロファイル名",
tunnelsProfileNamePlaceholder: "例:オフィスの踏み台サーバー",
tunnelsDelete: "削除",
tunnelsUnnamedProfile: "名称未設定のプロファイル",
tunnelsSave: "保存",
tunnelsReset: "リセット",
tunnelsSaved: "トンネルプロファイルを保存しました",
tunnelsSaveFailed: "トンネルプロファイルの保存に失敗しました: {message}",
tunnelsUnsavedHint: "未保存の変更があります",
redisTab: "Redis",
shortcutsTab: "ショートカット",
snippetsTab: "スニペット",
+21
View File
@@ -390,6 +390,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Senha do Proxy",
proxyPasswordPlaceholder: "Opcional",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (somente esta conexão)",
tunnelProfileManaged: "Gerenciado por um perfil de túnel compartilhado. Edite-o em Configurações > Túneis; as alterações se aplicam a todas as conexões que o usam na próxima conexão.",
tunnelProfileMissing: "O perfil de túnel referenciado não existe mais. Selecione outro perfil ou mude para personalizado.",
tunnelProfileMissingName: "Perfil de túnel ausente",
tunnelProfileManage: "Gerenciar",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC legado",
jdbcUrl: "URL JDBC",
@@ -2934,6 +2940,21 @@ export default withEnglishFallback({
appearanceTab: "Aparência",
navigationTab: "Navegação",
dataTab: "Dados",
tunnelsTab: "Túneis",
tunnelsDescription: "Configurações reutilizáveis de túneis SSH / proxy / HTTP. Configure uma vez e selecione o perfil na aba de túnel de uma conexão; as edições aqui se aplicam a todas as conexões que usam o perfil.",
tunnelsEmpty: "Ainda não há perfis de túnel. Crie um aqui e selecione-o nas configurações de túnel de uma conexão.",
tunnelsAddSsh: "Adicionar SSH",
tunnelsAddProxy: "Adicionar proxy",
tunnelsAddHttp: "Adicionar túnel HTTP",
tunnelsProfileName: "Nome do perfil",
tunnelsProfileNamePlaceholder: "ex.: Bastion do escritório",
tunnelsDelete: "Excluir",
tunnelsUnnamedProfile: "Perfil sem nome",
tunnelsSave: "Salvar",
tunnelsReset: "Redefinir",
tunnelsSaved: "Perfis de túnel salvos",
tunnelsSaveFailed: "Falha ao salvar os perfis de túnel: {message}",
tunnelsUnsavedHint: "Alterações não salvas",
redisTab: "Redis",
shortcutsTab: "Atalhos",
snippetsTab: "Snippets",
+21
View File
@@ -413,6 +413,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可选",
proxyPassword: "代理密码",
proxyPasswordPlaceholder: "可选",
tunnelProfile: "隧道档案",
tunnelProfileCustom: "自定义(仅此连接)",
tunnelProfileManaged: "由共享隧道档案管理。请在 设置 > 隧道维护 中编辑,修改会在下次连接时对所有使用该档案的连接生效。",
tunnelProfileMissing: "引用的隧道档案已不存在。请选择其他档案或切换为自定义配置。",
tunnelProfileMissingName: "档案已删除",
tunnelProfileManage: "管理",
httpTunnel: "HTTP 隧道",
httpTunnelAdd: "添加 HTTP 隧道",
httpTunnelUrl: "隧道脚本 URL",
@@ -3081,6 +3087,21 @@ export default withEnglishFallback({
appearanceTab: "外观",
navigationTab: "导航",
dataTab: "数据",
tunnelsTab: "隧道维护",
tunnelsDescription: "可复用的 SSH / 代理 / HTTP 隧道配置。一次配置,之后在连接的隧道页签中选择档案即可;此处的修改会对所有使用该档案的连接生效。",
tunnelsEmpty: "还没有隧道档案。先在这里创建,然后在连接的隧道设置中选择使用。",
tunnelsAddSsh: "新增 SSH",
tunnelsAddProxy: "新增代理",
tunnelsAddHttp: "新增 HTTP 隧道",
tunnelsProfileName: "档案名称",
tunnelsProfileNamePlaceholder: "例如:办公室跳板机",
tunnelsDelete: "删除",
tunnelsUnnamedProfile: "未命名档案",
tunnelsSave: "保存",
tunnelsReset: "重置",
tunnelsSaved: "隧道档案已保存",
tunnelsSaveFailed: "保存隧道档案失败:{message}",
tunnelsUnsavedHint: "有未保存的修改",
redisTab: "Redis",
shortcutsTab: "快捷键",
snippetsTab: "代码片段",
+21
View File
@@ -389,6 +389,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可選",
proxyPassword: "代理伺服器密碼",
proxyPasswordPlaceholder: "可選",
tunnelProfile: "隧道設定檔",
tunnelProfileCustom: "自訂(僅此連線)",
tunnelProfileManaged: "由共用隧道設定檔管理。請在 設定 > 隧道維護 中編輯,變更會在下次連線時套用到所有使用該設定檔的連線。",
tunnelProfileMissing: "引用的隧道設定檔已不存在。請選擇其他設定檔或切換為自訂。",
tunnelProfileMissingName: "設定檔已刪除",
tunnelProfileManage: "管理",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "Legacy JDBC",
jdbcUrl: "JDBC URL",
@@ -2797,6 +2803,21 @@ export default withEnglishFallback({
securityTab: "安全",
aboutTab: "關於我們",
dataTab: "資料",
tunnelsTab: "隧道維護",
tunnelsDescription: "可重複使用的 SSH / 代理 / HTTP 隧道設定。一次設定,之後在連線的隧道頁籤中選擇設定檔即可;此處的變更會套用到所有使用該設定檔的連線。",
tunnelsEmpty: "還沒有隧道設定檔。先在這裡建立,然後在連線的隧道設定中選擇使用。",
tunnelsAddSsh: "新增 SSH",
tunnelsAddProxy: "新增代理",
tunnelsAddHttp: "新增 HTTP 隧道",
tunnelsProfileName: "設定檔名稱",
tunnelsProfileNamePlaceholder: "例如:辦公室跳板機",
tunnelsDelete: "刪除",
tunnelsUnnamedProfile: "未命名設定檔",
tunnelsSave: "儲存",
tunnelsReset: "重設",
tunnelsSaved: "隧道設定檔已儲存",
tunnelsSaveFailed: "儲存隧道設定檔失敗:{message}",
tunnelsUnsavedHint: "有未儲存的變更",
fontFamily: "字型",
uiFontFamily: "介面字型",
uiFontAppDefault: "DBX 預設",
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createTunnelProfile, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import type { TunnelProfile } from "@/types/database";
function sshProfile(overrides: Partial<TunnelProfile> = {}): TunnelProfile {
return {
...createTunnelProfile("ssh"),
id: "profile-1",
name: "Bastion",
host: "bastion.example.com",
user: "deploy",
password: "s3cret",
...overrides,
} as TunnelProfile;
}
describe("tunnelProfileSummary", () => {
it("formats ssh profiles as user@host:port", () => {
expect(tunnelProfileSummary(sshProfile())).toBe("deploy@bastion.example.com:22");
});
it("formats proxy profiles as scheme://host:port", () => {
const proxy = { ...createTunnelProfile("proxy"), host: "127.0.0.1", port: 1080 } as TunnelProfile;
expect(tunnelProfileSummary(proxy)).toBe("socks5://127.0.0.1:1080");
});
it("returns the url for http tunnel profiles", () => {
const http = { ...createTunnelProfile("http_tunnel"), url: "https://example.com/dbx_tunnel.php" } as TunnelProfile;
expect(tunnelProfileSummary(http)).toBe("https://example.com/dbx_tunnel.php");
});
it("returns an empty string when the target is not configured yet", () => {
expect(tunnelProfileSummary(createTunnelProfile("ssh"))).toBe("");
});
});
describe("tunnelProfileReferenceLayer", () => {
it("keeps only identity, enabled state, and the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: false });
expect(stub.id).toBe("layer-1");
expect(stub.enabled).toBe(false);
expect(stub.profile_id).toBe("profile-1");
expect(stub.name).toBe("Bastion");
// Credentials must not be copied into the stub stored on the connection.
expect(stub.type).toBe("ssh");
if (stub.type === "ssh") {
expect(stub.host).toBe("");
expect(stub.password).toBe("");
}
});
it("generates a fresh id when there is no previous layer", () => {
const stub = tunnelProfileReferenceLayer(sshProfile());
expect(stub.id).toBeTruthy();
expect(stub.enabled).toBe(true);
});
});
describe("detachTunnelProfileLayer", () => {
it("copies the profile configuration onto the layer and drops the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, profile);
expect(detached.id).toBe("layer-1");
expect(detached.profile_id).toBeUndefined();
if (detached.type === "ssh") {
expect(detached.host).toBe("bastion.example.com");
expect(detached.password).toBe("s3cret");
} else {
throw new Error("expected ssh layer");
}
});
it("only drops the reference when the profile no longer exists", () => {
const stub = tunnelProfileReferenceLayer(sshProfile(), { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, undefined);
expect(detached.profile_id).toBeUndefined();
expect(detached.id).toBe("layer-1");
});
});
+2
View File
@@ -59,6 +59,8 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection");
export const refreshConnections = forward("refreshConnections");
export const saveConnections = forward("saveConnections");
export const loadConnections = forward("loadConnections");
export const loadTunnelProfiles = forward("loadTunnelProfiles");
export const saveTunnelProfiles = forward("saveTunnelProfiles");
export const readKeychainPassword = forward("readKeychainPassword");
export const readKeychainPasswords = forward("readKeychainPasswords");
export const decryptConfig = forward("decryptConfig");
+9
View File
@@ -33,6 +33,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
@@ -236,6 +237,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return get("/api/connection/list");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return get("/api/tunnel-profiles/list");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return post("/api/tunnel-profiles/save", { profiles });
}
export async function readKeychainPassword(_service: string): Promise<string> {
return ""; // Not available in web backend
}
+9
View File
@@ -34,6 +34,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -1109,6 +1110,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return invoke("load_tunnel_profiles");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return invoke("save_tunnel_profiles", { profiles });
}
export async function readKeychainPassword(service: string): Promise<string> {
return invoke("read_keychain_password", { service, account: null });
}
@@ -0,0 +1,95 @@
import { uuid } from "@/lib/common/utils";
import type { TransportLayerConfig, TunnelProfile } from "@/types/database";
export type TunnelProfileType = TunnelProfile["type"];
export function createTunnelProfile(type: TunnelProfileType): TunnelProfile {
if (type === "proxy") {
return {
type: "proxy",
id: uuid(),
name: "",
enabled: true,
proxy_type: "socks5",
host: "",
port: 1080,
username: "",
password: "",
};
}
if (type === "http_tunnel") {
return {
type: "http_tunnel",
id: uuid(),
name: "",
enabled: true,
url: "",
token: "",
connect_timeout_secs: 10,
};
}
return {
type: "ssh",
id: uuid(),
name: "",
enabled: true,
host: "",
port: 22,
user: "root",
password: "",
key_path: "",
key_passphrase: "",
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
};
}
export function tunnelProfileSummary(profile: TunnelProfile): string {
if (profile.type === "ssh") {
if (!profile.host) return "";
const user = profile.user ? `${profile.user}@` : "";
return `${user}${profile.host}:${profile.port || 22}`;
}
if (profile.type === "proxy") {
if (!profile.host) return "";
return `${profile.proxy_type || "socks5"}://${profile.host}:${profile.port || 1080}`;
}
return profile.url || "";
}
export function layerReferencesProfile(layer: TransportLayerConfig): boolean {
return !!layer.profile_id;
}
/**
* Builds the reference stub stored on a connection for a layer that uses a
* shared profile: only identity, enabled state, and the reference survive —
* the backend swaps in the profile's full configuration at connect time, and
* keeping credentials out of the stub avoids stale copies.
*/
export function tunnelProfileReferenceLayer(profile: TunnelProfile, previous?: Pick<TransportLayerConfig, "id" | "enabled">): TransportLayerConfig {
const stub = createTunnelProfile(profile.type);
stub.id = previous?.id || stub.id;
stub.enabled = previous?.enabled !== false;
stub.name = profile.name || "";
stub.profile_id = profile.id;
return stub;
}
/**
* Detaches a profile-referencing layer back into a self-contained custom
* layer by copying the profile's full configuration onto it.
*/
export function detachTunnelProfileLayer(layer: TransportLayerConfig, profile: TunnelProfile | undefined): TransportLayerConfig {
if (!profile) {
const detached = { ...layer };
delete detached.profile_id;
return detached;
}
const detached = { ...profile, id: layer.id, enabled: layer.enabled !== false };
delete detached.profile_id;
return detached;
}
+28 -2
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
@@ -23,6 +23,7 @@ import {
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
@@ -4672,7 +4673,9 @@ export const useConnectionStore = defineStore("connection", () => {
async function exportConnectionsToFile(passphrase: string) {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
const exportData = { connections: connections.value, layout: sidebarLayout.value };
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const exportData = { connections: connections.value, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles };
const json = JSON.stringify(exportData);
const payload = await encryptConfig(json, passphrase);
const content = JSON.stringify(payload, null, 2);
@@ -4860,6 +4863,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> {
let imported: ConnectionConfig[] = [];
let importedLayout: SidebarLayout | undefined;
let importedTunnelProfiles: TunnelProfile[] = [];
if (!passphrase && content.trimStart().startsWith("<")) {
const { parseNavicatConnections } = await import("@/lib/imports/navicatImport");
@@ -4889,6 +4893,9 @@ export const useConnectionStore = defineStore("connection", () => {
if (parsed.layout?.groups && parsed.layout?.order) {
importedLayout = parsed.layout;
}
if (Array.isArray(parsed.tunnelProfiles)) {
importedTunnelProfiles = parsed.tunnelProfiles;
}
} else {
imported = [];
}
@@ -4907,12 +4914,31 @@ export const useConnectionStore = defineStore("connection", () => {
if (decrypted.layout?.groups && decrypted.layout?.order) {
importedLayout = decrypted.layout;
}
if (Array.isArray(decrypted.tunnelProfiles)) {
importedTunnelProfiles = decrypted.tunnelProfiles;
}
} else {
imported = [];
}
}
}
// Profiles keep their original ids: imported connections reference them
// via transport_layers[].profile_id, so regenerating ids would break the
// links. Same-id profiles are overwritten with the imported copy.
if (importedTunnelProfiles.length) {
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const merged = [...tunnelProfileStore.profiles];
for (const profile of importedTunnelProfiles) {
if (!profile || typeof profile.id !== "string" || !profile.id) continue;
const index = merged.findIndex((existing) => existing.id === profile.id);
if (index >= 0) merged[index] = profile;
else merged.push(profile);
}
await tunnelProfileStore.saveProfiles(merged);
}
let count = 0;
const importedConnectionIdMap = new Map<string, string>();
for (const config of imported) {
@@ -0,0 +1,48 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/lib/backend/api";
import type { TunnelProfile } from "@/types/database";
/**
* Shared tunnel profiles (Settings > Tunnels). Connections reference a
* profile by id via `transport_layers[].profile_id`; the backend resolves
* the reference at connect time, so profile edits reach every referencing
* connection without touching the stored connections.
*/
export const useTunnelProfileStore = defineStore("tunnelProfiles", () => {
const profiles = ref<TunnelProfile[]>([]);
const isLoaded = ref(false);
async function init() {
if (isLoaded.value) return;
await refresh();
}
async function refresh() {
try {
profiles.value = (await api.loadTunnelProfiles()) || [];
isLoaded.value = true;
} catch {
// Backend unavailable (e.g. stale web session): keep previous state and
// retry on the next init/refresh call.
}
}
function profileById(id: string | undefined): TunnelProfile | undefined {
if (!id) return undefined;
return profiles.value.find((profile) => profile.id === id);
}
async function saveProfiles(next: TunnelProfile[]) {
const previous = profiles.value;
profiles.value = next;
try {
await api.saveTunnelProfiles(next);
} catch (error) {
profiles.value = previous;
throw error;
}
}
return { profiles, isLoaded, init, refresh, profileById, saveProfiles };
});
+18
View File
@@ -165,6 +165,14 @@ export interface ConnectionConfig {
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
/**
* A shared tunnel configuration managed in Settings > Tunnels. Structurally a
* `TransportLayerConfig`; its `id` is what connection layers reference via
* `profile_id`. Edits to a profile apply to every referencing connection the
* next time it connects.
*/
export type TunnelProfile = TransportLayerConfig;
export interface SshTunnelConfig {
id: string;
name?: string;
@@ -190,6 +198,12 @@ export interface SshTunnelConfig {
* for connections that already have `use_ssh_agent` configured.
*/
auth_method?: "password" | "key" | "agent" | "none";
/**
* When set, this layer references a shared tunnel profile; the profile's
* configuration replaces this layer's fields at connect time (only `id`
* and `enabled` are kept).
*/
profile_id?: string;
}
export interface SshConfigHostEntry {
@@ -209,6 +223,8 @@ export interface ProxyTunnelConfig {
port: number;
username?: string;
password?: string;
/** See {@link SshTunnelConfig.profile_id}. */
profile_id?: string;
}
export interface HttpTunnelConfig {
@@ -218,6 +234,8 @@ export interface HttpTunnelConfig {
url: string;
token?: string;
connect_timeout_secs?: number;
/** See {@link SshTunnelConfig.profile_id}. */
profile_id?: string;
}
export interface AttachedDatabaseConfig {