feat(postgres): add extension management
This commit is contained in:
1 parent
63dd0e88b5
commit
52499f4c9f
13 files changed
+426
-122
No files matched your search
@@ -5,6 +5,7 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -23,15 +24,34 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const available = ref<ExtensionInfo[]>([]);
|
||||
const installed = ref<ExtensionInfo[]>([]);
|
||||
const schemas = ref<string[]>([]);
|
||||
const selectedSchema = ref<string>("__default__");
|
||||
const loading = ref(false);
|
||||
const installing = ref<string | null>(null);
|
||||
const dropping = ref<string | null>(null);
|
||||
|
||||
function normalizeSchemaOptions(value: string[]): string[] {
|
||||
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function preferredSchemaOption(options: string[]): string {
|
||||
if (options.includes("public")) return "public";
|
||||
return options[0] ?? "__default__";
|
||||
}
|
||||
|
||||
function installedExtensionSummary(ext: ExtensionInfo): string {
|
||||
const parts = [ext.version];
|
||||
if (ext.schema) parts.push(`schema: ${ext.schema}`);
|
||||
if (ext.comment) parts.push(ext.comment);
|
||||
return parts.filter(Boolean).join(" - ");
|
||||
}
|
||||
|
||||
function show() {
|
||||
open.value = true;
|
||||
void loadData();
|
||||
@@ -41,9 +61,21 @@ 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;
|
||||
const [availResult, instResult, schemaResult] = await Promise.allSettled([
|
||||
api.listAvailableExtensions(props.node.connectionId, props.node.database),
|
||||
api.listExtensions(props.node.connectionId, props.node.database, props.node.schema),
|
||||
api.listSchemaInfos(props.node.connectionId, props.node.database),
|
||||
]);
|
||||
|
||||
available.value = availResult.status === "fulfilled" ? availResult.value : [];
|
||||
installed.value = instResult.status === "fulfilled" ? instResult.value : [];
|
||||
schemas.value = schemaResult.status === "fulfilled" ? normalizeSchemaOptions(schemaResult.value.map((schema) => schema.name)) : [];
|
||||
selectedSchema.value = props.node.schema && schemas.value.includes(props.node.schema) ? props.node.schema : selectedSchema.value !== "__default__" && schemas.value.includes(selectedSchema.value) ? selectedSchema.value : preferredSchemaOption(schemas.value);
|
||||
|
||||
const firstError = [availResult, instResult, schemaResult].find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason;
|
||||
if (firstError) {
|
||||
throw firstError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -55,17 +87,18 @@ 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);
|
||||
const schema = selectedSchema.value === "__default__" ? null : selectedSchema.value;
|
||||
const sql = buildCreateExtensionSql(name, schema);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(props.node.connectionId),
|
||||
database: props.node.database,
|
||||
sql,
|
||||
source: t("production.sourceExtension"),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
|
||||
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, schema ?? undefined),
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
emit("close");
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -87,6 +120,7 @@ async function dropExtension(name: string) {
|
||||
});
|
||||
if (!result) return;
|
||||
await loadData();
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
} finally {
|
||||
@@ -108,59 +142,81 @@ defineExpose({ show });
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid flex-1 min-h-0 grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Left: Available -->
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<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 v-else class="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 rounded-lg border bg-muted/30 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{{ t("extension.available") }}</span>
|
||||
<span>{{ available.length }}</span>
|
||||
<span class="text-border">/</span>
|
||||
<span class="font-medium text-foreground">{{ t("extension.installed") }}</span>
|
||||
<span>{{ installed.length }}</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">Schema</div>
|
||||
<Select v-model="selectedSchema">
|
||||
<SelectTrigger class="h-8 w-32 justify-between">
|
||||
<SelectValue :placeholder="'Schema'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{{ t("common.default") }}</SelectItem>
|
||||
<SelectItem v-for="schema in schemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ScrollArea class="min-h-0 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 min-h-0 flex-col">
|
||||
<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="min-h-0 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 class="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<section class="flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/30 px-3">
|
||||
<Package class="h-4 w-4 text-muted-foreground" />
|
||||
<div class="text-sm font-semibold text-foreground">{{ t("extension.available") }}</div>
|
||||
<span class="ml-auto rounded-full bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border">{{ available.length }}</span>
|
||||
</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>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div v-if="available.length === 0" class="flex items-center justify-center px-6 py-12 text-center 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-3 px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ ext.name }}</div>
|
||||
<div class="line-clamp-2 text-xs leading-4 text-muted-foreground">{{ ext.comment || ext.version }}</div>
|
||||
</div>
|
||||
<Button class="shrink-0" 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>
|
||||
</section>
|
||||
|
||||
<section class="flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/30 px-3">
|
||||
<Package class="h-4 w-4 text-muted-foreground" />
|
||||
<div class="text-sm font-semibold text-foreground">{{ t("extension.installed") }}</div>
|
||||
<span class="ml-auto rounded-full bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border">{{ installed.length }}</span>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div v-if="installed.length === 0" class="flex items-center justify-center px-6 py-12 text-center 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-3 px-3 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ ext.name }}</div>
|
||||
<div class="line-clamp-2 text-xs leading-4 text-muted-foreground">{{ installedExtensionSummary(ext) }}</div>
|
||||
</div>
|
||||
<Button class="shrink-0" 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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1051,7 +1051,7 @@ async function executeSidebarProcedureSql(sql: string) {
|
||||
}
|
||||
|
||||
async function refreshSidebarActionTarget() {
|
||||
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value;
|
||||
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value || sidebarInstallExtensionTarget.value;
|
||||
if (!target) return;
|
||||
const currentTarget = findSidebarActionTarget(store.treeNodes, target);
|
||||
if (!currentTarget) return;
|
||||
@@ -1521,7 +1521,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
||||
</template>
|
||||
</SidebarDangerConfirmDialog>
|
||||
<SidebarTreeItemDialogs v-if="sidebarTreeItemDialogController" :key="sidebarTreeItemDialogController.node?.id" :controller="sidebarTreeItemDialogController" @closed="sidebarTreeItemDialogController = null" />
|
||||
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" />
|
||||
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" @changed="refreshSidebarActionTarget" />
|
||||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t("sidebar.noConnections") }}
|
||||
</div>
|
||||
|
||||
@@ -518,6 +518,13 @@ async function toggle() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "group-extensions" && connectionStore.isTreeNodeChildrenLoaded(node.id)) {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
@@ -641,6 +648,8 @@ async function toggle() {
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||||
} else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||||
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||||
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||||
await connectionStore.refreshTreeNode(node);
|
||||
}
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
} catch (e: any) {
|
||||
|
||||
Reference in new issue
Block a user