feat(execution): multi-database SQL execution with scoped targets
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user