mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 08:23:34 +00:00
feat(diff): selective sync, swap button, and execution progress
Schema Compare: - Add checkbox per diff item for selective sync (with select-all) - SQL preview now reactively updates based on checked items - Add swap button to exchange source↔target - Execute sync now runs statement-by-statement with progress indicator - Show detailed error report for failed statements Data Compare: - Add swap button to exchange source↔target - Execute sync now runs statement-by-statement with progress indicator - Show error summary and per-statement failures
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/lib/dataCompare";
|
||||
import * as api from "@/lib/api";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { Copy, GitCompareArrows, Loader2, Play } from "lucide-vue-next";
|
||||
import { Copy, GitCompareArrows, Loader2, Play, ArrowLeftRight } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -56,6 +56,9 @@ const syncSql = ref("");
|
||||
const syncStatements = ref<string[]>([]);
|
||||
const comparing = ref(false);
|
||||
const executing = ref(false);
|
||||
const executedCount = ref(0);
|
||||
const executeTotal = ref(0);
|
||||
const syncErrors = ref<{ sql: string; error: string }[]>([]);
|
||||
const rowLimitOptions = [1000, 5000, 10000, 50000];
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
@@ -96,6 +99,31 @@ function connectionIconType(connectionId: string) {
|
||||
return config?.driver_profile || config?.db_type || "mysql";
|
||||
}
|
||||
|
||||
function swapSourceTarget() {
|
||||
const tmpConnId = sourceConnectionId.value;
|
||||
const tmpDb = sourceDatabase.value;
|
||||
const tmpDbs = sourceDatabases.value;
|
||||
const tmpSchema = sourceSchema.value;
|
||||
const tmpSchemas = sourceSchemas.value;
|
||||
const tmpTable = sourceTable.value;
|
||||
const tmpTables = sourceTables.value;
|
||||
sourceConnectionId.value = targetConnectionId.value;
|
||||
sourceDatabase.value = targetDatabase.value;
|
||||
sourceDatabases.value = targetDatabases.value;
|
||||
sourceSchema.value = targetSchema.value;
|
||||
sourceSchemas.value = targetSchemas.value;
|
||||
sourceTable.value = targetTable.value;
|
||||
sourceTables.value = targetTables.value;
|
||||
targetConnectionId.value = tmpConnId;
|
||||
targetDatabase.value = tmpDb;
|
||||
targetDatabases.value = tmpDbs;
|
||||
targetSchema.value = tmpSchema;
|
||||
targetSchemas.value = tmpSchemas;
|
||||
targetTable.value = tmpTable;
|
||||
targetTables.value = tmpTables;
|
||||
clearResult();
|
||||
}
|
||||
|
||||
async function resolveSchema(connectionId: string, database: string, preferredSchema = ""): Promise<string> {
|
||||
const config = store.getConfig(connectionId);
|
||||
if (isSchemaAware(config?.db_type)) {
|
||||
@@ -305,14 +333,25 @@ async function copySql() {
|
||||
async function executeSql() {
|
||||
if (!syncSql.value.trim() || syncStatements.value.length === 0 || executing.value) return;
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
executeTotal.value = syncStatements.value.length;
|
||||
executedCount.value = 0;
|
||||
try {
|
||||
await api.executeInTransaction(
|
||||
targetConnectionId.value,
|
||||
targetDatabase.value,
|
||||
syncStatements.value,
|
||||
targetSchema.value,
|
||||
);
|
||||
toast(t("dataCompare.syncSuccess"), 2000);
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
for (const stmt of syncStatements.value) {
|
||||
try {
|
||||
await api.executeQuery(targetConnectionId.value, targetDatabase.value, stmt, targetSchema.value);
|
||||
} catch (e: any) {
|
||||
syncErrors.value.push({ sql: stmt, error: e?.message || String(e) });
|
||||
}
|
||||
executedCount.value++;
|
||||
}
|
||||
const failed = syncErrors.value.length;
|
||||
if (failed === 0) {
|
||||
toast(t("dataCompare.syncSuccess"), 2000);
|
||||
} else {
|
||||
toast(t("diff.syncSummary", { success: syncStatements.value.length - failed, failed }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
@@ -401,7 +440,7 @@ watch(open, async (value) => {
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<Select
|
||||
@@ -452,6 +491,12 @@ watch(open, async (value) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" @click="swapSourceTarget">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<Select
|
||||
@@ -558,9 +603,27 @@ watch(open, async (value) => {
|
||||
<div v-else-if="result" class="text-sm text-muted-foreground">
|
||||
{{ t("dataCompare.noDifferences") }}
|
||||
</div>
|
||||
|
||||
<!-- Sync Errors -->
|
||||
<div v-if="syncErrors.length > 0" class="space-y-1">
|
||||
<Label class="text-xs font-medium text-destructive">
|
||||
{{ t("diff.syncSummary", { success: executeTotal - syncErrors.length, failed: syncErrors.length }) }}
|
||||
</Label>
|
||||
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
|
||||
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
|
||||
<span class="text-destructive">{{ err.error }}</span>
|
||||
<span class="text-muted-foreground ml-1"
|
||||
>— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter v-if="result && syncSql.trim()" class="flex items-center gap-2">
|
||||
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
|
||||
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
||||
</Button>
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
type TableDiff,
|
||||
} from "@/lib/schemaDiff";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Loader2, Copy, Play, GitCompareArrows } from "lucide-vue-next";
|
||||
import { Loader2, Copy, Play, GitCompareArrows, ArrowLeftRight } from "lucide-vue-next";
|
||||
|
||||
interface SelectableTableDiff extends TableDiff {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -46,10 +50,27 @@ const targetSchema = ref("");
|
||||
const targetSchemas = ref<string[]>([]);
|
||||
|
||||
const step = ref<"select" | "comparing" | "result">("select");
|
||||
const diffs = ref<TableDiff[]>([]);
|
||||
const syncSql = ref("");
|
||||
const diffs = ref<SelectableTableDiff[]>([]);
|
||||
const loadingMeta = ref(false);
|
||||
const executing = ref(false);
|
||||
const executedCount = ref(0);
|
||||
const executeTotal = ref(0);
|
||||
const syncErrors = ref<{ sql: string; error: string }[]>([]);
|
||||
|
||||
const allSelected = computed(() => diffs.value.length > 0 && diffs.value.every((d) => d.selected));
|
||||
const someSelected = computed(() => diffs.value.some((d) => d.selected) && !allSelected.value);
|
||||
|
||||
function toggleAll() {
|
||||
const next = !allSelected.value;
|
||||
diffs.value.forEach((d) => (d.selected = next));
|
||||
}
|
||||
|
||||
const syncSql = computed(() => {
|
||||
const selected = diffs.value.filter((d) => d.selected);
|
||||
if (selected.length === 0) return "";
|
||||
const srcConfig = store.getConfig(targetConnectionId.value);
|
||||
return generateSyncSql(selected, srcConfig?.db_type || "mysql", targetSchema.value);
|
||||
});
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
|
||||
@@ -70,6 +91,25 @@ function connectionIconType(connectionId: string) {
|
||||
return config?.driver_profile || config?.db_type || "mysql";
|
||||
}
|
||||
|
||||
function swapSourceTarget() {
|
||||
const tmpConnId = sourceConnectionId.value;
|
||||
const tmpDb = sourceDatabase.value;
|
||||
const tmpDbs = sourceDatabases.value;
|
||||
const tmpSchema = sourceSchema.value;
|
||||
const tmpSchemas = sourceSchemas.value;
|
||||
sourceConnectionId.value = targetConnectionId.value;
|
||||
sourceDatabase.value = targetDatabase.value;
|
||||
sourceDatabases.value = targetDatabases.value;
|
||||
sourceSchema.value = targetSchema.value;
|
||||
sourceSchemas.value = targetSchemas.value;
|
||||
targetConnectionId.value = tmpConnId;
|
||||
targetDatabase.value = tmpDb;
|
||||
targetDatabases.value = tmpDbs;
|
||||
targetSchema.value = tmpSchema;
|
||||
targetSchemas.value = tmpSchemas;
|
||||
resetResult();
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
loadingMeta.value = true;
|
||||
@@ -132,7 +172,6 @@ async function startCompare() {
|
||||
if (!canCompare.value) return;
|
||||
step.value = "comparing";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
|
||||
try {
|
||||
await store.ensureConnected(sourceConnectionId.value);
|
||||
@@ -152,23 +191,23 @@ async function startCompare() {
|
||||
const { added, removed, common } = diffTables(srcTableNames, tgtTableNames);
|
||||
const { added: addedViews, removed: removedViews } = diffTables(srcViewNames, tgtViewNames);
|
||||
|
||||
const result: TableDiff[] = [];
|
||||
const result: SelectableTableDiff[] = [];
|
||||
|
||||
for (const name of added) {
|
||||
const ddl = await api.getTableDdl(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name);
|
||||
result.push({ type: "added", objectType: "table", name, ddl });
|
||||
result.push({ type: "added", objectType: "table", name, ddl, selected: true });
|
||||
}
|
||||
|
||||
for (const name of removed) {
|
||||
result.push({ type: "removed", objectType: "table", name });
|
||||
result.push({ type: "removed", objectType: "table", name, selected: true });
|
||||
}
|
||||
|
||||
for (const name of addedViews) {
|
||||
result.push({ type: "added", objectType: "view", name });
|
||||
result.push({ type: "added", objectType: "view", name, selected: true });
|
||||
}
|
||||
|
||||
for (const name of removedViews) {
|
||||
result.push({ type: "removed", objectType: "view", name });
|
||||
result.push({ type: "removed", objectType: "view", name, selected: true });
|
||||
}
|
||||
|
||||
for (const name of common) {
|
||||
@@ -208,13 +247,12 @@ async function startCompare() {
|
||||
triggers: triggerDiffs.length > 0 ? triggerDiffs : undefined,
|
||||
sourceTableComment: commentChanged ? srcComment : undefined,
|
||||
targetTableComment: commentChanged ? tgtComment : undefined,
|
||||
selected: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
diffs.value = result;
|
||||
const srcConfig = store.getConfig(targetConnectionId.value);
|
||||
syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql", targetSchema.value);
|
||||
step.value = "result";
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
@@ -223,13 +261,35 @@ async function startCompare() {
|
||||
}
|
||||
|
||||
async function executeSql() {
|
||||
if (!syncSql.value.trim() || executing.value) return;
|
||||
const sql = syncSql.value.trim();
|
||||
if (!sql || executing.value) return;
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
|
||||
const statements = sql
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !s.startsWith("--"));
|
||||
executeTotal.value = statements.length;
|
||||
executedCount.value = 0;
|
||||
|
||||
try {
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value, targetSchema.value);
|
||||
toast(t("diff.syncSuccess"), 2000);
|
||||
open.value = false;
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await api.executeQuery(targetConnectionId.value, targetDatabase.value, stmt, targetSchema.value);
|
||||
} catch (e: any) {
|
||||
syncErrors.value.push({ sql: stmt, error: e?.message || String(e) });
|
||||
}
|
||||
executedCount.value++;
|
||||
}
|
||||
const failed = syncErrors.value.length;
|
||||
if (failed === 0) {
|
||||
toast(t("diff.syncSuccess"), 2000);
|
||||
open.value = false;
|
||||
} else {
|
||||
toast(t("diff.syncSummary", { success: statements.length - failed, failed }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
@@ -257,7 +317,9 @@ function diffLabel(type: string) {
|
||||
function resetResult() {
|
||||
step.value = "select";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
syncErrors.value = [];
|
||||
executedCount.value = 0;
|
||||
executeTotal.value = 0;
|
||||
}
|
||||
|
||||
watch(sourceConnectionId, (id) => {
|
||||
@@ -291,7 +353,9 @@ watch(open, async (val) => {
|
||||
if (val) {
|
||||
step.value = "select";
|
||||
diffs.value = [];
|
||||
syncSql.value = "";
|
||||
syncErrors.value = [];
|
||||
executedCount.value = 0;
|
||||
executeTotal.value = 0;
|
||||
if (props.prefillConnectionId) {
|
||||
sourceConnectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId, "source");
|
||||
@@ -316,7 +380,7 @@ watch(open, async (val) => {
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
||||
<!-- Source / Target Selection -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
|
||||
<Select
|
||||
@@ -368,6 +432,12 @@ watch(open, async (val) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" @click="swapSourceTarget">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
|
||||
<Select
|
||||
@@ -445,6 +515,15 @@ watch(open, async (val) => {
|
||||
<table class="w-full text-xs table-fixed">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-2 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-1/4">{{ t("diff.table") }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium w-16">{{ t("diff.status") }}</th>
|
||||
<th class="text-left px-3 py-2 font-medium">{{ t("diff.details") }}</th>
|
||||
@@ -452,6 +531,9 @@ watch(open, async (val) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in diffs" :key="d.name" class="border-t border-border/50 hover:bg-accent/30">
|
||||
<td class="px-2 py-1.5">
|
||||
<input v-model="d.selected" type="checkbox" class="accent-primary" />
|
||||
</td>
|
||||
<td class="px-3 py-1.5 font-mono truncate">{{ d.name }}</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<Badge :variant="diffBadgeVariant(d.type)" class="text-[10px] h-4 px-1.5">
|
||||
@@ -531,15 +613,34 @@ watch(open, async (val) => {
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
|
||||
<textarea
|
||||
v-model="syncSql"
|
||||
:value="syncSql"
|
||||
readonly
|
||||
class="w-full h-48 rounded-lg border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sync Errors -->
|
||||
<div v-if="syncErrors.length > 0" class="space-y-1">
|
||||
<Label class="text-xs font-medium text-destructive">
|
||||
{{ t("diff.syncSummary", { success: executeTotal - syncErrors.length, failed: syncErrors.length }) }}
|
||||
</Label>
|
||||
<div class="max-h-32 overflow-auto border rounded-lg bg-destructive/5 p-2 space-y-1">
|
||||
<div v-for="(err, i) in syncErrors" :key="i" class="text-xs font-mono">
|
||||
<span class="text-destructive">{{ err.error }}</span>
|
||||
<span class="text-muted-foreground ml-1"
|
||||
>— {{ err.sql.slice(0, 80) }}{{ err.sql.length > 80 ? "..." : "" }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter v-if="step === 'result' && diffs.length > 0" class="flex items-center gap-2">
|
||||
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
|
||||
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" @click="copySql">
|
||||
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
||||
</Button>
|
||||
|
||||
@@ -906,6 +906,9 @@ export default {
|
||||
copySql: "Copy SQL",
|
||||
executeSync: "Execute Sync",
|
||||
syncSuccess: "Sync executed successfully",
|
||||
swap: "Swap source and target",
|
||||
syncProgress: "Executing: {current}/{total}",
|
||||
syncSummary: "Succeeded {success}, failed {failed}",
|
||||
},
|
||||
dataCompare: {
|
||||
title: "Compare Data",
|
||||
|
||||
@@ -909,6 +909,9 @@ export default {
|
||||
copySql: "Copiar SQL",
|
||||
executeSync: "Ejecutar sincronización",
|
||||
syncSuccess: "Sincronización ejecutada exitosamente",
|
||||
swap: "Intercambiar origen y destino",
|
||||
syncProgress: "Ejecutando: {current}/{total}",
|
||||
syncSummary: "Exitosos {success}, fallidos {failed}",
|
||||
},
|
||||
dataCompare: {
|
||||
title: "Comparar datos",
|
||||
|
||||
@@ -892,6 +892,9 @@ export default {
|
||||
copySql: "复制 SQL",
|
||||
executeSync: "执行同步",
|
||||
syncSuccess: "同步执行成功",
|
||||
swap: "交换源和目标",
|
||||
syncProgress: "执行中: {current}/{total}",
|
||||
syncSummary: "成功 {success} 条,失败 {failed} 条",
|
||||
},
|
||||
dataCompare: {
|
||||
title: "比较数据",
|
||||
|
||||
Reference in New Issue
Block a user