mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-26 00:00:51 +00:00
458 lines
18 KiB
Vue
458 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|
import * as api from "@/lib/api";
|
|
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
|
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
|
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
|
|
|
const { t } = useI18n();
|
|
const store = useConnectionStore();
|
|
|
|
const props = defineProps<{
|
|
configs: SchemaDiffConfig[];
|
|
activeConfigId: string;
|
|
sourceConnectionId: string;
|
|
sourceDatabase: string;
|
|
sourceSchema: string;
|
|
targetConnectionId: string;
|
|
targetDatabase: string;
|
|
targetSchema: string;
|
|
ignoreComments: boolean;
|
|
options: SchemaDiffCompareOptions;
|
|
loading: boolean;
|
|
recentConfigs: SchemaDiffConfig[];
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "update:sourceConnectionId", value: string): void;
|
|
(e: "update:sourceDatabase", value: string): void;
|
|
(e: "update:sourceSchema", value: string): void;
|
|
(e: "update:targetConnectionId", value: string): void;
|
|
(e: "update:targetDatabase", value: string): void;
|
|
(e: "update:targetSchema", value: string): void;
|
|
(e: "update:ignoreComments", value: boolean): void;
|
|
(e: "compare"): void;
|
|
(e: "saveConfig"): void;
|
|
(e: "loadConfig"): void;
|
|
(e: "showOptions"): void;
|
|
(e: "swap"): void;
|
|
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
|
|
(e: "deleteHistoryConfig", configId: string): void;
|
|
}>();
|
|
|
|
const sourceDatabases = ref<string[]>([]);
|
|
const sourceSchemas = ref<string[]>([]);
|
|
const targetDatabases = ref<string[]>([]);
|
|
const targetSchemas = ref<string[]>([]);
|
|
const sourceDbVersion = ref<string | null>(null);
|
|
const targetDbVersion = ref<string | null>(null);
|
|
|
|
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "etcd", "mq", "nacos"].includes(c.db_type)));
|
|
|
|
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
|
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
|
|
|
const canCompare = computed(() => {
|
|
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
|
|
});
|
|
|
|
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
|
if (!connectionId) return;
|
|
try {
|
|
await store.ensureConnected(connectionId);
|
|
const dbs = await api.listDatabases(connectionId);
|
|
const dbNames = Array.isArray(dbs) ? dbs.map((db: any) => (typeof db === "string" ? db : db.name || db.database)) : [];
|
|
if (side === "source") {
|
|
sourceDatabases.value = dbNames;
|
|
if (props.sourceDatabase) {
|
|
await fetchDbVersion(connectionId, props.sourceDatabase, props.sourceSchema, "source");
|
|
// Ensure schema list is loaded after databases are available (handles race with sourceDatabase watcher)
|
|
if (isSchemaAware(sourceConfig.value?.db_type)) {
|
|
await loadSchemas("source");
|
|
}
|
|
}
|
|
} else {
|
|
targetDatabases.value = dbNames;
|
|
if (props.targetDatabase) {
|
|
await fetchDbVersion(connectionId, props.targetDatabase, props.targetSchema, "target");
|
|
// Ensure schema list is loaded after databases are available (handles race with targetDatabase watcher)
|
|
if (isSchemaAware(targetConfig.value?.db_type)) {
|
|
await loadSchemas("target");
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
if (side === "source") {
|
|
sourceDatabases.value = [];
|
|
sourceDbVersion.value = null;
|
|
} else {
|
|
targetDatabases.value = [];
|
|
targetDbVersion.value = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadSchemas(side: "source" | "target") {
|
|
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
|
|
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
|
|
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
|
|
if (!connectionId || !database) return;
|
|
|
|
try {
|
|
await store.ensureConnected(connectionId);
|
|
const schemas = await api.listSchemas(connectionId, database);
|
|
if (side === "source") {
|
|
sourceSchemas.value = schemas;
|
|
} else {
|
|
targetSchemas.value = schemas;
|
|
}
|
|
await fetchDbVersion(connectionId, database, schema, side);
|
|
} catch {
|
|
if (side === "source") {
|
|
sourceSchemas.value = [];
|
|
} else {
|
|
targetSchemas.value = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.sourceConnectionId,
|
|
async (id) => {
|
|
if (id) {
|
|
await loadDatabases(id, "source");
|
|
} else {
|
|
sourceDatabases.value = [];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => props.sourceDatabase,
|
|
async (db) => {
|
|
if (db && props.sourceConnectionId) {
|
|
await loadSchemas("source");
|
|
} else {
|
|
sourceSchemas.value = [];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => props.targetConnectionId,
|
|
async (id) => {
|
|
if (id) {
|
|
await loadDatabases(id, "target");
|
|
} else {
|
|
targetDatabases.value = [];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => props.targetDatabase,
|
|
async (db) => {
|
|
if (db && props.targetConnectionId) {
|
|
await loadSchemas("target");
|
|
} else {
|
|
targetSchemas.value = [];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
function connectionIconType(connectionId: string) {
|
|
const c = store.getConfig(connectionId);
|
|
return c?.driver_profile || c?.db_type || "mysql";
|
|
}
|
|
|
|
function getConnectionInfo(connectionId: string) {
|
|
const c = store.getConfig(connectionId);
|
|
if (!c) return null;
|
|
return {
|
|
name: c.name,
|
|
dbType: c.db_type,
|
|
host: c.host,
|
|
port: c.port,
|
|
};
|
|
}
|
|
|
|
async function fetchDbVersion(connectionId: string, database: string, schema: string, side: "source" | "target") {
|
|
try {
|
|
await store.ensureConnected(connectionId);
|
|
const config = store.getConfig(connectionId);
|
|
const dbType = config?.db_type;
|
|
let sql = "";
|
|
switch (dbType) {
|
|
case "postgres":
|
|
case "opengauss":
|
|
sql = "SELECT version()";
|
|
break;
|
|
case "mysql":
|
|
sql = "SELECT VERSION()";
|
|
break;
|
|
case "sqlite":
|
|
sql = "SELECT sqlite_version()";
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
const result = await api.executeQuery(connectionId, database, sql, schema || undefined);
|
|
if (result.rows && result.rows.length > 0) {
|
|
const version = String(result.rows[0][0]);
|
|
if (side === "source") {
|
|
sourceDbVersion.value = version;
|
|
} else {
|
|
targetDbVersion.value = version;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`[fetchDbVersion] Failed to fetch version for ${side}:`, e);
|
|
if (side === "source") {
|
|
sourceDbVersion.value = null;
|
|
} else {
|
|
targetDbVersion.value = null;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-4">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-center gap-4 py-2">
|
|
<div class="text-center">
|
|
<div class="text-xs text-muted-foreground">{{ sourceConfig?.name || t("diff.source") }}</div>
|
|
<div class="text-xs font-medium">{{ sourceDatabase }}{{ sourceSchema ? `.${sourceSchema}` : "" }}</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon v-if="sourceConnectionId" :db-type="connectionIconType(sourceConnectionId)" class="w-5 h-5" />
|
|
<ArrowLeftRight class="w-4 h-4 text-muted-foreground" />
|
|
<DatabaseIcon v-if="targetConnectionId" :db-type="connectionIconType(targetConnectionId)" class="w-5 h-5" />
|
|
</div>
|
|
<div class="text-center">
|
|
<div class="text-xs text-muted-foreground">{{ targetConfig?.name || t("diff.target") }}</div>
|
|
<div class="text-xs font-medium">{{ targetDatabase }}{{ targetSchema ? `.${targetSchema}` : "" }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Source / Target Selection -->
|
|
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
|
<!-- Source Side -->
|
|
<div class="space-y-3">
|
|
<div class="text-sm font-medium text-blue-500">{{ t("diff.source") }}</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
|
<Select :model-value="sourceConnectionId" @update:model-value="(v: any) => $emit('update: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="c in sqlConnections" :key="c.id" :value="c.id">
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
|
{{ c.name }}
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
|
<Select :model-value="sourceDatabase" @update:model-value="(v: any) => $emit('update:sourceDatabase', String(v))">
|
|
<SelectTrigger class="h-8 text-xs" :disabled="!sourceDatabases.length">
|
|
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="db in sourceDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div v-if="isSchemaAware(sourceConfig?.db_type)" class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
|
<Select :model-value="sourceSchema" @update:model-value="(v: any) => $emit('update:sourceSchema', String(v))">
|
|
<SelectTrigger class="h-8 text-xs" :disabled="!sourceSchemas.length">
|
|
<SelectValue :placeholder="t('diff.selectSchema')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="schema in sourceSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- Source Info -->
|
|
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
|
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
|
|
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
|
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
|
<span>{{ getConnectionInfo(sourceConnectionId)?.dbType }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
|
<span>{{ getConnectionInfo(sourceConnectionId)?.name }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
|
<span>{{ getConnectionInfo(sourceConnectionId)?.host }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
|
<span>{{ getConnectionInfo(sourceConnectionId)?.port }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
|
<span>{{ sourceDbVersion || "--" }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Swap Button -->
|
|
<div class="pt-8">
|
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('swap')">
|
|
<ArrowLeftRight class="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Target Side -->
|
|
<div class="space-y-3">
|
|
<div class="text-sm font-medium text-green-500">{{ t("diff.target") }}</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.connection") }}</Label>
|
|
<Select :model-value="targetConnectionId" @update:model-value="(v: any) => $emit('update: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="c in sqlConnections" :key="c.id" :value="c.id">
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
|
{{ c.name }}
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.database") }}</Label>
|
|
<Select :model-value="targetDatabase" @update:model-value="(v: any) => $emit('update:targetDatabase', String(v))">
|
|
<SelectTrigger class="h-8 text-xs" :disabled="!targetDatabases.length">
|
|
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="db in targetDatabases" :key="db" :value="db">{{ db }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div v-if="isSchemaAware(targetConfig?.db_type)" class="space-y-1.5">
|
|
<Label class="text-xs">{{ t("diff.schema") }}</Label>
|
|
<Select :model-value="targetSchema" @update:model-value="(v: any) => $emit('update:targetSchema', String(v))">
|
|
<SelectTrigger class="h-8 text-xs" :disabled="!targetSchemas.length">
|
|
<SelectValue :placeholder="t('diff.selectSchema')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="schema in targetSchemas" :key="schema" :value="schema">{{ schema }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- Target Info -->
|
|
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
|
|
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
|
|
<div class="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5 text-xs">
|
|
<span class="text-muted-foreground">{{ t("diff.connType") }}</span>
|
|
<span>{{ getConnectionInfo(targetConnectionId)?.dbType }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.connName") }}</span>
|
|
<span>{{ getConnectionInfo(targetConnectionId)?.name }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.host") }}</span>
|
|
<span>{{ getConnectionInfo(targetConnectionId)?.host }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.port") }}</span>
|
|
<span>{{ getConnectionInfo(targetConnectionId)?.port }}</span>
|
|
<span class="text-muted-foreground">{{ t("diff.serverVersion") }}</span>
|
|
<span>{{ targetDbVersion || "--" }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Options -->
|
|
<div class="flex items-center gap-2 p-3 rounded-lg border bg-muted/20">
|
|
<input id="schema-diff-ignore-comments" :checked="ignoreComments" type="checkbox" class="accent-primary" @change="$emit('update:ignoreComments', ($event.target as HTMLInputElement).checked)" />
|
|
<Label for="schema-diff-ignore-comments" class="cursor-pointer text-xs">
|
|
{{ t("diff.ignoreComments") }}
|
|
</Label>
|
|
</div>
|
|
|
|
<!-- Recent Configs Dropdown -->
|
|
<div v-if="recentConfigs.length > 0" class="flex items-center gap-2">
|
|
<Label class="text-xs text-muted-foreground">{{ t("diff.recentConfigs") }}</Label>
|
|
<Select
|
|
:model-value="''"
|
|
@update:model-value="
|
|
(v: any) => {
|
|
const config = recentConfigs.find((c) => c.id === v);
|
|
if (config) $emit('loadHistoryConfig', config);
|
|
}
|
|
"
|
|
>
|
|
<SelectTrigger class="h-8 text-xs w-[280px]">
|
|
<SelectValue :placeholder="t('diff.selectRecentConfig')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="config in recentConfigs" :key="config.id" :value="config.id" class="pr-8">
|
|
<div class="flex items-center justify-between w-full gap-2">
|
|
<div class="flex flex-col gap-0.5 min-w-0">
|
|
<span class="text-xs font-medium truncate">{{ config.name }}</span>
|
|
<span class="text-[10px] text-muted-foreground truncate">
|
|
{{ store.getConfig(config.sourceConnectionId)?.name || config.sourceConnectionId }}
|
|
/{{ config.sourceDatabase }}{{ config.sourceSchema ? `.${config.sourceSchema}` : "" }}
|
|
→
|
|
{{ store.getConfig(config.targetConnectionId)?.name || config.targetConnectionId }}
|
|
/{{ config.targetDatabase }}{{ config.targetSchema ? `.${config.targetSchema}` : "" }}
|
|
</span>
|
|
</div>
|
|
<button class="shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors" @click.stop="$emit('deleteHistoryConfig', config.id)">
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- Bottom Actions -->
|
|
<div class="flex items-center justify-between pt-2">
|
|
<div class="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" @click="$emit('saveConfig')">
|
|
<Save class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.saveConfig") }}
|
|
</Button>
|
|
<Button variant="outline" size="sm" @click="$emit('loadConfig')">
|
|
<FolderOpen class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.loadConfig") }}
|
|
</Button>
|
|
<Button variant="outline" size="sm" @click="$emit('showOptions')">
|
|
<Settings class="w-3.5 h-3.5 mr-1" />
|
|
{{ t("diff.options") }}
|
|
</Button>
|
|
</div>
|
|
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
|
|
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
|
{{ loading ? t("common.loading") : t("diff.compare") }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</template>
|