1484 lines
56 KiB
Vue
1484 lines
56 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import { useToast } from "@/composables/useToast";
|
|
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import type {
|
|
DataCompareCellValue,
|
|
DataCompareModifiedRow,
|
|
DataCompareResult,
|
|
DataCompareRow,
|
|
DataCompareSyncPlan,
|
|
DataCompareSyncPlanTableOptions,
|
|
} from "@/lib/dataCompare";
|
|
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
|
import * as api from "@/lib/api";
|
|
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|
import {
|
|
ArrowLeftRight,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Copy,
|
|
GitCompareArrows,
|
|
Loader2,
|
|
Play,
|
|
Square,
|
|
} from "@lucide/vue";
|
|
|
|
type CompareColumn = ColumnInfo;
|
|
|
|
interface DataCompareTableTask {
|
|
sourceTable: string;
|
|
targetTable: string;
|
|
}
|
|
|
|
type DataCompareTableStatus = "different" | "same" | "error";
|
|
type DiffKind = "added" | "removed" | "modified";
|
|
|
|
interface SelectableDataCompareRow extends DataCompareRow {
|
|
selected: boolean;
|
|
}
|
|
|
|
interface SelectableDataCompareModifiedRow extends DataCompareModifiedRow {
|
|
selected: boolean;
|
|
}
|
|
|
|
interface SelectableDataCompareResult {
|
|
added: SelectableDataCompareRow[];
|
|
removed: SelectableDataCompareRow[];
|
|
modified: SelectableDataCompareModifiedRow[];
|
|
}
|
|
|
|
interface DataCompareTableResult {
|
|
sourceTable: string;
|
|
targetTable: string;
|
|
keyColumns: string[];
|
|
columns: string[];
|
|
columnInfo: CompareColumn[];
|
|
status: DataCompareTableStatus;
|
|
added: number;
|
|
removed: number;
|
|
modified: number;
|
|
sourceRowCount: number;
|
|
targetRowCount: number;
|
|
sourceTruncated: boolean;
|
|
targetTruncated: boolean;
|
|
databaseType?: DatabaseType;
|
|
preSyncStatements?: string[];
|
|
diff: SelectableDataCompareResult;
|
|
expanded: boolean;
|
|
showAll: Record<DiffKind, boolean>;
|
|
error?: string;
|
|
}
|
|
|
|
const PREVIEW_LIMIT_OPTIONS = [50, 100, 200, 500];
|
|
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const store = useConnectionStore();
|
|
const open = defineModel<boolean>("open", { default: false });
|
|
|
|
const props = defineProps<{
|
|
prefillConnectionId?: string;
|
|
prefillDatabase?: string;
|
|
prefillSchema?: string;
|
|
prefillTable?: string;
|
|
}>();
|
|
|
|
const sourceConnectionId = ref("");
|
|
const sourceDatabase = ref("");
|
|
const sourceSchema = ref("");
|
|
const sourceTable = ref("");
|
|
const sourceDatabases = ref<string[]>([]);
|
|
const sourceSchemas = ref<string[]>([]);
|
|
const sourceTables = ref<string[]>([]);
|
|
const sourceTableSearch = ref("");
|
|
const selectedSourceTables = ref<Set<string>>(new Set());
|
|
|
|
const targetConnectionId = ref("");
|
|
const targetDatabase = ref("");
|
|
const targetSchema = ref("");
|
|
const targetTable = ref("");
|
|
const targetDatabases = ref<string[]>([]);
|
|
const targetSchemas = ref<string[]>([]);
|
|
const targetTables = ref<string[]>([]);
|
|
|
|
const keyColumnsText = ref("");
|
|
const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1]));
|
|
const batchResults = ref<DataCompareTableResult[]>([]);
|
|
const syncPlan = ref<DataCompareSyncPlan>(emptySyncPlan());
|
|
const comparing = ref(false);
|
|
const planningSync = ref(false);
|
|
const compareProgressCurrent = ref(0);
|
|
const compareProgressTotal = ref(0);
|
|
const compareProgressTable = ref("");
|
|
const executing = ref(false);
|
|
const executedCount = ref(0);
|
|
const executeTotal = ref(0);
|
|
const syncErrors = ref<{ sql: string; error: string }[]>([]);
|
|
const showAdded = ref(true);
|
|
const showRemoved = ref(true);
|
|
const showModified = ref(true);
|
|
|
|
let syncPlanRequestId = 0;
|
|
|
|
const sqlConnections = computed(() =>
|
|
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch"].includes(connection.db_type)),
|
|
);
|
|
const selectedSourceTableNames = computed(() =>
|
|
sourceTables.value.filter((table) => selectedSourceTables.value.has(table)),
|
|
);
|
|
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
|
const filteredSourceTables = computed(() => {
|
|
const query = sourceTableSearch.value.trim().toLowerCase();
|
|
if (!query) return sourceTables.value;
|
|
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
|
|
});
|
|
const allFilteredTablesSelected = computed(
|
|
() =>
|
|
filteredSourceTables.value.length > 0 &&
|
|
filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)),
|
|
);
|
|
const compareTasksPreview = computed(() =>
|
|
selectedSourceTableNames.value.map((table) => {
|
|
const target = isBatchCompare.value ? table : targetTable.value || table;
|
|
const matched = !!target && targetTables.value.includes(target);
|
|
return {
|
|
sourceTable: table,
|
|
targetTable: target,
|
|
matched,
|
|
};
|
|
}),
|
|
);
|
|
const matchedTaskCount = computed(() => compareTasksPreview.value.filter((task) => task.matched).length);
|
|
const missingTargetTables = computed(() =>
|
|
compareTasksPreview.value.filter((task) => !task.matched).map((task) => task.targetTable || task.sourceTable),
|
|
);
|
|
const canCompare = computed(
|
|
() =>
|
|
sourceConnectionId.value &&
|
|
sourceDatabase.value &&
|
|
sourceSchema.value &&
|
|
selectedSourceTableNames.value.length > 0 &&
|
|
targetConnectionId.value &&
|
|
targetDatabase.value &&
|
|
targetSchema.value,
|
|
);
|
|
const keyColumns = computed(() =>
|
|
keyColumnsText.value
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean),
|
|
);
|
|
const detailPreviewLimitNumber = computed(() => Number(detailPreviewLimit.value) || PREVIEW_LIMIT_OPTIONS[1]);
|
|
|
|
const sameTableCount = computed(() => batchResults.value.filter((item) => item.status === "same").length);
|
|
const differentTableCount = computed(() => batchResults.value.filter((item) => item.status === "different").length);
|
|
const failedTableCount = computed(() => batchResults.value.filter((item) => item.status === "error").length);
|
|
const totalAdded = computed(() => batchResults.value.reduce((sum, item) => sum + item.added, 0));
|
|
const totalRemoved = computed(() => batchResults.value.reduce((sum, item) => sum + item.removed, 0));
|
|
const totalModified = computed(() => batchResults.value.reduce((sum, item) => sum + item.modified, 0));
|
|
const hasResults = computed(() => batchResults.value.length > 0);
|
|
const visibleKinds = computed(() => [
|
|
...(showAdded.value ? (["added"] as DiffKind[]) : []),
|
|
...(showRemoved.value ? (["removed"] as DiffKind[]) : []),
|
|
...(showModified.value ? (["modified"] as DiffKind[]) : []),
|
|
]);
|
|
const selectedAddedCount = computed(() => selectedDiffCount("added"));
|
|
const selectedRemovedCount = computed(() => selectedDiffCount("removed"));
|
|
const selectedModifiedCount = computed(() => selectedDiffCount("modified"));
|
|
const summary = computed(() => {
|
|
if (!hasResults.value) return "";
|
|
if (batchResults.value.length === 1 && batchResults.value[0]?.status !== "error") {
|
|
const item = batchResults.value[0];
|
|
return t("dataCompare.summary", {
|
|
added: item.added,
|
|
removed: item.removed,
|
|
modified: item.modified,
|
|
});
|
|
}
|
|
return t("dataCompare.batchSummary", {
|
|
tables: batchResults.value.length,
|
|
different: differentTableCount.value,
|
|
same: sameTableCount.value,
|
|
failed: failedTableCount.value,
|
|
added: totalAdded.value,
|
|
removed: totalRemoved.value,
|
|
modified: totalModified.value,
|
|
});
|
|
});
|
|
const selectedSummary = computed(() =>
|
|
t("dataCompare.selectedSummary", {
|
|
added: selectedAddedCount.value,
|
|
removed: selectedRemovedCount.value,
|
|
modified: selectedModifiedCount.value,
|
|
}),
|
|
);
|
|
const compareProgressLabel = computed(() => {
|
|
if (!comparing.value || compareProgressTotal.value === 0) return "";
|
|
return t("dataCompare.comparingTable", {
|
|
current: compareProgressCurrent.value,
|
|
total: compareProgressTotal.value,
|
|
table: compareProgressTable.value,
|
|
});
|
|
});
|
|
|
|
function emptySyncPlan(): DataCompareSyncPlan {
|
|
return {
|
|
insertCount: 0,
|
|
updateCount: 0,
|
|
deleteCount: 0,
|
|
statementCount: 0,
|
|
syncStatements: [],
|
|
syncSql: "",
|
|
};
|
|
}
|
|
|
|
function connectionIconType(connectionId: string) {
|
|
const config = store.getConfig(connectionId);
|
|
return config?.driver_profile || config?.db_type || "mysql";
|
|
}
|
|
|
|
function targetDatabaseType(): DatabaseType | undefined {
|
|
return store.getConfig(targetConnectionId.value)?.db_type;
|
|
}
|
|
|
|
function resetSelectedSourceTables(nextTables: Iterable<string>) {
|
|
selectedSourceTables.value = new Set(nextTables);
|
|
}
|
|
|
|
function toggleSourceTable(table: string) {
|
|
const next = new Set(selectedSourceTables.value);
|
|
if (next.has(table)) next.delete(table);
|
|
else next.add(table);
|
|
resetSelectedSourceTables(next);
|
|
}
|
|
|
|
function toggleSelectAllSourceTables() {
|
|
const next = new Set(selectedSourceTables.value);
|
|
if (allFilteredTablesSelected.value) {
|
|
filteredSourceTables.value.forEach((table) => next.delete(table));
|
|
} else {
|
|
filteredSourceTables.value.forEach((table) => next.add(table));
|
|
}
|
|
resetSelectedSourceTables(next);
|
|
}
|
|
|
|
function buildCompareTasks(): DataCompareTableTask[] {
|
|
if (!selectedSourceTableNames.value.length) return [];
|
|
if (!isBatchCompare.value) {
|
|
const table = selectedSourceTableNames.value[0];
|
|
return table ? [{ sourceTable: table, targetTable: targetTable.value || table }] : [];
|
|
}
|
|
return selectedSourceTableNames.value.map((table) => ({
|
|
sourceTable: table,
|
|
targetTable: table,
|
|
}));
|
|
}
|
|
|
|
function clearResult() {
|
|
batchResults.value = [];
|
|
syncPlan.value = emptySyncPlan();
|
|
syncErrors.value = [];
|
|
compareProgressCurrent.value = 0;
|
|
compareProgressTotal.value = 0;
|
|
compareProgressTable.value = "";
|
|
executedCount.value = 0;
|
|
executeTotal.value = 0;
|
|
syncPlanRequestId++;
|
|
}
|
|
|
|
function swapSourceTarget() {
|
|
const previousSelectedTables = [...selectedSourceTableNames.value];
|
|
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
|
|
const nextSourceSelection =
|
|
previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
|
|
|
|
const tmpConnId = sourceConnectionId.value;
|
|
const tmpDb = sourceDatabase.value;
|
|
const tmpDbs = sourceDatabases.value;
|
|
const tmpSchema = sourceSchema.value;
|
|
const tmpSchemas = sourceSchemas.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;
|
|
sourceTables.value = targetTables.value;
|
|
resetSelectedSourceTables(nextSourceSelection.filter((table) => targetTables.value.includes(table)));
|
|
|
|
targetConnectionId.value = tmpConnId;
|
|
targetDatabase.value = tmpDb;
|
|
targetDatabases.value = tmpDbs;
|
|
targetSchema.value = tmpSchema;
|
|
targetSchemas.value = tmpSchemas;
|
|
targetTables.value = tmpTables;
|
|
targetTable.value = nextSingleTarget;
|
|
|
|
sourceTable.value = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
|
clearResult();
|
|
}
|
|
|
|
async function resolveSchema(connectionId: string, database: string, preferredSchema = ""): Promise<string> {
|
|
const config = store.getConfig(connectionId);
|
|
if (isSchemaAware(config?.db_type)) {
|
|
const schemas = await api.listSchemas(connectionId, database);
|
|
if (preferredSchema && schemas.includes(preferredSchema)) return preferredSchema;
|
|
return schemas.includes("public") ? "public" : (schemas[0] ?? "");
|
|
}
|
|
return database;
|
|
}
|
|
|
|
async function loadSchemas(side: "source" | "target", preferredSchema = "") {
|
|
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
|
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
|
|
if (!connectionId || !database) return;
|
|
const config = store.getConfig(connectionId);
|
|
if (!isSchemaAware(config?.db_type)) {
|
|
if (side === "source") {
|
|
sourceSchemas.value = [];
|
|
sourceSchema.value = database;
|
|
} else {
|
|
targetSchemas.value = [];
|
|
targetSchema.value = database;
|
|
}
|
|
await loadTables(side);
|
|
return;
|
|
}
|
|
|
|
const schemas = await api.listSchemas(connectionId, database);
|
|
const schema =
|
|
preferredSchema && schemas.includes(preferredSchema)
|
|
? preferredSchema
|
|
: schemas.includes("public")
|
|
? "public"
|
|
: (schemas[0] ?? "");
|
|
if (side === "source") {
|
|
sourceSchemas.value = schemas;
|
|
sourceSchema.value = schema;
|
|
} else {
|
|
targetSchemas.value = schemas;
|
|
targetSchema.value = schema;
|
|
}
|
|
}
|
|
|
|
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
|
if (!connectionId) return;
|
|
await store.ensureConnected(connectionId);
|
|
const names = (await api.listDatabases(connectionId)).map((database) => database.name);
|
|
if (side === "source") {
|
|
sourceDatabases.value = names;
|
|
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
|
sourceSchemas.value = [];
|
|
sourceSchema.value = "";
|
|
sourceTables.value = [];
|
|
sourceTable.value = "";
|
|
resetSelectedSourceTables([]);
|
|
} else {
|
|
targetDatabases.value = names;
|
|
targetDatabase.value = names.length === 1 ? names[0] : "";
|
|
targetSchemas.value = [];
|
|
targetSchema.value = "";
|
|
targetTables.value = [];
|
|
targetTable.value = "";
|
|
}
|
|
}
|
|
|
|
async function loadTables(side: "source" | "target") {
|
|
const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value;
|
|
const database = side === "source" ? sourceDatabase.value : targetDatabase.value;
|
|
if (!connectionId || !database) return;
|
|
const schema =
|
|
side === "source"
|
|
? sourceSchema.value || (await resolveSchema(connectionId, database, props.prefillSchema))
|
|
: targetSchema.value || (await resolveSchema(connectionId, database));
|
|
const tables = (await api.listTables(connectionId, database, schema))
|
|
.filter((table) => table.table_type !== "VIEW")
|
|
.map((table) => table.name);
|
|
|
|
if (side === "source") {
|
|
const preferredSelection =
|
|
props.prefillTable && tables.includes(props.prefillTable)
|
|
? [props.prefillTable]
|
|
: [...selectedSourceTables.value].filter((table) => tables.includes(table));
|
|
sourceSchema.value = schema;
|
|
sourceTables.value = tables;
|
|
resetSelectedSourceTables(preferredSelection);
|
|
sourceTable.value = preferredSelection.length === 1 ? preferredSelection[0] : "";
|
|
} else {
|
|
targetSchema.value = schema;
|
|
targetTables.value = tables;
|
|
const singleSourceTable = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
|
const preferred =
|
|
targetTable.value && tables.includes(targetTable.value)
|
|
? targetTable.value
|
|
: singleSourceTable && tables.includes(singleSourceTable)
|
|
? singleSourceTable
|
|
: "";
|
|
targetTable.value = preferred;
|
|
}
|
|
}
|
|
|
|
async function loadColumnsWithCache(
|
|
cache: Map<string, CompareColumn[]>,
|
|
connectionId: string,
|
|
database: string,
|
|
schema: string,
|
|
table: string,
|
|
): Promise<CompareColumn[]> {
|
|
const key = `${connectionId}:${database}:${schema}:${table}`;
|
|
const cached = cache.get(key);
|
|
if (cached) return cached;
|
|
const columns = (await api.getColumns(connectionId, database, schema, table)) as CompareColumn[];
|
|
cache.set(key, columns);
|
|
return columns;
|
|
}
|
|
|
|
async function inferKeyColumnsForTable(
|
|
table: string,
|
|
sourceColumnCache?: Map<string, CompareColumn[]>,
|
|
): Promise<string[]> {
|
|
if (!sourceConnectionId.value || !sourceDatabase.value || !sourceSchema.value || !table) return [];
|
|
const columns = sourceColumnCache
|
|
? await loadColumnsWithCache(
|
|
sourceColumnCache,
|
|
sourceConnectionId.value,
|
|
sourceDatabase.value,
|
|
sourceSchema.value,
|
|
table,
|
|
)
|
|
: (((await api.getColumns(
|
|
sourceConnectionId.value,
|
|
sourceDatabase.value,
|
|
sourceSchema.value,
|
|
table,
|
|
)) as CompareColumn[]) ?? []);
|
|
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
|
if (primaryKeys.length > 0) return primaryKeys;
|
|
return columns.slice(0, 1).map((column) => column.name);
|
|
}
|
|
|
|
async function inferKeyColumns() {
|
|
const table = selectedSourceTableNames.value.length === 1 ? selectedSourceTableNames.value[0] : "";
|
|
if (!table) return;
|
|
const inferred = await inferKeyColumnsForTable(table);
|
|
keyColumnsText.value = inferred.join(", ");
|
|
}
|
|
|
|
function resultStatusLabel(status: DataCompareTableStatus): string {
|
|
if (status === "different") return t("dataCompare.statusDifferent");
|
|
if (status === "same") return t("dataCompare.statusSame");
|
|
return t("dataCompare.statusError");
|
|
}
|
|
|
|
function resultStatusClass(status: DataCompareTableStatus): string {
|
|
if (status === "different") return "bg-amber-500/15 text-amber-700";
|
|
if (status === "same") return "bg-emerald-500/15 text-emerald-700";
|
|
return "bg-destructive/15 text-destructive";
|
|
}
|
|
|
|
function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
|
|
return {
|
|
added: diff.added.map((row) => ({ ...row, selected: true })),
|
|
removed: diff.removed.map((row) => ({ ...row, selected: true })),
|
|
modified: diff.modified.map((row) => ({ ...row, selected: true })),
|
|
};
|
|
}
|
|
|
|
function buildSelectedDiff(table: DataCompareTableResult): DataCompareResult {
|
|
return {
|
|
added: table.diff.added.filter((row) => row.selected).map(stripSelectedRow),
|
|
removed: table.diff.removed.filter((row) => row.selected).map(stripSelectedRow),
|
|
modified: table.diff.modified.filter((row) => row.selected).map(stripSelectedModifiedRow),
|
|
};
|
|
}
|
|
|
|
function stripSelectedRow(row: SelectableDataCompareRow): DataCompareRow {
|
|
const { selected: _selected, ...rest } = row;
|
|
return rest;
|
|
}
|
|
|
|
function stripSelectedModifiedRow(row: SelectableDataCompareModifiedRow): DataCompareModifiedRow {
|
|
const { selected: _selected, ...rest } = row;
|
|
return rest;
|
|
}
|
|
|
|
function hasDiffRows(table: DataCompareTableResult, kind: DiffKind): boolean {
|
|
return table.diff[kind].length > 0;
|
|
}
|
|
|
|
function selectedRows(table: DataCompareTableResult, kind: DiffKind): number {
|
|
return table.diff[kind].filter((row) => row.selected).length;
|
|
}
|
|
|
|
function rowsForDisplay(table: DataCompareTableResult, kind: DiffKind) {
|
|
const rows = table.diff[kind];
|
|
return table.showAll[kind] ? rows : rows.slice(0, detailPreviewLimitNumber.value);
|
|
}
|
|
|
|
function remainingRows(table: DataCompareTableResult, kind: DiffKind) {
|
|
return Math.max(0, table.diff[kind].length - rowsForDisplay(table, kind).length);
|
|
}
|
|
|
|
function toggleTableExpanded(table: DataCompareTableResult) {
|
|
table.expanded = !table.expanded;
|
|
}
|
|
|
|
function toggleShowAll(table: DataCompareTableResult, kind: DiffKind) {
|
|
table.showAll[kind] = !table.showAll[kind];
|
|
}
|
|
|
|
function setDiffSelection(kind: DiffKind, selected: boolean) {
|
|
batchResults.value.forEach((table) => {
|
|
table.diff[kind].forEach((row) => {
|
|
row.selected = selected;
|
|
});
|
|
});
|
|
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
|
|
}
|
|
|
|
function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, selected: boolean) {
|
|
table.diff[kind].forEach((row) => {
|
|
row.selected = selected;
|
|
});
|
|
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
|
|
}
|
|
|
|
function clearAllSelections() {
|
|
(["added", "removed", "modified"] as DiffKind[]).forEach((kind) => {
|
|
batchResults.value.forEach((table) => {
|
|
table.diff[kind].forEach((row) => {
|
|
row.selected = false;
|
|
});
|
|
});
|
|
});
|
|
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
|
|
}
|
|
|
|
function toggleRowSelection(row: SelectableDataCompareRow | SelectableDataCompareModifiedRow) {
|
|
row.selected = !row.selected;
|
|
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
|
|
}
|
|
|
|
function selectedDiffCount(kind: DiffKind) {
|
|
return batchResults.value.reduce((sum, table) => sum + selectedRows(table, kind), 0);
|
|
}
|
|
|
|
function buildSyncPlanTables(): DataCompareSyncPlanTableOptions[] {
|
|
return batchResults.value
|
|
.filter((table) => table.status === "different")
|
|
.map((table) => ({
|
|
tableName: table.targetTable,
|
|
schema: targetSchema.value,
|
|
columns: table.columns,
|
|
keyColumns: table.keyColumns,
|
|
columnInfo: table.columnInfo,
|
|
diff: buildSelectedDiff(table),
|
|
databaseType: table.databaseType,
|
|
preSyncStatements: table.preSyncStatements ?? [],
|
|
}))
|
|
.filter(
|
|
(table) =>
|
|
table.preSyncStatements.length > 0 ||
|
|
table.diff.added.length > 0 ||
|
|
table.diff.removed.length > 0 ||
|
|
table.diff.modified.length > 0,
|
|
);
|
|
}
|
|
|
|
async function rebuildSyncPlan() {
|
|
const requestId = ++syncPlanRequestId;
|
|
const tables = buildSyncPlanTables();
|
|
if (tables.length === 0) {
|
|
syncPlan.value = emptySyncPlan();
|
|
planningSync.value = false;
|
|
return;
|
|
}
|
|
planningSync.value = true;
|
|
try {
|
|
const plan = await api.buildDataCompareSyncPlan({ tables });
|
|
if (requestId !== syncPlanRequestId) return;
|
|
syncPlan.value = plan;
|
|
} catch (e: any) {
|
|
if (requestId !== syncPlanRequestId) return;
|
|
syncPlan.value = emptySyncPlan();
|
|
toast(e?.message || String(e), 5000);
|
|
} finally {
|
|
if (requestId === syncPlanRequestId) planningSync.value = false;
|
|
}
|
|
}
|
|
|
|
async function startCompare() {
|
|
if (!canCompare.value || comparing.value) return;
|
|
const tasks = buildCompareTasks();
|
|
if (tasks.length === 0) {
|
|
toast(t("dataCompare.noComparableTables"), 5000);
|
|
return;
|
|
}
|
|
|
|
comparing.value = true;
|
|
clearResult();
|
|
compareProgressTotal.value = tasks.length;
|
|
|
|
const sourceColumnCache = new Map<string, CompareColumn[]>();
|
|
const targetColumnCache = new Map<string, CompareColumn[]>();
|
|
const results: DataCompareTableResult[] = [];
|
|
const currentTargetDatabaseType = targetDatabaseType();
|
|
|
|
try {
|
|
await Promise.all([
|
|
store.ensureConnected(sourceConnectionId.value),
|
|
store.ensureConnected(targetConnectionId.value),
|
|
]);
|
|
|
|
for (const [index, task] of tasks.entries()) {
|
|
compareProgressCurrent.value = index + 1;
|
|
compareProgressTable.value = task.sourceTable;
|
|
|
|
try {
|
|
if (!targetTables.value.includes(task.targetTable)) {
|
|
const sourceColumns = await loadColumnsWithCache(
|
|
sourceColumnCache,
|
|
sourceConnectionId.value,
|
|
sourceDatabase.value,
|
|
sourceSchema.value,
|
|
task.sourceTable,
|
|
);
|
|
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : [];
|
|
const preparation = await api.prepareDataCompareMissingTarget({
|
|
sourceConnectionId: sourceConnectionId.value,
|
|
sourceDatabase: sourceDatabase.value,
|
|
sourceSchema: sourceSchema.value,
|
|
sourceTable: task.sourceTable,
|
|
targetConnectionId: targetConnectionId.value,
|
|
targetDatabase: targetDatabase.value,
|
|
targetSchema: targetSchema.value,
|
|
targetTable: task.targetTable,
|
|
keyColumns: resolvedKeys,
|
|
});
|
|
results.push({
|
|
sourceTable: task.sourceTable,
|
|
targetTable: task.targetTable,
|
|
keyColumns: resolvedKeys,
|
|
columns: sourceColumns.map((column) => column.name),
|
|
columnInfo: sourceColumns,
|
|
status: "different",
|
|
added: preparation.result.added.length,
|
|
removed: 0,
|
|
modified: 0,
|
|
sourceRowCount: preparation.sourceRowCount,
|
|
targetRowCount: 0,
|
|
sourceTruncated: preparation.sourceTruncated,
|
|
targetTruncated: false,
|
|
databaseType: currentTargetDatabaseType,
|
|
preSyncStatements: preparation.preSyncStatements,
|
|
diff: toSelectableDiff(preparation.result),
|
|
expanded: preparation.result.added.length > 0,
|
|
showAll: {
|
|
added: false,
|
|
removed: false,
|
|
modified: false,
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const resolvedKeys =
|
|
keyColumns.value.length > 0
|
|
? keyColumns.value
|
|
: await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
|
|
if (resolvedKeys.length === 0) {
|
|
throw new Error(t("dataCompare.noKeyColumns"));
|
|
}
|
|
|
|
const sourceColumns = await loadColumnsWithCache(
|
|
sourceColumnCache,
|
|
sourceConnectionId.value,
|
|
sourceDatabase.value,
|
|
sourceSchema.value,
|
|
task.sourceTable,
|
|
);
|
|
const targetColumns = await loadColumnsWithCache(
|
|
targetColumnCache,
|
|
targetConnectionId.value,
|
|
targetDatabase.value,
|
|
targetSchema.value,
|
|
task.targetTable,
|
|
);
|
|
const columns = sourceColumns
|
|
.map((column) => column.name)
|
|
.filter((column) => targetColumns.some((target) => target.name === column));
|
|
const columnInfo = columns
|
|
.map((column) => targetColumns.find((target) => target.name === column))
|
|
.filter((column): column is CompareColumn => !!column);
|
|
const missingKeys = resolvedKeys.filter((column) => !columns.includes(column));
|
|
if (missingKeys.length > 0) {
|
|
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
|
|
}
|
|
if (columns.length === 0) {
|
|
throw new Error(t("dataCompare.noCommonColumns"));
|
|
}
|
|
|
|
const preparation = await api.prepareDataCompareFromTables({
|
|
sourceConnectionId: sourceConnectionId.value,
|
|
sourceDatabase: sourceDatabase.value,
|
|
sourceSchema: sourceSchema.value,
|
|
sourceTable: task.sourceTable,
|
|
targetConnectionId: targetConnectionId.value,
|
|
targetDatabase: targetDatabase.value,
|
|
targetSchema: targetSchema.value,
|
|
targetTable: task.targetTable,
|
|
columns,
|
|
keyColumns: resolvedKeys,
|
|
});
|
|
|
|
const added = preparation.result.added.length;
|
|
const removed = preparation.result.removed.length;
|
|
const modified = preparation.result.modified.length;
|
|
const status: DataCompareTableStatus = added || removed || modified ? "different" : "same";
|
|
|
|
results.push({
|
|
sourceTable: task.sourceTable,
|
|
targetTable: task.targetTable,
|
|
keyColumns: resolvedKeys,
|
|
columns,
|
|
columnInfo,
|
|
status,
|
|
added,
|
|
removed,
|
|
modified,
|
|
sourceRowCount: preparation.sourceRowCount,
|
|
targetRowCount: preparation.targetRowCount,
|
|
sourceTruncated: preparation.sourceTruncated,
|
|
targetTruncated: preparation.targetTruncated,
|
|
databaseType: currentTargetDatabaseType,
|
|
diff: toSelectableDiff(preparation.result),
|
|
expanded: status === "different",
|
|
showAll: {
|
|
added: false,
|
|
removed: false,
|
|
modified: false,
|
|
},
|
|
});
|
|
} catch (e: any) {
|
|
results.push({
|
|
sourceTable: task.sourceTable,
|
|
targetTable: task.targetTable,
|
|
keyColumns: keyColumns.value,
|
|
columns: [],
|
|
columnInfo: [],
|
|
status: "error",
|
|
added: 0,
|
|
removed: 0,
|
|
modified: 0,
|
|
sourceRowCount: 0,
|
|
targetRowCount: 0,
|
|
sourceTruncated: false,
|
|
targetTruncated: false,
|
|
databaseType: currentTargetDatabaseType,
|
|
preSyncStatements: [],
|
|
diff: { added: [], removed: [], modified: [] },
|
|
expanded: false,
|
|
showAll: {
|
|
added: false,
|
|
removed: false,
|
|
modified: false,
|
|
},
|
|
error: e?.message || String(e),
|
|
});
|
|
}
|
|
}
|
|
|
|
batchResults.value = results;
|
|
await rebuildSyncPlan();
|
|
} catch (e: any) {
|
|
toast(e?.message || String(e), 5000);
|
|
} finally {
|
|
comparing.value = false;
|
|
compareProgressCurrent.value = 0;
|
|
compareProgressTotal.value = 0;
|
|
compareProgressTable.value = "";
|
|
}
|
|
}
|
|
|
|
async function copySql() {
|
|
try {
|
|
await copyToClipboard(syncPlan.value.syncSql);
|
|
toast(t("grid.copied"));
|
|
} catch (e: any) {
|
|
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
|
}
|
|
}
|
|
|
|
async function executeSql() {
|
|
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
|
|
executing.value = true;
|
|
syncErrors.value = [];
|
|
executeTotal.value = syncPlan.value.syncStatements.length;
|
|
executedCount.value = 0;
|
|
try {
|
|
await store.ensureConnected(targetConnectionId.value);
|
|
for (const stmt of syncPlan.value.syncStatements) {
|
|
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: syncPlan.value.syncStatements.length - failed, failed }), 5000);
|
|
}
|
|
} catch (e: any) {
|
|
toast(e?.message || String(e), 5000);
|
|
} finally {
|
|
executing.value = false;
|
|
}
|
|
}
|
|
|
|
function formatValue(value: DataCompareCellValue): string {
|
|
if (value == null) return "NULL";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function truncateText(text: string, limit = 160): string {
|
|
return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
|
|
}
|
|
|
|
function formatKeyValues(values: Record<string, DataCompareCellValue>): string {
|
|
return Object.entries(values)
|
|
.map(([column, value]) => `${column}=${formatValue(value)}`)
|
|
.join(", ");
|
|
}
|
|
|
|
function formatRowValues(values: Record<string, DataCompareCellValue>): string {
|
|
return truncateText(
|
|
Object.entries(values)
|
|
.map(([column, value]) => `${column}=${formatValue(value)}`)
|
|
.join(", "),
|
|
);
|
|
}
|
|
|
|
function formatModifiedSummary(row: SelectableDataCompareModifiedRow): string {
|
|
return truncateText(
|
|
row.changes
|
|
.map((change) => `${change.column}: ${formatValue(change.target)} -> ${formatValue(change.source)}`)
|
|
.join(", "),
|
|
220,
|
|
);
|
|
}
|
|
|
|
watch(sourceConnectionId, (id) => {
|
|
clearResult();
|
|
sourceDatabase.value = "";
|
|
sourceSchema.value = "";
|
|
sourceSchemas.value = [];
|
|
sourceTables.value = [];
|
|
sourceTable.value = "";
|
|
sourceTableSearch.value = "";
|
|
resetSelectedSourceTables([]);
|
|
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(targetConnectionId, (id) => {
|
|
clearResult();
|
|
targetDatabase.value = "";
|
|
targetSchema.value = "";
|
|
targetSchemas.value = [];
|
|
targetTables.value = [];
|
|
targetTable.value = "";
|
|
loadDatabases(id, "target").catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(sourceDatabase, () => {
|
|
clearResult();
|
|
sourceSchema.value = "";
|
|
sourceSchemas.value = [];
|
|
sourceTables.value = [];
|
|
sourceTable.value = "";
|
|
sourceTableSearch.value = "";
|
|
resetSelectedSourceTables([]);
|
|
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(targetDatabase, () => {
|
|
clearResult();
|
|
targetSchema.value = "";
|
|
targetSchemas.value = [];
|
|
targetTables.value = [];
|
|
targetTable.value = "";
|
|
loadSchemas("target").catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(sourceSchema, () => {
|
|
clearResult();
|
|
sourceTables.value = [];
|
|
sourceTable.value = "";
|
|
sourceTableSearch.value = "";
|
|
resetSelectedSourceTables([]);
|
|
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(targetSchema, () => {
|
|
clearResult();
|
|
targetTables.value = [];
|
|
targetTable.value = "";
|
|
if (targetSchema.value) loadTables("target").catch((e) => toast(String(e), 5000));
|
|
});
|
|
watch(selectedSourceTableNames, (tables, previous) => {
|
|
clearResult();
|
|
sourceTable.value = tables.length === 1 ? tables[0] : "";
|
|
if (tables.length !== 1) {
|
|
keyColumnsText.value = "";
|
|
return;
|
|
}
|
|
const table = tables[0];
|
|
if (targetTables.value.includes(table)) {
|
|
targetTable.value = table;
|
|
} else if (previous?.length === 1 && targetTable.value === previous[0]) {
|
|
targetTable.value = "";
|
|
}
|
|
if (table !== previous?.[0]) {
|
|
keyColumnsText.value = "";
|
|
}
|
|
inferKeyColumns().catch(() => {});
|
|
});
|
|
watch(targetTable, () => clearResult());
|
|
watch(
|
|
open,
|
|
async (value) => {
|
|
if (!value) return;
|
|
clearResult();
|
|
if (props.prefillConnectionId) {
|
|
sourceConnectionId.value = props.prefillConnectionId;
|
|
await loadDatabases(props.prefillConnectionId, "source");
|
|
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
|
|
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
|
|
if (props.prefillTable) {
|
|
await loadTables("source");
|
|
if (sourceTables.value.includes(props.prefillTable)) {
|
|
resetSelectedSourceTables([props.prefillTable]);
|
|
sourceTable.value = props.prefillTable;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogContent class="sm:max-w-5xl max-h-[85vh] flex flex-col overflow-hidden" @interact-outside.prevent>
|
|
<DialogHeader>
|
|
<DialogTitle class="flex items-center gap-2">
|
|
<GitCompareArrows class="w-4 h-4" />
|
|
{{ t("dataCompare.title") }}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
|
<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
|
|
:model-value="sourceConnectionId"
|
|
@update:model-value="(v: any) => (sourceConnectionId = String(v))"
|
|
>
|
|
<SelectTrigger class="h-8 text-xs">
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon
|
|
v-if="sourceConnectionId"
|
|
:db-type="connectionIconType(sourceConnectionId)"
|
|
class="w-3.5 h-3.5"
|
|
/>
|
|
<SelectValue :placeholder="t('diff.selectConnection')" />
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
|
|
{{ connection.name }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => (sourceDatabase = String(v))">
|
|
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectDatabase')" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="database in sourceDatabases" :key="database" :value="database">{{
|
|
database
|
|
}}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
v-if="sourceSchemas.length"
|
|
:model-value="sourceSchema"
|
|
@update:model-value="(v: any) => (sourceSchema = String(v))"
|
|
>
|
|
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div class="space-y-2 rounded-lg border p-2">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<Label class="text-xs font-medium">{{ t("dataCompare.sourceTables") }}</Label>
|
|
<div v-if="sourceTables.length" class="text-[11px] text-muted-foreground">
|
|
{{
|
|
t("dataCompare.selectedTables", {
|
|
selected: selectedSourceTableNames.length,
|
|
total: sourceTables.length,
|
|
})
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<Input
|
|
v-if="sourceTables.length > 5"
|
|
v-model="sourceTableSearch"
|
|
class="h-7 text-xs"
|
|
:placeholder="t('dataCompare.searchTables')"
|
|
/>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
v-if="sourceTables.length"
|
|
variant="outline"
|
|
size="sm"
|
|
class="h-7 px-2 text-xs"
|
|
@click="toggleSelectAllSourceTables"
|
|
>
|
|
{{
|
|
allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables")
|
|
}}
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-3 text-center">
|
|
{{ t("dataCompare.selectSourceTables") }}
|
|
</div>
|
|
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-3 text-center">
|
|
{{ t("dataCompare.noTables") }}
|
|
</div>
|
|
<div v-else class="max-h-40 overflow-auto rounded border">
|
|
<button
|
|
v-for="table in filteredSourceTables"
|
|
:key="table"
|
|
type="button"
|
|
class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50"
|
|
@click="toggleSourceTable(table)"
|
|
>
|
|
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
|
|
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
|
<span class="truncate">{{ table }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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
|
|
:model-value="targetConnectionId"
|
|
@update:model-value="(v: any) => (targetConnectionId = String(v))"
|
|
>
|
|
<SelectTrigger class="h-8 text-xs">
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon
|
|
v-if="targetConnectionId"
|
|
:db-type="connectionIconType(targetConnectionId)"
|
|
class="w-3.5 h-3.5"
|
|
/>
|
|
<SelectValue :placeholder="t('diff.selectConnection')" />
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">
|
|
{{ connection.name }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select :model-value="targetDatabase" @update:model-value="(v: any) => (targetDatabase = String(v))">
|
|
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectDatabase')" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="database in targetDatabases" :key="database" :value="database">{{
|
|
database
|
|
}}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
v-if="targetSchemas.length"
|
|
:model-value="targetSchema"
|
|
@update:model-value="(v: any) => (targetSchema = String(v))"
|
|
>
|
|
<SelectTrigger class="h-8 text-xs"><SelectValue :placeholder="t('diff.selectSchema')" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div v-if="!isBatchCompare" class="space-y-1">
|
|
<Label class="text-xs font-medium">{{ t("dataCompare.targetTable") }}</Label>
|
|
<Select :model-value="targetTable" @update:model-value="(v: any) => (targetTable = String(v))">
|
|
<SelectTrigger class="h-8 text-xs">
|
|
<SelectValue :placeholder="t('dataCompare.selectTable')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="table in targetTables" :key="table" :value="table">{{ table }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div v-else class="space-y-2 rounded-lg border p-3 text-xs">
|
|
<div class="font-medium">{{ t("dataCompare.autoMatchHint") }}</div>
|
|
<div class="text-muted-foreground">
|
|
{{
|
|
t("dataCompare.matchedTables", { matched: matchedTaskCount, total: selectedSourceTableNames.length })
|
|
}}
|
|
</div>
|
|
<div v-if="missingTargetTables.length" class="text-destructive">
|
|
{{ t("dataCompare.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
|
|
</div>
|
|
<div v-if="compareTasksPreview.length" class="max-h-36 overflow-auto rounded border bg-muted/20">
|
|
<div
|
|
v-for="task in compareTasksPreview"
|
|
:key="`${task.sourceTable}:${task.targetTable}`"
|
|
class="flex items-center justify-between gap-2 border-b px-2 py-1 last:border-b-0"
|
|
>
|
|
<span class="truncate font-mono">{{ task.sourceTable }}</span>
|
|
<span class="text-muted-foreground">→</span>
|
|
<span class="truncate font-mono" :class="task.matched ? '' : 'text-destructive'">
|
|
{{ task.targetTable || t("dataCompare.targetTableMissing", { table: task.sourceTable }) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
|
|
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" />
|
|
<div class="text-[11px] text-muted-foreground">
|
|
{{ t("dataCompare.keyColumnsAutoHint") }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="hasResults" class="space-y-3">
|
|
<div class="rounded-lg border p-3 text-sm space-y-2">
|
|
<div>{{ summary }}</div>
|
|
<div class="text-xs text-muted-foreground">{{ selectedSummary }}</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-3 space-y-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-7 text-xs"
|
|
:class="showAdded ? 'border-primary' : ''"
|
|
@click="showAdded = !showAdded"
|
|
>
|
|
{{ t("diff.added") }} · {{ totalAdded }}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-7 text-xs"
|
|
:class="showRemoved ? 'border-primary' : ''"
|
|
@click="showRemoved = !showRemoved"
|
|
>
|
|
{{ t("diff.removed") }} · {{ totalRemoved }}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-7 text-xs"
|
|
:class="showModified ? 'border-primary' : ''"
|
|
@click="showModified = !showModified"
|
|
>
|
|
{{ t("diff.modified") }} · {{ totalModified }}
|
|
</Button>
|
|
<span class="flex-1" />
|
|
<Select v-model="detailPreviewLimit">
|
|
<SelectTrigger class="h-7 w-32 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="limit in PREVIEW_LIMIT_OPTIONS" :key="limit" :value="String(limit)">
|
|
{{ t("dataCompare.previewLimitOption", { count: limit }) }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('added', true)">
|
|
{{ t("dataCompare.selectAllKind", { kind: t("diff.added") }) }}
|
|
</Button>
|
|
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('removed', true)">
|
|
{{ t("dataCompare.selectAllKind", { kind: t("diff.removed") }) }}
|
|
</Button>
|
|
<Button size="sm" variant="outline" class="h-7 text-xs" @click="setDiffSelection('modified', true)">
|
|
{{ t("dataCompare.selectAllKind", { kind: t("diff.modified") }) }}
|
|
</Button>
|
|
<Button size="sm" variant="outline" class="h-7 text-xs" @click="clearAllSelections">
|
|
{{ t("dataCompare.clearSelection") }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border overflow-hidden">
|
|
<div class="max-h-64 overflow-auto">
|
|
<table class="w-full text-xs">
|
|
<thead class="bg-muted sticky top-0 z-10">
|
|
<tr>
|
|
<th class="px-3 py-2 text-left font-medium">{{ t("diff.table") }}</th>
|
|
<th class="px-3 py-2 text-left font-medium">{{ t("dataCompare.targetTable") }}</th>
|
|
<th class="px-3 py-2 text-left font-medium">{{ t("diff.status") }}</th>
|
|
<th class="px-3 py-2 text-left font-medium">{{ t("diff.details") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in batchResults" :key="`${item.sourceTable}:${item.targetTable}`" class="border-t">
|
|
<td class="px-3 py-2 align-top font-mono">{{ item.sourceTable }}</td>
|
|
<td class="px-3 py-2 align-top font-mono text-muted-foreground">{{ item.targetTable }}</td>
|
|
<td class="px-3 py-2 align-top">
|
|
<span class="inline-flex rounded px-2 py-0.5 text-[11px]" :class="resultStatusClass(item.status)">
|
|
{{ resultStatusLabel(item.status) }}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-2 align-top text-muted-foreground">
|
|
<div v-if="item.status === 'error'" class="text-destructive">{{ item.error }}</div>
|
|
<template v-else>
|
|
<div>
|
|
{{
|
|
t("dataCompare.summary", {
|
|
added: item.added,
|
|
removed: item.removed,
|
|
modified: item.modified,
|
|
})
|
|
}}
|
|
</div>
|
|
<div class="mt-1">
|
|
{{
|
|
t("dataCompare.rowCounts", {
|
|
source: item.sourceRowCount,
|
|
target: item.targetRowCount,
|
|
})
|
|
}}
|
|
</div>
|
|
<div class="mt-1">
|
|
{{ t("dataCompare.keyColumnsInline", { columns: item.keyColumns.join(", ") }) }}
|
|
</div>
|
|
<div v-if="item.status === 'different'" class="mt-1">
|
|
{{
|
|
t("dataCompare.selectedInline", {
|
|
selected:
|
|
selectedRows(item, "added") +
|
|
selectedRows(item, "removed") +
|
|
selectedRows(item, "modified"),
|
|
total: item.added + item.removed + item.modified,
|
|
})
|
|
}}
|
|
</div>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="item in batchResults.filter((entry) => entry.status === 'different')"
|
|
:key="`details-${item.sourceTable}:${item.targetTable}`"
|
|
class="rounded-lg border overflow-hidden"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 border-b bg-muted/30 px-3 py-2 text-left text-sm font-medium"
|
|
@click="toggleTableExpanded(item)"
|
|
>
|
|
<ChevronDown v-if="item.expanded" class="h-4 w-4 shrink-0" />
|
|
<ChevronRight v-else class="h-4 w-4 shrink-0" />
|
|
<span class="font-mono">{{ item.sourceTable }}</span>
|
|
<span class="text-muted-foreground">→</span>
|
|
<span class="font-mono text-muted-foreground">{{ item.targetTable }}</span>
|
|
</button>
|
|
|
|
<div v-if="item.expanded" class="space-y-3 p-3">
|
|
<div
|
|
v-for="kind in visibleKinds"
|
|
:key="`${item.sourceTable}:${kind}`"
|
|
class="rounded-lg border"
|
|
v-show="hasDiffRows(item, kind)"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-2 text-xs">
|
|
<span class="font-medium">{{ t(`diff.${kind}`) }}</span>
|
|
<span class="text-muted-foreground"
|
|
>{{ selectedRows(item, kind) }}/{{ item.diff[kind].length }}</span
|
|
>
|
|
<span class="flex-1" />
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
class="h-6 px-2 text-xs"
|
|
@click="setTableDiffSelection(item, kind, true)"
|
|
>
|
|
{{ t("dataCompare.selectAllKind", { kind: t(`diff.${kind}`) }) }}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
class="h-6 px-2 text-xs"
|
|
@click="setTableDiffSelection(item, kind, false)"
|
|
>
|
|
{{ t("dataCompare.clearKind", { kind: t(`diff.${kind}`) }) }}
|
|
</Button>
|
|
<Button
|
|
v-if="item.diff[kind].length > detailPreviewLimitNumber"
|
|
size="sm"
|
|
variant="ghost"
|
|
class="h-6 px-2 text-xs"
|
|
@click="toggleShowAll(item, kind)"
|
|
>
|
|
{{
|
|
item.showAll[kind]
|
|
? t("dataCompare.showLessRows")
|
|
: t("dataCompare.showAllRows", { count: item.diff[kind].length })
|
|
}}
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="max-h-72 overflow-auto divide-y">
|
|
<button
|
|
v-for="row in rowsForDisplay(item, kind)"
|
|
:key="`${item.sourceTable}:${kind}:${row.key}`"
|
|
type="button"
|
|
class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40"
|
|
@click="toggleRowSelection(row)"
|
|
>
|
|
<CheckSquare v-if="row.selected" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
|
|
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
|
|
<div class="mt-1 text-muted-foreground break-words">
|
|
{{
|
|
kind === "modified"
|
|
? formatModifiedSummary(row as SelectableDataCompareModifiedRow)
|
|
: formatRowValues((row as SelectableDataCompareRow).values)
|
|
}}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]"
|
|
class="border-t px-3 py-2 text-xs text-muted-foreground"
|
|
>
|
|
{{ t("dataCompare.remainingRows", { count: remainingRows(item, kind) }) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="planningSync" class="text-sm text-muted-foreground">
|
|
{{ t("dataCompare.planningSync") }}
|
|
</div>
|
|
<div v-else-if="syncPlan.syncSql.trim()" class="space-y-1">
|
|
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
|
|
<textarea
|
|
:value="syncPlan.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>
|
|
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
|
|
{{ t("dataCompare.noDifferences") }}
|
|
</div>
|
|
<div v-else class="text-sm text-muted-foreground">
|
|
{{ t("dataCompare.noSelectedDifferences") }}
|
|
</div>
|
|
</div>
|
|
|
|
<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="!hasResults">
|
|
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
|
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{
|
|
compareProgressLabel
|
|
}}</span>
|
|
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
|
|
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
|
|
<GitCompareArrows v-else class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("dataCompare.compare") }}
|
|
</Button>
|
|
</DialogFooter>
|
|
|
|
<DialogFooter v-else class="flex items-center gap-2">
|
|
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
|
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
|
|
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
|
|
</span>
|
|
<span v-else-if="planningSync" class="text-xs text-muted-foreground mr-auto">
|
|
{{ t("dataCompare.planningSync") }}
|
|
</span>
|
|
<span v-else class="text-xs text-muted-foreground mr-auto">
|
|
{{
|
|
t("dataCompare.planSummary", {
|
|
inserts: syncPlan.insertCount,
|
|
updates: syncPlan.updateCount,
|
|
deletes: syncPlan.deleteCount,
|
|
statements: syncPlan.statementCount,
|
|
})
|
|
}}
|
|
</span>
|
|
<Button variant="outline" size="sm" :disabled="!syncPlan.syncSql.trim()" @click="copySql">
|
|
<Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }}
|
|
</Button>
|
|
<Button size="sm" :disabled="planningSync || executing || syncPlan.statementCount === 0" @click="executeSql">
|
|
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
|
<Play v-else class="w-3 h-3 mr-1" />
|
|
{{ t("diff.executeSync") }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|