feat: add SSH config aliases and PostgreSQL extensions
This commit is contained in:
parent
8b035d0989
commit
f800d80f90
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Loader2, Package, Plus, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import type { ExtensionInfo, TreeNode } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const available = ref<ExtensionInfo[]>([]);
|
||||
const installed = ref<ExtensionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const installing = ref<string | null>(null);
|
||||
const dropping = ref<string | null>(null);
|
||||
|
||||
function show() {
|
||||
open.value = true;
|
||||
void loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [avail, inst] = await Promise.all([api.listAvailableExtensions(props.node.connectionId, props.node.database).catch(() => [] as ExtensionInfo[]), api.listExtensions(props.node.connectionId, props.node.database, props.node.schema || "public").catch(() => [] as ExtensionInfo[])]);
|
||||
available.value = avail;
|
||||
installed.value = inst;
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installExtension(name: string) {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
installing.value = name;
|
||||
try {
|
||||
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
await loadData();
|
||||
emit("close");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
installing.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function dropExtension(name: string) {
|
||||
if (!props.node.connectionId || !props.node.database) return;
|
||||
dropping.value = name;
|
||||
try {
|
||||
const sql = buildDropExtensionSql(name, false);
|
||||
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
dropping.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("extension.manageTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4">
|
||||
<!-- Left: Available -->
|
||||
<div class="flex flex-col min-h-[300px]">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.available") }}
|
||||
<span class="ml-auto text-xs">({{ available.length }})</span>
|
||||
</div>
|
||||
<ScrollArea class="flex-1 rounded-md border">
|
||||
<div v-if="available.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noAvailable") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in available" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.comment || ext.version }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="installing === ext.name" @click="installExtension(ext.name)">
|
||||
<Loader2 v-if="installing === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Plus v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.install") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<!-- Right: Installed -->
|
||||
<div class="flex flex-col min-h-[300px]">
|
||||
<div class="flex items-center gap-1.5 mb-2 text-sm font-medium text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
{{ t("extension.installed") }}
|
||||
<span class="ml-auto text-xs">({{ installed.length }})</span>
|
||||
</div>
|
||||
<ScrollArea class="flex-1 rounded-md border">
|
||||
<div v-if="installed.length === 0" class="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{{ t("extension.noInstalled") }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="ext in installed" :key="ext.name" class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ ext.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ ext.version }}{{ ext.comment ? ` — ${ext.comment}` : "" }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="dropping === ext.name" @click="dropExtension(ext.name)">
|
||||
<Loader2 v-if="dropping === ext.name" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Trash2 v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("extension.drop") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -127,6 +127,7 @@ import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/li
|
|||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
|
|
@ -370,6 +371,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
|||
return { icon: Package, colorClass: "text-cyan-500" };
|
||||
case "group-partitions":
|
||||
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" };
|
||||
case "group-extensions":
|
||||
return { icon: Package, colorClass: "text-violet-500" };
|
||||
case "extension":
|
||||
return { icon: Package, colorClass: "text-violet-400" };
|
||||
case "load-more":
|
||||
return { icon: Plus, colorClass: "text-primary" };
|
||||
default:
|
||||
|
|
@ -377,7 +382,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
|||
}
|
||||
}
|
||||
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions"]);
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions", "group-extensions"]);
|
||||
function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
|
|
@ -1752,6 +1757,13 @@ const showEditSchemaCommentDialog = ref(false);
|
|||
const schemaCommentText = ref("");
|
||||
const schemaCommentLoading = ref(false);
|
||||
|
||||
// --- Extension Management ---
|
||||
const installExtensionDialogRef = ref<InstanceType<typeof InstallExtensionDialog> | null>(null);
|
||||
|
||||
function openInstallExtensionDialog(_node: TreeNode) {
|
||||
installExtensionDialogRef.value?.show();
|
||||
}
|
||||
|
||||
// --- Procedure / Function Management ---
|
||||
const showDropObjectConfirm = ref(false);
|
||||
const showProcedureExecutionConfirm = ref(false);
|
||||
|
|
@ -4648,6 +4660,12 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
// 8.5 Extension
|
||||
if (node.type === "extension") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
return items;
|
||||
}
|
||||
|
||||
// 9. Group Labels (group-columns, group-tables, etc.)
|
||||
if (isGroupLabel(node)) {
|
||||
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database);
|
||||
|
|
@ -4664,6 +4682,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
if (hasGroupCreateAction) {
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
if (node.type === "group-extensions") {
|
||||
items.push({
|
||||
label: t("contextMenu.manageExtension"),
|
||||
action: () => openInstallExtensionDialog(node),
|
||||
icon: Plus,
|
||||
});
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
if (canLoadAllObjectGroup) {
|
||||
items.push({
|
||||
label: t("contextMenu.expandAll"),
|
||||
|
|
@ -5225,6 +5251,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
<DangerConfirmDialog v-model:open="showDropSchemaConfirm" :title="t('contextMenu.confirmDropSchemaTitle')" :message="t('contextMenu.confirmDropSchemaMessage', { name: node.label })" :sql="dropSchemaPreviewSql" :confirm-label="t('contextMenu.dropSchema')" @confirm="confirmDropSchema" />
|
||||
|
||||
<DdlViewDialog v-if="ddlTarget" :connection-id="ddlTarget.connectionId!" :database="ddlTarget.database!" :schema="ddlTarget.schema" :table-name="ddlTarget.label" :dialect="ddlDialect" :format-dialect="ddlFormatDialect" v-model:open="showDdlDialog" />
|
||||
|
||||
<InstallExtensionDialog ref="installExtensionDialogRef" :node="node" @close="refresh" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -332,7 +332,14 @@ export default {
|
|||
advancedTab: "Advanced",
|
||||
sshEnable: "Use tunnel / proxy",
|
||||
sshHost: "SSH Host",
|
||||
sshHostPlaceholder: "ssh.example.com or an alias from ~/.ssh/config",
|
||||
sshUser: "SSH User",
|
||||
sshAuthMethod: "Login Method",
|
||||
sshAuthMethodPassword: "Password",
|
||||
sshAuthMethodKey: "Private Key",
|
||||
sshAuthMethodNone: "None",
|
||||
sshAuthMethodNoneHint: "No credentials will be sent. Use this for bastions or proxies that accept unauthenticated connections.",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent (legacy)",
|
||||
sshPassword: "SSH Password",
|
||||
sshPasswordPlaceholder: "Leave empty to use key",
|
||||
sshKeyPath: "Key Path",
|
||||
|
|
@ -1421,6 +1428,8 @@ export default {
|
|||
openInSqlEditor: "Open in Editor",
|
||||
dropProcedure: "Drop Procedure",
|
||||
dropFunction: "Drop Function",
|
||||
manageExtension: "Manage Extensions...",
|
||||
dropExtension: "Drop Extension",
|
||||
confirmDropViewTitle: "Drop View",
|
||||
confirmDropViewMessage: 'Are you sure you want to drop view "{name}"?',
|
||||
confirmDropObjectTitle: "Drop Object",
|
||||
|
|
@ -1552,6 +1561,19 @@ export default {
|
|||
partitions: "Partitions",
|
||||
loadMore: "Load more...",
|
||||
objectBrowser: "Browse in Object Browser ({count})",
|
||||
extensions: "Extensions",
|
||||
},
|
||||
extension: {
|
||||
manageTitle: "Manage Extensions",
|
||||
installTitle: "Install Extension",
|
||||
name: "Extension name",
|
||||
namePlaceholder: "e.g. pg_stat_statements",
|
||||
install: "Install",
|
||||
drop: "Drop",
|
||||
available: "Available",
|
||||
installed: "Installed",
|
||||
noAvailable: "All available extensions are already installed.",
|
||||
noInstalled: "No extensions installed.",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "Users & Privileges",
|
||||
|
|
|
|||
|
|
@ -335,7 +335,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "Avanzado",
|
||||
sshEnable: "Usar túnel SSH / proxy",
|
||||
sshHost: "Host SSH",
|
||||
sshHostPlaceholder: "ssh.example.com o un alias de ~/.ssh/config",
|
||||
sshUser: "Usuario SSH",
|
||||
sshAuthMethod: "Método de acceso",
|
||||
sshAuthMethodPassword: "Contraseña",
|
||||
sshAuthMethodKey: "Clave privada",
|
||||
sshAuthMethodNone: "Ninguno",
|
||||
sshAuthMethodNoneHint: "No se enviarán credenciales. Úsalo para bastiones o proxies que aceptan conexiones sin autenticación.",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent (heredado)",
|
||||
sshPassword: "Contraseña SSH",
|
||||
sshPasswordPlaceholder: "Dejar vacío para usar clave",
|
||||
sshKeyPath: "Ruta de clave",
|
||||
|
|
@ -1461,6 +1468,8 @@ export default withEnglishFallback({
|
|||
schemaCommentPlaceholder: "Introduce un comentario del esquema...",
|
||||
schemaCommentSaving: "Guardando...",
|
||||
editSchemaCommentSuccess: 'Comentario del esquema "{name}" actualizado',
|
||||
manageExtension: "Administrar extensión...",
|
||||
dropExtension: "Eliminar extensión",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Bases de datos visibles",
|
||||
|
|
@ -1508,6 +1517,7 @@ export default withEnglishFallback({
|
|||
partitions: "Particiones",
|
||||
loadMore: "Cargar más...",
|
||||
objectBrowser: "Explorar en el navegador de objetos ({count})",
|
||||
extensions: "Extensiones",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "Usuarios y Privilegios",
|
||||
|
|
@ -3290,4 +3300,16 @@ export default withEnglishFallback({
|
|||
fileUploaded: "Archivo {fileName} subido.",
|
||||
fileDeleted: "Archivo {fileName} eliminado.",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "Instalar extensión",
|
||||
manageTitle: "Administrar extensión",
|
||||
name: "Nombre de la extensión",
|
||||
namePlaceholder: "Por ejemplo: pg_stat_statements",
|
||||
install: "Instalar",
|
||||
drop: "Eliminar",
|
||||
available: "Extensiones disponibles",
|
||||
installed: "Extensiones instaladas",
|
||||
noAvailable: "Todas las extensiones disponibles ya están instaladas.",
|
||||
noInstalled: "Aún no hay extensiones instaladas.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -333,7 +333,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "Avanzate",
|
||||
sshEnable: "Usa tunnel SSH / proxy",
|
||||
sshHost: "Host SSH",
|
||||
sshHostPlaceholder: "ssh.example.com o un alias da ~/.ssh/config",
|
||||
sshUser: "Utente SSH",
|
||||
sshAuthMethod: "Metodo di accesso",
|
||||
sshAuthMethodPassword: "Password",
|
||||
sshAuthMethodKey: "Chiave privata",
|
||||
sshAuthMethodNone: "Nessuno",
|
||||
sshAuthMethodNoneHint: "Non verrà inviata alcuna credenziale. Usalo per bastion host o proxy che accettano connessioni senza autenticazione.",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent (legacy)",
|
||||
sshPassword: "Password SSH",
|
||||
sshPasswordPlaceholder: "Lascia vuoto per usare la chiave",
|
||||
sshKeyPath: "Percorso Chiave",
|
||||
|
|
@ -1459,6 +1466,8 @@ export default withEnglishFallback({
|
|||
schemaCommentPlaceholder: "Inserisci commento schema...",
|
||||
schemaCommentSaving: "Salvataggio...",
|
||||
editSchemaCommentSuccess: 'Commento dello schema "{name}" aggiornato',
|
||||
manageExtension: "Gestisci estensione...",
|
||||
dropExtension: "Elimina estensione",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Database Visibili",
|
||||
|
|
@ -1506,6 +1515,7 @@ export default withEnglishFallback({
|
|||
partitions: "Partizioni",
|
||||
loadMore: "Carica altro...",
|
||||
objectBrowser: "Sfoglia in Esplora Oggetti ({count})",
|
||||
extensions: "Estensioni",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "Utenti e Privilegi",
|
||||
|
|
@ -3288,4 +3298,16 @@ export default withEnglishFallback({
|
|||
fileUploaded: "{fileName} caricato.",
|
||||
fileDeleted: "{fileName} eliminato.",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "Installa estensione",
|
||||
manageTitle: "Gestisci estensioni",
|
||||
name: "Nome estensione",
|
||||
namePlaceholder: "Es: pg_stat_statements",
|
||||
install: "Installa",
|
||||
drop: "Elimina",
|
||||
available: "Estensioni disponibili",
|
||||
installed: "Installate",
|
||||
noAvailable: "Tutte le estensioni disponibili sono installate.",
|
||||
noInstalled: "Nessuna estensione installata.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -327,7 +327,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "詳細",
|
||||
sshEnable: "SSHトンネル/プロキシを使用する",
|
||||
sshHost: "SSHホスト",
|
||||
sshHostPlaceholder: "ssh.example.com または ~/.ssh/config のエイリアス",
|
||||
sshUser: "SSHユーザー",
|
||||
sshAuthMethod: "ログイン方式",
|
||||
sshAuthMethodPassword: "パスワード",
|
||||
sshAuthMethodKey: "秘密鍵",
|
||||
sshAuthMethodNone: "なし",
|
||||
sshAuthMethodNoneHint: "認証情報は送信されません。無認証接続を許可する踏み台やプロキシに使用してください。",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent(旧)",
|
||||
sshPassword: "SSHパスワード",
|
||||
sshPasswordPlaceholder: "鍵を使用する場合は空のまま",
|
||||
sshKeyPath: "鍵パス",
|
||||
|
|
@ -1459,6 +1466,8 @@ export default withEnglishFallback({
|
|||
instanceInfo: "インスタンス情報",
|
||||
viewDdlLoading: "DDLを読み込み中...",
|
||||
ddlCopied: "DDLをコピーしました",
|
||||
manageExtension: "拡張機能を管理...",
|
||||
dropExtension: "拡張機能を削除",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "表示するデータベース",
|
||||
|
|
@ -1506,6 +1515,7 @@ export default withEnglishFallback({
|
|||
objectBrowser: "オブジェクトブラウザで参照({count}件)",
|
||||
linkedServers: "リンクサーバー",
|
||||
materializedViews: "マテリアライズドビュー",
|
||||
extensions: "拡張機能",
|
||||
},
|
||||
zookeeper: {
|
||||
prefixPlaceholder: "パスプレフィックス(例: /app/)",
|
||||
|
|
@ -3288,4 +3298,16 @@ export default withEnglishFallback({
|
|||
fileUploaded: "{fileName} をアップロードしました。",
|
||||
fileDeleted: "{fileName} を削除しました。",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "拡張機能のインストール",
|
||||
manageTitle: "拡張機能の管理",
|
||||
name: "拡張機能名",
|
||||
namePlaceholder: "例: pg_stat_statements",
|
||||
install: "インストール",
|
||||
drop: "削除",
|
||||
available: "利用可能な拡張機能",
|
||||
installed: "インストール済み",
|
||||
noAvailable: "すべての利用可能な拡張機能がインストールされています。",
|
||||
noInstalled: "インストールされている拡張機能はありません。",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -334,7 +334,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "Avançado",
|
||||
sshEnable: "Usar túnel SSH / proxy",
|
||||
sshHost: "Host SSH",
|
||||
sshHostPlaceholder: "ssh.example.com ou um alias de ~/.ssh/config",
|
||||
sshUser: "Usuário SSH",
|
||||
sshAuthMethod: "Método de login",
|
||||
sshAuthMethodPassword: "Senha",
|
||||
sshAuthMethodKey: "Chave privada",
|
||||
sshAuthMethodNone: "Nenhum",
|
||||
sshAuthMethodNoneHint: "Nenhuma credencial será enviada. Use para bastiões ou proxies que aceitam conexões sem autenticação.",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent (legado)",
|
||||
sshPassword: "Senha SSH",
|
||||
sshPasswordPlaceholder: "Deixe vazio para usar a chave",
|
||||
sshKeyPath: "Caminho da Chave",
|
||||
|
|
@ -1460,6 +1467,8 @@ export default withEnglishFallback({
|
|||
schemaCommentPlaceholder: "Digite o comentário do schema...",
|
||||
schemaCommentSaving: "Salvando...",
|
||||
editSchemaCommentSuccess: 'Comentário do schema "{name}" atualizado',
|
||||
manageExtension: "Gerenciar extensão...",
|
||||
dropExtension: "Remover extensão",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Bancos de dados visíveis",
|
||||
|
|
@ -1507,6 +1516,7 @@ export default withEnglishFallback({
|
|||
partitions: "Partições",
|
||||
loadMore: "Carregar mais...",
|
||||
objectBrowser: "Navegar no Navegador de Objetos ({count})",
|
||||
extensions: "Extensões",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "Usuários e Privilégios",
|
||||
|
|
@ -3289,4 +3299,16 @@ export default withEnglishFallback({
|
|||
fileUploaded: "Arquivo {fileName} enviado.",
|
||||
fileDeleted: "Arquivo {fileName} excluído.",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "Instalar extensão",
|
||||
manageTitle: "Gerenciar extensão",
|
||||
name: "Nome da extensão",
|
||||
namePlaceholder: "Exemplo: pg_stat_statements",
|
||||
install: "Instalar",
|
||||
drop: "Remover",
|
||||
available: "Extensões disponíveis",
|
||||
installed: "Instaladas",
|
||||
noAvailable: "Todas as extensões disponíveis já estão instaladas.",
|
||||
noInstalled: "Nenhuma extensão instalada.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -336,7 +336,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "高级",
|
||||
sshEnable: "使用隧道/代理连接",
|
||||
sshHost: "SSH 主机",
|
||||
sshHostPlaceholder: "ssh.example.com 或 ~/.ssh/config 中的别名",
|
||||
sshUser: "SSH 用户",
|
||||
sshAuthMethod: "登录方式",
|
||||
sshAuthMethodPassword: "密码",
|
||||
sshAuthMethodKey: "私钥",
|
||||
sshAuthMethodNone: "无认证",
|
||||
sshAuthMethodNoneHint: "不会发送任何凭据,适用于接受无认证连接的 bastion 或代理。",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent(旧版)",
|
||||
sshPassword: "SSH 密码",
|
||||
sshPasswordPlaceholder: "留空则使用密钥",
|
||||
sshKeyPath: "密钥路径",
|
||||
|
|
@ -1423,6 +1430,8 @@ export default withEnglishFallback({
|
|||
openInSqlEditor: "打开到编辑器",
|
||||
dropProcedure: "删除存储过程",
|
||||
dropFunction: "删除函数",
|
||||
manageExtension: "管理扩展...",
|
||||
dropExtension: "删除扩展",
|
||||
confirmDropViewTitle: "删除视图",
|
||||
confirmDropViewMessage: "确定要删除视图「{name}」吗?",
|
||||
confirmDropObjectTitle: "删除对象",
|
||||
|
|
@ -1552,6 +1561,19 @@ export default withEnglishFallback({
|
|||
partitions: "分区",
|
||||
loadMore: "加载更多...",
|
||||
objectBrowser: "在对象浏览器中查看 ({count})",
|
||||
extensions: "扩展",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "安装扩展",
|
||||
manageTitle: "管理扩展",
|
||||
name: "扩展名称",
|
||||
namePlaceholder: "例如: pg_stat_statements",
|
||||
install: "安装",
|
||||
drop: "删除",
|
||||
available: "可用扩展",
|
||||
installed: "已安装",
|
||||
noAvailable: "所有可用扩展均已安装。",
|
||||
noInstalled: "暂无已安装的扩展。",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "用户与权限",
|
||||
|
|
|
|||
|
|
@ -334,7 +334,14 @@ export default withEnglishFallback({
|
|||
advancedTab: "進階",
|
||||
sshEnable: "使用 SSH 隧道/代理連線",
|
||||
sshHost: "SSH 主機",
|
||||
sshHostPlaceholder: "ssh.example.com 或 ~/.ssh/config 中的別名",
|
||||
sshUser: "SSH 使用者",
|
||||
sshAuthMethod: "登入方式",
|
||||
sshAuthMethodPassword: "密碼",
|
||||
sshAuthMethodKey: "私鑰",
|
||||
sshAuthMethodNone: "無認證",
|
||||
sshAuthMethodNoneHint: "不會傳送任何憑證,適用於接受無認證連線的 bastion 或代理伺服器。",
|
||||
sshAuthMethodAgentLegacy: "SSH Agent(舊版)",
|
||||
sshPassword: "SSH 密碼",
|
||||
sshPasswordPlaceholder: "留空則使用金鑰",
|
||||
sshKeyPath: "金鑰路徑",
|
||||
|
|
@ -1460,6 +1467,8 @@ export default withEnglishFallback({
|
|||
schemaCommentPlaceholder: "輸入 Schema 註解...",
|
||||
schemaCommentSaving: "正在儲存...",
|
||||
editSchemaCommentSuccess: "Schema「{name}」註解已更新",
|
||||
manageExtension: "管理擴展...",
|
||||
dropExtension: "刪除擴展",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "顯示資料庫",
|
||||
|
|
@ -1507,6 +1516,7 @@ export default withEnglishFallback({
|
|||
partitions: "分割區",
|
||||
loadMore: "載入更多...",
|
||||
objectBrowser: "在物件瀏覽器中檢視 ({count})",
|
||||
extensions: "擴展",
|
||||
},
|
||||
objects: {
|
||||
all: "全部",
|
||||
|
|
@ -3289,4 +3299,16 @@ export default withEnglishFallback({
|
|||
fileUploaded: "已上傳 {fileName}。",
|
||||
fileDeleted: "已刪除 {fileName}。",
|
||||
},
|
||||
extension: {
|
||||
installTitle: "安裝擴展",
|
||||
manageTitle: "管理擴展",
|
||||
name: "擴展名稱",
|
||||
namePlaceholder: "例如:pg_stat_statements",
|
||||
install: "安裝",
|
||||
drop: "刪除",
|
||||
available: "可用擴展",
|
||||
installed: "已安裝",
|
||||
noAvailable: "所有可用擴展均已安裝。",
|
||||
noInstalled: "暫無已安裝的擴展。",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export const listFunctions = forward("listFunctions");
|
|||
export const listSequences = forward("listSequences");
|
||||
export const listRules = forward("listRules");
|
||||
export const listOwners = forward("listOwners");
|
||||
export const listExtensions = forward("listExtensions");
|
||||
export const listAvailableExtensions = forward("listAvailableExtensions");
|
||||
export const prepareSchemaDiff = forward("prepareSchemaDiff");
|
||||
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
|
||||
|
||||
|
|
@ -240,6 +242,7 @@ export const deleteAiConversation = forward("deleteAiConversation");
|
|||
|
||||
// System
|
||||
export const listSystemFonts = forward("listSystemFonts");
|
||||
export const listSshConfigHosts = forward("listSshConfigHosts");
|
||||
|
||||
// SQL File Execution
|
||||
export const previewSqlFile = forward("previewSqlFile");
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
IndexInfo,
|
||||
ForeignKeyInfo,
|
||||
TriggerInfo,
|
||||
ExtensionInfo,
|
||||
FunctionInfo,
|
||||
SequenceInfo,
|
||||
RuleInfo,
|
||||
|
|
@ -29,6 +30,7 @@ import type {
|
|||
SavedSqlFile,
|
||||
SavedSqlFolder,
|
||||
SavedSqlLibrary,
|
||||
SshConfigHostEntry,
|
||||
} from "@/types/database";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
|
|
@ -242,6 +244,10 @@ export async function listSystemFonts(): Promise<string[]> {
|
|||
return get("/api/system/fonts");
|
||||
}
|
||||
|
||||
export async function listSshConfigHosts(): Promise<SshConfigHostEntry[]> {
|
||||
return get("/api/ssh/config-hosts");
|
||||
}
|
||||
|
||||
export async function listPlugins(): Promise<InstalledPlugin[]> {
|
||||
return get("/api/plugins");
|
||||
}
|
||||
|
|
@ -605,6 +611,14 @@ export async function listOwners(connectionId: string, database: string, schema:
|
|||
return get(`/api/schema/owners?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
export async function listExtensions(connectionId: string, database: string, schema: string): Promise<ExtensionInfo[]> {
|
||||
return get(`/api/schema/extensions?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
export async function listAvailableExtensions(connectionId: string, database: string): Promise<ExtensionInfo[]> {
|
||||
return get(`/api/schema/available-extensions?${qs({ connection_id: connectionId, database })}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
SequenceInfo,
|
||||
RuleInfo,
|
||||
OwnerInfo,
|
||||
ExtensionInfo,
|
||||
QueryResult,
|
||||
SqlReferenceAnalysis,
|
||||
DatabaseType,
|
||||
|
|
@ -30,6 +31,7 @@ import type {
|
|||
SavedSqlFile,
|
||||
SavedSqlFolder,
|
||||
SavedSqlLibrary,
|
||||
SshConfigHostEntry,
|
||||
} from "@/types/database";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
|
|
@ -495,6 +497,10 @@ export async function listSystemFonts(): Promise<string[]> {
|
|||
return invoke("list_system_fonts");
|
||||
}
|
||||
|
||||
export async function listSshConfigHosts(): Promise<SshConfigHostEntry[]> {
|
||||
return invoke("list_ssh_config_hosts");
|
||||
}
|
||||
|
||||
export async function pendingOpenSqlFiles(): Promise<string[]> {
|
||||
return invoke("pending_open_sql_files");
|
||||
}
|
||||
|
|
@ -990,6 +996,14 @@ export async function listOwners(connectionId: string, database: string, schema:
|
|||
return invoke("list_owners", { connectionId, database, schema });
|
||||
}
|
||||
|
||||
export async function listExtensions(connectionId: string, database: string, schema: string): Promise<ExtensionInfo[]> {
|
||||
return invoke("list_extensions", { connectionId, database, schema });
|
||||
}
|
||||
|
||||
export async function listAvailableExtensions(connectionId: string, database: string): Promise<ExtensionInfo[]> {
|
||||
return invoke("list_available_extensions", { connectionId, database });
|
||||
}
|
||||
|
||||
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {
|
||||
return invoke("save_connections", { configs });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,3 +122,24 @@ function quotePostgresIdentifier(value: string): string {
|
|||
function quoteSqlLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function buildCreateExtensionSql(name: string, schema?: string | null): string {
|
||||
const extName = quotePostgresIdentifier(name);
|
||||
if (schema) {
|
||||
return `CREATE EXTENSION ${extName} WITH SCHEMA ${quotePostgresIdentifier(schema)};`;
|
||||
}
|
||||
return `CREATE EXTENSION ${extName};`;
|
||||
}
|
||||
|
||||
export function buildDropExtensionSql(name: string, cascade = false): string {
|
||||
const extName = quotePostgresIdentifier(name);
|
||||
return cascade ? `DROP EXTENSION ${extName} CASCADE;` : `DROP EXTENSION ${extName};`;
|
||||
}
|
||||
|
||||
export function buildListAvailableExtensionsSql(schema?: string | null): string {
|
||||
// pg_available_extensions shows extensions available for installation
|
||||
if (schema) {
|
||||
return `SELECT name, default_version, comment FROM pg_catalog.pg_available_extensions WHERE installed_version IS NULL ORDER BY name`;
|
||||
}
|
||||
return `SELECT name, default_version, comment FROM pg_catalog.pg_available_extensions WHERE installed_version IS NULL ORDER BY name`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2660,6 +2660,18 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
schema: effectiveSchema,
|
||||
objectTypes: supportedSidebarObjectTypes(config),
|
||||
});
|
||||
if (isPostgresLikeForExtensions(config?.db_type)) {
|
||||
children.push({
|
||||
id: `${nodeId}:__extensions`,
|
||||
label: "tree.extensions",
|
||||
type: "group-extensions",
|
||||
connectionId,
|
||||
database,
|
||||
schema: effectiveSchema,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||||
if (!canApplyTreeMetadataResult(node)) return;
|
||||
|
|
@ -2795,6 +2807,35 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
);
|
||||
}
|
||||
|
||||
async function loadExtensions(connectionId: string, database: string, schema: string) {
|
||||
const node = findNode(treeNodes.value, `${connectionId}:${database}:${schema}:__extensions`);
|
||||
if (!node) return;
|
||||
node.isLoading = true;
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node)) return;
|
||||
const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database, schema), "extensions");
|
||||
const children: TreeNode[] = extensions.map((ext) => ({
|
||||
id: `${node.id}:${ext.name}`,
|
||||
label: ext.name,
|
||||
type: "extension" as const,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
comment: ext.comment ?? null,
|
||||
meta: ext,
|
||||
isExpanded: false,
|
||||
}));
|
||||
setChildren(node, children);
|
||||
node.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
throw e;
|
||||
} finally {
|
||||
node.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreObjectGroupChildren(node: TreeNode) {
|
||||
if (node.type !== "load-more" || !node.loadMore) return;
|
||||
const loadMore = node.loadMore;
|
||||
|
|
@ -3349,6 +3390,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
await loadObjectGroupChildren(node, options);
|
||||
} else if (node.type === "group-partitions") {
|
||||
node.isExpanded = true;
|
||||
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||||
await loadExtensions(node.connectionId, node.database || "", node.schema || "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3412,6 +3455,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return isSchemaAware(getConfig(connectionId)?.db_type);
|
||||
}
|
||||
|
||||
function isPostgresLikeForExtensions(dbType?: string): boolean {
|
||||
return dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "highgo" || dbType === "vastbase" || dbType === "kingbase";
|
||||
}
|
||||
|
||||
function metadataQuerySchema(connectionId: string, database: string, schema?: string): string {
|
||||
return connectionObjectTreeQuerySchema(getConfig(connectionId), database, schema);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -607,6 +607,13 @@ export const useQueryStore = defineStore("query", () => {
|
|||
if (legacy.rawTabs || legacy.rawActiveTabId) {
|
||||
const restored = restoreLegacySavedTabs();
|
||||
applyRestoredOpenTabs(restored);
|
||||
if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||||
// Restore is explicitly disabled, so keeping the legacy startup payload
|
||||
// would resurrect old tabs if the user later changes the setting.
|
||||
clearLegacySavedTabs();
|
||||
isOpenTabsLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveTabs(tabs.value, activeTabId.value);
|
||||
// Keep old desktop installs readable until the async store has the
|
||||
|
|
|
|||
|
|
@ -172,6 +172,25 @@ export interface SshTunnelConfig {
|
|||
expose_lan?: boolean;
|
||||
use_ssh_agent?: boolean;
|
||||
ssh_agent_sock_path?: string;
|
||||
/**
|
||||
* UI-facing choice of login method. Drives which credential inputs the
|
||||
* connection dialog shows; the backend still probes "none" then falls
|
||||
* back to key > password > agent based on which fields are non-empty,
|
||||
* independent of this selector (see `db/ssh_tunnel.rs`).
|
||||
*
|
||||
* `"agent"` is a legacy value: it's no longer offered as a dropdown
|
||||
* choice for new connections, but is preserved and displayed read-only
|
||||
* for connections that already have `use_ssh_agent` configured.
|
||||
*/
|
||||
auth_method?: "password" | "key" | "agent" | "none";
|
||||
}
|
||||
|
||||
export interface SshConfigHostEntry {
|
||||
alias: string;
|
||||
host_name?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
identity_file?: string;
|
||||
}
|
||||
|
||||
export interface ProxyTunnelConfig {
|
||||
|
|
@ -381,6 +400,13 @@ export interface RuleInfo {
|
|||
definition: string;
|
||||
}
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
comment?: string | null;
|
||||
schema?: string | null;
|
||||
}
|
||||
|
||||
export interface OwnerInfo {
|
||||
object_name: string;
|
||||
object_type: string;
|
||||
|
|
@ -507,6 +533,8 @@ export type TreeNodeType =
|
|||
| "group-sequences"
|
||||
| "group-packages"
|
||||
| "group-partitions"
|
||||
| "group-extensions"
|
||||
| "extension"
|
||||
| "object-browser"
|
||||
| "user-admin"
|
||||
| "saved-sql-root"
|
||||
|
|
@ -574,7 +602,7 @@ export interface TreeNode {
|
|||
tableSearchParentId?: string;
|
||||
savedSqlId?: string;
|
||||
savedSqlFolderId?: string;
|
||||
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | VectorCollectionMeta;
|
||||
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ExtensionInfo | VectorCollectionMeta;
|
||||
loadMore?: {
|
||||
parentId: string;
|
||||
offset: number;
|
||||
|
|
|
|||
|
|
@ -911,6 +911,7 @@ mod tests {
|
|||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}),
|
||||
TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig {
|
||||
id: "http".to_string(),
|
||||
|
|
|
|||
|
|
@ -780,6 +780,7 @@ mod tests {
|
|||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "key".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ use crate::sql::starts_with_executable_sql_keyword;
|
|||
use crate::types::{
|
||||
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
|
||||
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
|
||||
ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult, RuleInfo,
|
||||
SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
|
||||
ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult,
|
||||
RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
|
||||
};
|
||||
|
||||
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
|
||||
|
|
@ -2845,6 +2845,56 @@ pub async fn list_rules(pool: &Pool, schema: &str) -> Result<Vec<RuleInfo>, Stri
|
|||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_extensions(pool: &Pool, schema: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client
|
||||
.prepare_cached(
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM pg_catalog.pg_extension e \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
|
||||
WHERE n.nspname = $1 \
|
||||
ORDER BY e.extname",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ExtensionInfo {
|
||||
name: row.get::<_, String>(0),
|
||||
version: row.get::<_, String>(1),
|
||||
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
||||
schema: Some(schema.to_string()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_available_extensions(pool: &Pool) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client
|
||||
.prepare_cached(
|
||||
"SELECT name, default_version, comment \
|
||||
FROM pg_catalog.pg_available_extensions \
|
||||
WHERE installed_version IS NULL \
|
||||
ORDER BY name",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ExtensionInfo {
|
||||
name: row.get::<_, String>(0),
|
||||
version: row.get::<_, String>(1),
|
||||
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
||||
schema: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client.prepare_cached(POSTGRES_OWNERS_SQL).await.map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
use crate::path_utils::expand_tilde;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
|
|
@ -66,6 +68,7 @@ async fn connect_and_authenticate(
|
|||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
) -> Result<Handle<SshClient>, String> {
|
||||
let config = Arc::new(ssh_client_config());
|
||||
|
|
@ -88,8 +91,19 @@ async fn connect_and_authenticate(
|
|||
return Ok(session);
|
||||
}
|
||||
|
||||
// When auth_method is "none" and the probe was rejected, fail early
|
||||
// instead of falling back to other credential methods.
|
||||
if auth_method == "none" {
|
||||
return Err("SSH authentication failed: server rejected the connection without credentials".to_string());
|
||||
}
|
||||
|
||||
// "none" was rejected — fall back to the configured credential method.
|
||||
if !ssh_key_path.is_empty() {
|
||||
// When auth_method is set, only try the matching method.
|
||||
let try_key = auth_method.is_empty() && !ssh_key_path.is_empty() || auth_method == "key";
|
||||
let try_password = auth_method.is_empty() && !ssh_password.is_empty() || auth_method == "password";
|
||||
let try_agent = auth_method.is_empty() && use_ssh_agent || auth_method == "agent";
|
||||
|
||||
if try_key {
|
||||
// Validate SSH key file path
|
||||
validate_file_path(ssh_key_path, |_| false)?;
|
||||
|
||||
|
|
@ -112,7 +126,7 @@ async fn connect_and_authenticate(
|
|||
if !auth_res.success() {
|
||||
return Err("SSH public key authentication failed".to_string());
|
||||
}
|
||||
} else if !ssh_password.is_empty() {
|
||||
} else if try_password {
|
||||
let auth_res = tokio::time::timeout(connect_timeout, session.authenticate_password(ssh_user, ssh_password))
|
||||
.await
|
||||
.map_err(|_| format!("SSH password auth timed out ({connect_timeout_secs}s)"))?
|
||||
|
|
@ -120,7 +134,7 @@ async fn connect_and_authenticate(
|
|||
if !auth_res.success() {
|
||||
return Err("SSH password authentication failed".to_string());
|
||||
}
|
||||
} else if use_ssh_agent {
|
||||
} else if try_agent {
|
||||
match try_authenticate_with_agent(&mut session, ssh_user, ssh_agent_sock_path, &connect_timeout).await {
|
||||
Ok(()) => {}
|
||||
Err(agent_err) => return Err(agent_err),
|
||||
|
|
@ -215,7 +229,8 @@ async fn try_authenticate_with_agent(
|
|||
}
|
||||
|
||||
fn load_ssh_private_key(path: &str, passphrase: Option<&str>) -> Result<PrivateKey, String> {
|
||||
let secret = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let expanded = expand_tilde(path);
|
||||
let secret = fs::read_to_string(&expanded).map_err(|e| e.to_string())?;
|
||||
match decode_secret_key(&secret, passphrase) {
|
||||
Ok(key) => Ok(key),
|
||||
Err(err) if is_ssh_key_character_encoding_error(&err.to_string()) => {
|
||||
|
|
@ -460,6 +475,7 @@ async fn tunnel_reconnect_loop(
|
|||
ssh_key_passphrase: String,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: String,
|
||||
auth_method: String,
|
||||
connect_timeout_secs: u64,
|
||||
listener: TcpListener,
|
||||
remote_host: String,
|
||||
|
|
@ -495,6 +511,7 @@ async fn tunnel_reconnect_loop(
|
|||
&ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
&ssh_agent_sock_path,
|
||||
&auth_method,
|
||||
connect_timeout_secs,
|
||||
)
|
||||
.await
|
||||
|
|
@ -565,6 +582,7 @@ impl TunnelManager {
|
|||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
|
|
@ -588,6 +606,7 @@ impl TunnelManager {
|
|||
ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path,
|
||||
auth_method,
|
||||
connect_timeout_secs,
|
||||
remote_host,
|
||||
remote_port,
|
||||
|
|
@ -658,6 +677,7 @@ impl TunnelManager {
|
|||
&hop.key_passphrase,
|
||||
hop.use_ssh_agent,
|
||||
&hop.ssh_agent_sock_path,
|
||||
&hop.auth_method,
|
||||
effective_hop_timeout(hop),
|
||||
&target_host,
|
||||
target_port,
|
||||
|
|
@ -718,6 +738,7 @@ async fn spawn_tunnel(
|
|||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
|
|
@ -739,6 +760,7 @@ async fn spawn_tunnel(
|
|||
ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path,
|
||||
auth_method,
|
||||
connect_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -753,6 +775,7 @@ async fn spawn_tunnel(
|
|||
ssh_key_passphrase.to_string(),
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path.to_string(),
|
||||
auth_method.to_string(),
|
||||
connect_timeout_secs,
|
||||
listener,
|
||||
remote_host.to_string(),
|
||||
|
|
@ -851,6 +874,7 @@ mod tests {
|
|||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,25 @@ impl LayerEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolves any `~/.ssh/config` aliases on SSH layers before endpoints are
|
||||
/// computed. Both `.endpoint()` read sites below (the current layer's own
|
||||
/// connect target, and an earlier layer's forward target when this layer is
|
||||
/// the *next* one in the chain) must see the resolved host/port rather than
|
||||
/// a literal alias string, so resolution happens once up front instead of
|
||||
/// only at the `start_tunnel` call site.
|
||||
fn resolve_ssh_layers(
|
||||
layers: &[TransportLayerConfig],
|
||||
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
|
||||
) -> Vec<TransportLayerConfig> {
|
||||
layers
|
||||
.iter()
|
||||
.map(|layer| match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => TransportLayerConfig::Ssh(resolve(ssh)),
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starts an ordered transport layer chain and returns the final local port.
|
||||
///
|
||||
/// Each layer listens on a local port. The next layer connects to that local
|
||||
|
|
@ -34,6 +53,9 @@ pub async fn start_transport_layers(
|
|||
}
|
||||
validate_transport_layers(layers)?;
|
||||
|
||||
let layers = resolve_ssh_layers(layers, crate::ssh_config::resolve_ssh_tunnel_config);
|
||||
let layers = layers.as_slice();
|
||||
|
||||
let mut next_connect_endpoint: Option<LayerEndpoint> = None;
|
||||
let mut final_local_port = 0;
|
||||
|
||||
|
|
@ -52,21 +74,22 @@ pub async fn start_transport_layers(
|
|||
};
|
||||
|
||||
let local_port = match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => ssh_tunnels
|
||||
TransportLayerConfig::Ssh(resolved) => ssh_tunnels
|
||||
.start_tunnel(
|
||||
&layer_id,
|
||||
&connect_endpoint.host,
|
||||
connect_endpoint.port,
|
||||
&ssh.user,
|
||||
&ssh.password,
|
||||
&ssh.key_path,
|
||||
&ssh.key_passphrase,
|
||||
ssh.use_ssh_agent,
|
||||
&ssh.ssh_agent_sock_path,
|
||||
effective_ssh_connect_timeout_secs(ssh.connect_timeout_secs),
|
||||
&resolved.user,
|
||||
&resolved.password,
|
||||
&resolved.key_path,
|
||||
&resolved.key_passphrase,
|
||||
resolved.use_ssh_agent,
|
||||
&resolved.ssh_agent_sock_path,
|
||||
&resolved.auth_method,
|
||||
effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs),
|
||||
&target_endpoint.host,
|
||||
target_endpoint.port,
|
||||
is_last && ssh.expose_lan,
|
||||
is_last && resolved.expose_lan,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?,
|
||||
|
|
@ -164,6 +187,22 @@ fn plan_transport_layers(
|
|||
remote_port: u16,
|
||||
local_ports: &[u16],
|
||||
) -> Vec<PlannedTransportLayer> {
|
||||
plan_transport_layers_with_resolver(layers, remote_host, remote_port, local_ports, |ssh| ssh.clone())
|
||||
}
|
||||
|
||||
/// Same as `plan_transport_layers`, but takes an explicit SSH-alias resolver
|
||||
/// so tests can exercise `~/.ssh/config` resolution without touching the real
|
||||
/// filesystem (mirrors `start_transport_layers`'s use of `resolve_ssh_layers`).
|
||||
#[cfg(test)]
|
||||
fn plan_transport_layers_with_resolver(
|
||||
layers: &[TransportLayerConfig],
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
local_ports: &[u16],
|
||||
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
|
||||
) -> Vec<PlannedTransportLayer> {
|
||||
let layers = resolve_ssh_layers(layers, resolve);
|
||||
let layers = layers.as_slice();
|
||||
let mut planned = Vec::new();
|
||||
let mut next_connect_endpoint: Option<(String, u16)> = None;
|
||||
for (index, layer) in layers.iter().enumerate() {
|
||||
|
|
@ -198,7 +237,10 @@ fn plan_transport_layers(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{plan_transport_layers, validate_transport_layers, PlannedLayerType, PlannedTransportLayer};
|
||||
use super::{
|
||||
plan_transport_layers, plan_transport_layers_with_resolver, validate_transport_layers, PlannedLayerType,
|
||||
PlannedTransportLayer,
|
||||
};
|
||||
use crate::models::connection::{
|
||||
HttpTunnelConfig, ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig,
|
||||
};
|
||||
|
|
@ -218,6 +260,7 @@ mod tests {
|
|||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -295,6 +338,71 @@ mod tests {
|
|||
assert!(err.contains("HTTP tunnel must be the first transport layer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_ssh_hop_with_config_alias_connects_to_resolved_host_and_port() {
|
||||
// RF-001 regression: a single SSH layer whose `host` is a
|
||||
// `~/.ssh/config` alias must dial the resolved HostName/Port, not
|
||||
// the literal alias string.
|
||||
let layers = vec![ssh_layer("ssh-a", "myserver", 22)];
|
||||
|
||||
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[], |ssh| {
|
||||
assert_eq!(ssh.host, "myserver");
|
||||
let mut resolved = ssh.clone();
|
||||
resolved.host = "10.0.0.5".to_string();
|
||||
resolved.port = 2222;
|
||||
resolved
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
planned,
|
||||
vec![PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "10.0.0.5".to_string(),
|
||||
connect_port: 2222,
|
||||
remote_host: "db.internal".to_string(),
|
||||
remote_port: 5432,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_hop_forwards_to_resolved_alias_of_next_ssh_hop() {
|
||||
// RF-002 regression: when hop N+1 is an SSH layer addressed by a
|
||||
// config alias, hop N's forward target must be the resolved
|
||||
// HostName/Port, not the literal alias (which the remote host at
|
||||
// hop N cannot resolve).
|
||||
let layers = vec![ssh_layer("ssh-a", "bastion-a", 22), ssh_layer("ssh-b", "myserver", 22)];
|
||||
|
||||
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[41001], |ssh| {
|
||||
let mut resolved = ssh.clone();
|
||||
if ssh.host == "myserver" {
|
||||
resolved.host = "10.0.0.5".to_string();
|
||||
resolved.port = 2222;
|
||||
}
|
||||
resolved
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
planned,
|
||||
vec![
|
||||
PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "bastion-a".to_string(),
|
||||
connect_port: 22,
|
||||
remote_host: "10.0.0.5".to_string(),
|
||||
remote_port: 2222,
|
||||
},
|
||||
PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "127.0.0.1".to_string(),
|
||||
connect_port: 41001,
|
||||
remote_host: "db.internal".to_string(),
|
||||
remote_port: 5432,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_tunnel_first_layer_targets_next_layer() {
|
||||
let layers = vec![
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub mod sql_file_import;
|
|||
pub mod sql_risk;
|
||||
pub mod sqlite_backup;
|
||||
pub(crate) mod sqlserver_temporal;
|
||||
pub mod ssh_config;
|
||||
pub mod storage;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,13 @@ pub struct SshTunnelConfig {
|
|||
/// the `SSH_AUTH_SOCK` environment variable.
|
||||
#[serde(default)]
|
||||
pub ssh_agent_sock_path: String,
|
||||
/// Login method: `"password"`, `"key"`, `"agent"`, or `"none"`.
|
||||
/// Empty string means an older saved connection predating this field —
|
||||
/// the backend falls back to probing key > password > agent based on
|
||||
/// which fields are non-empty. When set to a specific method the backend
|
||||
/// only tries that method (after the standard `none` probe).
|
||||
#[serde(default)]
|
||||
pub auth_method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -4073,6 +4073,43 @@ pub async fn list_rules_core(
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn list_extensions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Postgres(p) => db::postgres::list_extensions(p, schema).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_available_extensions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Postgres(p) => db::postgres::list_available_extensions(p).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_owners_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::models::connection::SshTunnelConfig;
|
||||
use crate::path_utils::expand_tilde;
|
||||
|
||||
/// Sentinel values the frontend fills in when the user leaves a field blank
|
||||
/// (see `normalizeSshTunnel` / `defaultSshTunnel` in ConnectionDialog.vue).
|
||||
/// There is no way to distinguish "user explicitly typed 22" from "field was
|
||||
/// left empty and defaulted to 22", so we treat these as "unset" for the
|
||||
/// purpose of filling in values from `~/.ssh/config`.
|
||||
const DEFAULT_USER_SENTINEL: &str = "root";
|
||||
const DEFAULT_PORT_SENTINEL: u16 = 22;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SshConfigHostEntry {
|
||||
pub alias: String,
|
||||
pub host_name: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub user: Option<String>,
|
||||
pub identity_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads and parses `~/.ssh/config`. Returns an empty list (not an error) if
|
||||
/// the file does not exist, since that's a normal state for users without
|
||||
/// an SSH config.
|
||||
pub fn list_hosts() -> Result<Vec<SshConfigHostEntry>, String> {
|
||||
let path = expand_tilde("~/.ssh/config");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(parse_ssh_config(&content)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(err) => Err(format!("Failed to read {path}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_host(alias: &str) -> Option<SshConfigHostEntry> {
|
||||
list_hosts().ok()?.into_iter().find(|entry| entry.alias == alias)
|
||||
}
|
||||
|
||||
/// Fills in `host`, `user`, `port`, and `key_path` from a matching `~/.ssh/config`
|
||||
/// `Host` block, without overwriting values the user has explicitly set.
|
||||
///
|
||||
/// Only `ssh.host` is matched against config aliases; `user`/`port`/`key_path`
|
||||
/// are filled in from that same matched entry. Values already present on
|
||||
/// `ssh` win, except for `user`/`port` which use the sentinel defaults above
|
||||
/// to detect "not actually set by the user".
|
||||
pub fn resolve_ssh_tunnel_config(ssh: &SshTunnelConfig) -> SshTunnelConfig {
|
||||
match find_host(&ssh.host) {
|
||||
Some(entry) => apply_host_entry(ssh, entry),
|
||||
None => ssh.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resolved `~/.ssh/config` entry onto `ssh`, without overwriting
|
||||
/// values the user has explicitly set. `user`/`port` use the sentinel
|
||||
/// defaults above to detect "not actually set by the user".
|
||||
fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunnelConfig {
|
||||
let mut resolved = ssh.clone();
|
||||
|
||||
if let Some(host_name) = entry.host_name {
|
||||
resolved.host = host_name;
|
||||
}
|
||||
if resolved.user == DEFAULT_USER_SENTINEL {
|
||||
if let Some(user) = entry.user {
|
||||
resolved.user = user;
|
||||
}
|
||||
}
|
||||
if resolved.port == DEFAULT_PORT_SENTINEL {
|
||||
if let Some(port) = entry.port {
|
||||
resolved.port = port;
|
||||
}
|
||||
}
|
||||
if resolved.key_path.is_empty() {
|
||||
if let Some(identity_file) = entry.identity_file {
|
||||
resolved.key_path = identity_file;
|
||||
// If the SSH config supplied the only usable credential, make the
|
||||
// backend use it even when an older/default UI payload still says
|
||||
// "password" with an empty password.
|
||||
if resolved.auth_method.is_empty() || (resolved.auth_method == "password" && resolved.password.is_empty()) {
|
||||
resolved.auth_method = "key".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Parses a minimal subset of OpenSSH client config syntax: `Host`, `HostName`,
|
||||
/// `Port`, `User`, `IdentityFile`. Wildcard host patterns (containing `*` or
|
||||
/// `?`) are skipped since they aren't usable as a literal alias in the host
|
||||
/// field. `Include` and other directives are not supported.
|
||||
fn parse_ssh_config(content: &str) -> Vec<SshConfigHostEntry> {
|
||||
let mut entries: Vec<SshConfigHostEntry> = Vec::new();
|
||||
let mut current_aliases: Vec<String> = Vec::new();
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let line = strip_comment(raw_line).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((keyword, value)) = split_directive(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match keyword.to_ascii_lowercase().as_str() {
|
||||
"host" => {
|
||||
current_aliases = value
|
||||
.split_whitespace()
|
||||
.filter(|alias| !alias.contains('*') && !alias.contains('?'))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
for alias in ¤t_aliases {
|
||||
entries.push(SshConfigHostEntry {
|
||||
alias: alias.clone(),
|
||||
host_name: None,
|
||||
port: None,
|
||||
user: None,
|
||||
identity_file: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
"hostname" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.host_name = Some(value.to_string());
|
||||
}),
|
||||
"port" => {
|
||||
if let Ok(port) = value.parse::<u16>() {
|
||||
set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.port = Some(port);
|
||||
});
|
||||
}
|
||||
}
|
||||
"user" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.user = Some(value.to_string());
|
||||
}),
|
||||
"identityfile" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.identity_file = Some(value.to_string());
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn set_current_field(
|
||||
entries: &mut [SshConfigHostEntry],
|
||||
current_aliases: &[String],
|
||||
apply: impl Fn(&mut SshConfigHostEntry),
|
||||
) {
|
||||
for entry in entries.iter_mut() {
|
||||
if current_aliases.contains(&entry.alias) {
|
||||
apply(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
match line.find('#') {
|
||||
Some(index) => &line[..index],
|
||||
None => line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a config line into `(keyword, value)`. OpenSSH allows the keyword
|
||||
/// and value to be separated by whitespace or a single `=`.
|
||||
fn split_directive(line: &str) -> Option<(&str, &str)> {
|
||||
let line = line.trim();
|
||||
let split_index = line.find(|c: char| c.is_whitespace() || c == '=')?;
|
||||
let keyword = &line[..split_index];
|
||||
let value = line[split_index..].trim_start_matches(|c: char| c.is_whitespace() || c == '=').trim();
|
||||
if keyword.is_empty() || value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((keyword, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(host: &str) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
id: "1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
host: host.to_string(),
|
||||
port: DEFAULT_PORT_SENTINEL,
|
||||
user: DEFAULT_USER_SENTINEL.to_string(),
|
||||
password: String::new(),
|
||||
key_path: String::new(),
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_basic_host_block() {
|
||||
let entries = parse_ssh_config(
|
||||
"Host myserver\n HostName 10.0.0.5\n Port 2222\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n",
|
||||
);
|
||||
assert_eq!(entries.len(), 1);
|
||||
let entry = &entries[0];
|
||||
assert_eq!(entry.alias, "myserver");
|
||||
assert_eq!(entry.host_name, Some("10.0.0.5".to_string()));
|
||||
assert_eq!(entry.port, Some(2222));
|
||||
assert_eq!(entry.user, Some("deploy".to_string()));
|
||||
assert_eq!(entry.identity_file, Some("~/.ssh/id_ed25519".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_can_declare_multiple_aliases() {
|
||||
let entries = parse_ssh_config("Host prod prod-alias\n HostName 10.0.0.9\n");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|entry| entry.host_name == Some("10.0.0.9".to_string())));
|
||||
assert_eq!(entries[0].alias, "prod");
|
||||
assert_eq!(entries[1].alias, "prod-alias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_wildcard_host_patterns() {
|
||||
let entries = parse_ssh_config("Host *.example.com\n User git\nHost real\n User deploy\n");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].alias, "real");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comments_and_blank_lines() {
|
||||
let entries = parse_ssh_config("# a comment\n\nHost myserver # inline comment\n User deploy\n");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].user, Some("deploy".to_string()));
|
||||
}
|
||||
|
||||
fn entry(alias: &str) -> SshConfigHostEntry {
|
||||
SshConfigHostEntry {
|
||||
alias: alias.to_string(),
|
||||
host_name: Some("10.0.0.5".to_string()),
|
||||
port: Some(2222),
|
||||
user: Some("deploy".to_string()),
|
||||
identity_file: Some("~/.ssh/id_ed25519".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fills_unset_fields_from_matching_alias() {
|
||||
let ssh = config("myserver");
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.host, "10.0.0.5");
|
||||
assert_eq!(resolved.port, 2222);
|
||||
assert_eq!(resolved.user, "deploy");
|
||||
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
|
||||
assert_eq!(resolved.auth_method, "key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keeps_password_auth_when_password_is_present() {
|
||||
let mut ssh = config("myserver");
|
||||
ssh.password = "secret".to_string();
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
|
||||
assert_eq!(resolved.auth_method, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_does_not_override_explicit_values() {
|
||||
let mut ssh = config("myserver");
|
||||
ssh.user = "alice".to_string();
|
||||
ssh.port = 9999;
|
||||
ssh.key_path = "/explicit/key".to_string();
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.host, "10.0.0.5");
|
||||
assert_eq!(resolved.user, "alice");
|
||||
assert_eq!(resolved.port, 9999);
|
||||
assert_eq!(resolved.key_path, "/explicit/key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_noop_when_host_does_not_match_any_alias() {
|
||||
// `resolve_ssh_tunnel_config` looks up the real `~/.ssh/config`; an
|
||||
// alias this unlikely to exist on a test machine exercises the
|
||||
// "no match found" branch without needing to mock the filesystem.
|
||||
let ssh = config("dbx-test-alias-that-should-never-exist-anywhere");
|
||||
let resolved = resolve_ssh_tunnel_config(&ssh);
|
||||
assert_eq!(resolved, ssh);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,14 @@ pub struct ObjectInfo {
|
|||
pub parent_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub comment: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectStatistics {
|
||||
pub name: String,
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ async fn main() {
|
|||
.route("/jdbc/plugin/uninstall", post(routes::jdbc::uninstall_jdbc_plugin))
|
||||
// System
|
||||
.route("/system/fonts", get(routes::jdbc::list_system_fonts))
|
||||
.route("/ssh/config-hosts", get(routes::ssh_config::list_ssh_config_hosts))
|
||||
// Agent drivers
|
||||
.route("/agents/installed-local", get(routes::agents::list_installed_agents_local))
|
||||
.route("/agents/installed", get(routes::agents::list_installed_agents))
|
||||
|
|
@ -288,6 +289,8 @@ async fn main() {
|
|||
.route("/schema/sequences", get(routes::schema::list_sequences))
|
||||
.route("/schema/rules", get(routes::schema::list_rules))
|
||||
.route("/schema/owners", get(routes::schema::list_owners))
|
||||
.route("/schema/extensions", get(routes::schema::list_extensions))
|
||||
.route("/schema/available-extensions", get(routes::schema::list_available_extensions))
|
||||
.route("/schema/ddl", get(routes::schema::get_ddl))
|
||||
.route("/schema-diff/prepare", post(routes::schema_diff::prepare_schema_diff))
|
||||
.route("/schema-diff/generate-sync-sql", post(routes::schema_diff::generate_schema_sync_sql))
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod schema;
|
|||
pub mod schema_cache;
|
||||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod ssh_config;
|
||||
pub mod tab_runtime_cache;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
|
|
|
|||
|
|
@ -332,3 +332,26 @@ pub async fn list_owners(
|
|||
dbx_core::schema::list_owners_core(&state.app, &q.connection_id, database, schema).await.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_extensions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_extensions_core(&state.app, &q.connection_id, database, schema)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_available_extensions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_available_extensions_core(&state.app, &q.connection_id, database)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
use axum::Json;
|
||||
use dbx_core::ssh_config::{self, SshConfigHostEntry};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
pub async fn list_ssh_config_hosts() -> Result<Json<Vec<SshConfigHostEntry>>, AppError> {
|
||||
Ok(Json(ssh_config::list_hosts().map_err(AppError::internal)?))
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ pub mod schema_cache;
|
|||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod sqlite_backup;
|
||||
pub mod ssh_config;
|
||||
pub mod system_fonts;
|
||||
pub mod tab_runtime_cache;
|
||||
pub mod table_export;
|
||||
|
|
|
|||
|
|
@ -292,3 +292,22 @@ pub async fn list_owners(
|
|||
) -> Result<Vec<db::OwnerInfo>, String> {
|
||||
dbx_core::schema::list_owners_core(&state, &connection_id, &database, &schema).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_extensions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
dbx_core::schema::list_extensions_core(&state, &connection_id, &database, &schema).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_available_extensions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
dbx_core::schema::list_available_extensions_core(&state, &connection_id, &database).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
use dbx_core::ssh_config::SshConfigHostEntry;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_ssh_config_hosts() -> Result<Vec<SshConfigHostEntry>, String> {
|
||||
tauri::async_runtime::spawn_blocking(dbx_core::ssh_config::list_hosts).await.map_err(|err| err.to_string())?
|
||||
}
|
||||
|
|
@ -799,6 +799,8 @@ pub fn run() {
|
|||
commands::schema::list_sequences,
|
||||
commands::schema::list_rules,
|
||||
commands::schema::list_owners,
|
||||
commands::schema::list_extensions,
|
||||
commands::schema::list_available_extensions,
|
||||
commands::schema_diff::prepare_schema_diff,
|
||||
commands::schema_diff::generate_schema_sync_sql,
|
||||
commands::schema_cache::save_schema_cache,
|
||||
|
|
@ -1102,6 +1104,7 @@ pub fn run() {
|
|||
commands::agents::import_agents_from_zip,
|
||||
commands::agents::import_agent_jar_cmd,
|
||||
commands::system_fonts::list_system_fonts,
|
||||
commands::ssh_config::list_ssh_config_hosts,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
|
|
|||
Loading…
Reference in New Issue