feat(execution): multi-database SQL execution with scoped targets

This commit is contained in:
miracle authored and GitHub committed 2026-08-09 14:24:10 +08:00
1 parent 328a2aa107
commit 71ccb3d97d
46 files changed
+4421 -182

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, ChevronRight, FolderOpen } from "@lucide/vue";
import { Loader2, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
import { useToast } from "@/composables/useToast";
@@ -69,6 +69,11 @@ const progressValue = (task: ExportTask) => {
if (task.status === "Done") return 100;
return Math.min(95, Math.round(((task.tableIndex ?? 0) / task.totalTables) * 100));
}
if (task.kind === "multi-db-execution") {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return 100;
if (!task.multiDbTotal) return 0;
return Math.min(95, Math.round(((task.multiDbCompleted ?? 0) / task.multiDbTotal) * 100));
}
return progressPercent(task.totalRows, task.rowsExported);
};
@@ -92,6 +97,7 @@ const taskTitle = (task: ExportTask) => {
}
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
return taskFileName(task) || `${task.tableName}.${task.format}`;
};
@@ -124,6 +130,14 @@ const rowsText = (task: ExportTask) => {
const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) });
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`;
}
if (task.kind === "multi-db-execution") {
return t("exportProgress.multiDbTargets", {
completed: (task.multiDbCompleted ?? 0).toLocaleString(),
total: (task.multiDbTotal ?? 0).toLocaleString(),
success: (task.multiDbSuccessCount ?? 0).toLocaleString(),
failed: (task.multiDbFailureCount ?? 0).toLocaleString(),
});
}
if (task.totalRows) return `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()}`;
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
@@ -151,6 +165,7 @@ const statusIcon = (task: ExportTask) => {
if (task.kind === "database-export") return DatabaseBackup;
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
if (task.kind === "multi-db-execution") return Layers3;
}
switch (task.status) {
case "Running":
@@ -215,6 +230,10 @@ function toggleFailureDetails(exportId: string) {
function failureDetailCount(task: ExportTask) {
return (task.transferFailures?.length ?? 0) + (task.transferFailuresOmitted ?? 0);
}
function openTask(task: ExportTask): void {
task.onOpen?.();
}
</script>
<template>
@@ -245,7 +264,7 @@ function failureDetailCount(task: ExportTask) {
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables)"
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables) || (task.kind === 'multi-db-execution' && task.multiDbTotal)"
class="h-full bg-primary rounded-full transition-[width] duration-300"
:style="{ width: `${progressValue(task)}%` }"
/>
@@ -286,6 +305,9 @@ function failureDetailCount(task: ExportTask) {
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
<div class="flex shrink-0 pt-4">
<button v-if="task.kind === 'multi-db-execution' && task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
@@ -20,6 +20,7 @@ const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/s
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
@@ -89,6 +90,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
const request = productionSafetyStore.pending;
@@ -99,6 +101,15 @@ const productionConfirmationDetails = computed(() => {
source: request.source || "-",
});
});
const multiDbDangerDetails = computed(() => {
const request = sqlExecutionDangerStore.pending;
if (!request) return "";
return [request.targetLabel || request.connectionName, request.database].filter(Boolean).join("\n");
});
const multiDbDangerMessage = computed(() => {
const request = sqlExecutionDangerStore.pending;
return request?.kind === "redis" ? t("dangerDialog.redisCommandMessage") : t("dangerDialog.message");
});
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@@ -165,6 +176,19 @@ watch(
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<DangerConfirmDialog
v-if="sqlExecutionDangerStore.pending"
:open="true"
:title="t('multiDbExecute.dangerTitle')"
:message="multiDbDangerMessage"
:details-text="multiDbDangerDetails"
:sql="sqlExecutionDangerStore.pending.sql"
:confirm-label="t('multiDbExecute.dangerConfirm')"
:show-suppress-toggle="false"
:close-on-confirm="false"
@update:open="(open) => !open && sqlExecutionDangerStore.cancel()"
@confirm="sqlExecutionDangerStore.confirm()"
/>
<SqlParameterDialog
v-if="showSqlParameterDialog"
:open="showSqlParameterDialog"
@@ -245,7 +245,12 @@ const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection));
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
const activeResultConnection = computed(() => (activeResultExecutionTarget.value ? connectionStore.getConfig(activeResultExecutionTarget.value.connectionId) : props.activeConnection));
const activeResultConnectionId = computed(() => activeResultExecutionTarget.value?.connectionId ?? props.activeTab.connectionId);
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
@@ -473,7 +478,7 @@ function mongoQueryResultDocumentId(rowIdx: number, fallback: unknown): unknown
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
const tab = props.activeTab;
const target = tab.mongoEditTarget;
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !tab.connectionId || !tab.database || !tab.result) return undefined;
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !activeResultConnectionId.value || !activeResultDatabase.value || !tab.result) return undefined;
if (!tab.result.columns.includes(target.idColumn)) return undefined;
const save: CustomSaveHandler["save"] = async (changes: MongoQueryGridChanges) => {
@@ -488,7 +493,7 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
if (id === null || id === undefined || String(id).trim() === "") continue;
const updateDoc = buildMongoUpdateDocument(dirtyCols, changes.columns, tab.result?.mongo_documents?.[rowIdx]);
if (Object.keys(updateDoc).length === 0) continue;
await api.mongoUpdateDocument(tab.connectionId, tab.database, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc));
await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, serializeMongoDocumentId(mongoQueryResultDocumentId(rowIdx, id)), JSON.stringify(updateDoc));
}
};
@@ -1070,7 +1075,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
>
{{ t("tabs.runN", { n: run.sequence }) }}
{{ run.title || t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
@@ -1089,7 +1094,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="min-w-0 truncate">{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<span class="min-w-0 truncate">{{ activeResultRunItem ? activeResultRunItem.title || t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
</Button>
</DropdownMenuTrigger>
@@ -1097,7 +1102,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<span class="min-w-0 flex-1 truncate">{{ run.title || t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -1494,9 +1499,9 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
context="results"
:auto-transpose-single-row="settingsStore.editorSettings.dataGridAutoTransposeSingleRow"
:database-type="activeEffectiveDatabaseType"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:connection-id="activeResultConnectionId"
:database="activeResultDatabase"
:schema="activeResultSchema"
:table-meta="activeTab.tableMeta"
:table-info-tab="activeTab.tableInfoTab"
:page-offset="activeTab.resultPageOffset"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2 } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -16,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { useConnectionGroupLabel } from "@/composables/useConnectionGroupLabel";
import { isSingleDatabase, supportsClearableQuerySchema, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -46,6 +47,7 @@ const emit = defineEmits<{
openSql: [];
importResultArchive: [];
pasteSqlInCondition: [];
multiExecute: [];
changeConnection: [connectionId: string];
changeCatalog: [catalog: string | undefined, database: string];
changeDatabase: [database: string];
@@ -146,6 +148,12 @@ const executeButtonClass = computed(() => {
});
const isTransactionActive = computed(() => !!props.txnSessionId);
const canMultiExecute = computed(() => {
if (!supportsQueryExecution(props.activeConnection?.db_type)) return false;
if (props.activeTab.isExecuting || props.activeTab.isExplaining || props.activeTab.isCancelling) return false;
if (props.autoCommit === false || isTransactionActive.value) return false;
return !!props.executableSql.trim();
});
const showSchemaSelector = computed(() => {
const connection = props.activeConnection;
@@ -399,6 +407,14 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.exPasteSqlInCondition") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-primary hover:bg-primary/10" :disabled="!canMultiExecute" :aria-label="t('toolbar.multiDbExecute')" @click="emit('multiExecute')">
<CirclePlay class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.multiDbExecute") }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">