feat(schema-diff): preserve dialect-specific DDL semantics
This commit is contained in:
1 parent
820b8cd26f
commit
002a2c85c0
146 files changed
+36532
-1375
No files matched your search
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, RefreshCw } from "@lucide/vue";
|
||||
import type { DriftAlert } from "@/types/governance";
|
||||
|
||||
interface DriftSummary {
|
||||
sourceEnv: string;
|
||||
targetEnv: string;
|
||||
driftCount: number;
|
||||
lastDetectedAt: string;
|
||||
hasUnacknowledged: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
drifts: DriftSummary[];
|
||||
alerts: DriftAlert[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
acknowledge: [alertId: string];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const expandedKeys = ref<Set<string>>(new Set());
|
||||
|
||||
const totalDriftCount = computed(() => props.drifts.reduce((sum, d) => sum + d.driftCount, 0));
|
||||
|
||||
function toggleKey(key: string) {
|
||||
const next = new Set(expandedKeys.value);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
expandedKeys.value = next;
|
||||
}
|
||||
|
||||
function alertsForDrift(summary: DriftSummary): DriftAlert[] {
|
||||
return props.alerts.filter((a) => a.sourceEnv === summary.sourceEnv && a.targetEnv === summary.targetEnv);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border bg-card">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("configDrift.title") }}</h3>
|
||||
<Button size="sm" variant="ghost" class="h-7 gap-1 text-xs" @click="emit('refresh')">
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="border-b px-4 py-2 text-xs">
|
||||
<template v-if="totalDriftCount > 0">
|
||||
<span class="text-muted-foreground">{{ t("configDrift.driftFound", { count: totalDriftCount }) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-muted-foreground">{{ t("configDrift.noDrift") }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="drifts.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/30 text-muted-foreground">
|
||||
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.sourceEnv") }}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.targetEnv") }}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.driftCount") }}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.lastDetected") }}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{{ t("configDrift.configKey") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="drift in drifts" :key="`${drift.sourceEnv}-${drift.targetEnv}`">
|
||||
<tr class="border-b last:border-b-0 hover:bg-accent/50">
|
||||
<td class="px-4 py-2.5 font-medium">{{ drift.sourceEnv }}</td>
|
||||
<td class="px-4 py-2.5">{{ drift.targetEnv }}</td>
|
||||
<td class="px-4 py-2.5">{{ drift.driftCount }}</td>
|
||||
<td class="px-4 py-2.5 text-muted-foreground">{{ formatDate(drift.lastDetectedAt) }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="!drift.hasUnacknowledged" class="text-green-600 dark:text-green-400">{{ t("configDrift.acknowledged") }}</span>
|
||||
<Badge v-else variant="outline" class="text-xs">{{ t("configDrift.unacknowledged") }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-for="alert in alertsForDrift(drift)" :key="alert.id" class="border-b bg-muted/20">
|
||||
<td colspan="5" class="px-4 py-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="flex items-center gap-1.5 text-left text-xs" @click="toggleKey(alert.id)">
|
||||
<ChevronRight v-if="!expandedKeys.has(alert.id)" class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown v-else class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<code class="rounded bg-muted px-1 py-0.5 font-mono text-xs">{{ alert.configKey }}</code>
|
||||
<span v-if="alert.acknowledged" class="text-green-600 dark:text-green-400">{{ t("configDrift.acknowledged") }}</span>
|
||||
</button>
|
||||
<Button v-if="!alert.acknowledged" size="sm" variant="outline" class="h-7 gap-1 text-xs" @click="emit('acknowledge', alert.id)">
|
||||
<Check class="h-3 w-3" />
|
||||
{{ t("configDrift.acknowledge") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="expandedKeys.has(alert.id)" class="mt-2 space-y-1.5 pl-5">
|
||||
<div class="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span class="text-muted-foreground">{{ t("configDrift.checksum") }} (expected)</span>
|
||||
<code class="ml-2 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{{ alert.expectedChecksum }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">{{ t("configDrift.checksum") }} (actual)</span>
|
||||
<code class="ml-2 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{{ alert.actualChecksum }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="alert.detailsJson" class="text-xs">
|
||||
<span class="text-muted-foreground">{{ t("configDrift.mismatchedFields") }}</span>
|
||||
<pre class="mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 font-mono text-[10px] whitespace-pre-wrap">{{ typeof alert.detailsJson === "string" ? alert.detailsJson : JSON.stringify(alert.detailsJson, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="drifts.length === 0" class="flex items-center justify-center px-4 py-10 text-xs text-muted-foreground">
|
||||
<AlertTriangle class="mr-2 h-4 w-4" />
|
||||
{{ t("configDrift.noDrift") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "@lucide/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
lastReloadedAt: string | null;
|
||||
reloadCount: number;
|
||||
reloading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [];
|
||||
dismiss: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="lastReloadedAt || reloadCount > 0" class="fixed bottom-4 right-4 z-50">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card p-3 shadow-lg">
|
||||
<Loader2 v-if="reloading" class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span class="text-xs font-medium">{{ t("toolbar.configChanged") }}</span>
|
||||
<Button size="sm" variant="default" class="h-6 text-xs px-2" @click="emit('apply')">
|
||||
{{ t("toolbar.applyConfig") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" class="h-6 text-xs px-2" @click="emit('dismiss')">
|
||||
{{ t("toolbar.dismiss") }}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="text-xs">
|
||||
{{ lastReloadedAt ? formatTimestamp(lastReloadedAt) : "" }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,681 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Download, Edit3, FileJson, Lock, Plus, Search, Trash2, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/common/utils";
|
||||
|
||||
interface BusinessTag {
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
interface TagFilter {
|
||||
search: string;
|
||||
showExpired: boolean;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tags: BusinessTag[];
|
||||
whitelist: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [tag: BusinessTag];
|
||||
delete: [key: string];
|
||||
import: [tags: BusinessTag[]];
|
||||
updateWhitelist: [keys: string[]];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const filter = ref<TagFilter>({
|
||||
search: "",
|
||||
showExpired: false,
|
||||
environment: "",
|
||||
});
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const showImportDialog = ref(false);
|
||||
const showWhitelistSection = ref(false);
|
||||
const editingTag = ref<BusinessTag | null>(null);
|
||||
const confirmDeleteKey = ref<string | null>(null);
|
||||
const importData = ref("");
|
||||
const importPreview = ref<BusinessTag[]>([]);
|
||||
const whitelistInput = ref("");
|
||||
|
||||
const environments = ["", "development", "staging", "production"];
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
return props.tags.filter((tag) => {
|
||||
if (filter.value.search) {
|
||||
const q = filter.value.search.toLowerCase();
|
||||
if (!tag.key.toLowerCase().includes(q) && !tag.value.toLowerCase().includes(q) && !tag.description.toLowerCase().includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
function resetEditingTag() {
|
||||
editingTag.value = { key: "", value: "", description: "", immutable: false };
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
resetEditingTag();
|
||||
showAddDialog.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(tag: BusinessTag) {
|
||||
editingTag.value = { ...tag };
|
||||
showAddDialog.value = true;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editingTag.value) return;
|
||||
emit("save", { ...editingTag.value });
|
||||
showAddDialog.value = false;
|
||||
editingTag.value = null;
|
||||
}
|
||||
|
||||
function handleDelete(key: string) {
|
||||
emit("delete", key);
|
||||
confirmDeleteKey.value = null;
|
||||
}
|
||||
|
||||
function parseImport() {
|
||||
try {
|
||||
const parsed: BusinessTag[] = JSON.parse(importData.value);
|
||||
importPreview.value = parsed.filter((item): item is BusinessTag => typeof item.key === "string" && typeof item.value === "string" && typeof item.description === "string" && typeof item.immutable === "boolean");
|
||||
} catch {
|
||||
const lines = importData.value.split("\n").filter(Boolean);
|
||||
const parsed: BusinessTag[] = lines.map((line) => {
|
||||
const parts = line.split(",");
|
||||
return {
|
||||
key: parts[0]?.trim() ?? "",
|
||||
value: parts[1]?.trim() ?? "",
|
||||
description: parts[2]?.trim() ?? "",
|
||||
immutable: parts[3]?.trim() === "true",
|
||||
};
|
||||
});
|
||||
importPreview.value = parsed.filter((item) => item.key);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
if (importPreview.value.length === 0) return;
|
||||
emit("import", importPreview.value);
|
||||
importPreview.value = [];
|
||||
importData.value = "";
|
||||
showImportDialog.value = false;
|
||||
}
|
||||
|
||||
function addWhitelistKey() {
|
||||
const key = whitelistInput.value.trim();
|
||||
if (!key || props.whitelist.includes(key)) return;
|
||||
emit("updateWhitelist", [...props.whitelist, key]);
|
||||
whitelistInput.value = "";
|
||||
}
|
||||
|
||||
function removeWhitelistKey(key: string) {
|
||||
emit(
|
||||
"updateWhitelist",
|
||||
props.whitelist.filter((k) => k !== key),
|
||||
);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const blob = new Blob([JSON.stringify(props.tags, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "tags.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tag-management-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{{ t("tag.management") }}</h2>
|
||||
<div class="panel-actions">
|
||||
<Button variant="outline" size="sm" @click="showImportDialog = true">
|
||||
<FileJson class="h-4 w-4" />
|
||||
{{ t("tag.importTags") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="handleExport">
|
||||
<Download class="h-4 w-4" />
|
||||
{{ t("tag.exportTags") }}
|
||||
</Button>
|
||||
<Button size="sm" @click="openAddDialog">
|
||||
<Plus class="h-4 w-4" />
|
||||
{{ t("common.add") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrapper">
|
||||
<Search class="search-icon" />
|
||||
<Input v-model="filter.search" :placeholder="t('common.search')" />
|
||||
</div>
|
||||
<div class="filter-controls">
|
||||
<Select v-model="filter.environment">
|
||||
<SelectTrigger class="w-[160px]">
|
||||
<SelectValue :placeholder="t('tag.environment')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">
|
||||
{{ t("common.all") }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="env in environments.filter(Boolean)" :key="env" :value="env">
|
||||
{{ env }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label class="expired-toggle">
|
||||
<Switch v-model="filter.showExpired" size="sm" />
|
||||
<span>{{ t("tag.expired") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="tag-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("tag.key") }}</th>
|
||||
<th>{{ t("tag.value") }}</th>
|
||||
<th>{{ t("tag.description") }}</th>
|
||||
<th>{{ t("tag.immutable") }}</th>
|
||||
<th class="actions-cell">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="filteredTags.length === 0">
|
||||
<td colspan="5" class="empty-state">
|
||||
{{ t("tag.noTags") }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="tag in filteredTags" :key="tag.key">
|
||||
<td class="cell-key">{{ tag.key }}</td>
|
||||
<td>{{ tag.value }}</td>
|
||||
<td class="cell-desc">{{ tag.description }}</td>
|
||||
<td>
|
||||
<Badge v-if="tag.immutable" variant="secondary" class="immutable-badge">
|
||||
<Lock class="h-3 w-3" />
|
||||
{{ t("tag.immutable") }}
|
||||
</Badge>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<div class="row-actions">
|
||||
<Button variant="ghost" size="icon-sm" @click="openEditDialog(tag)">
|
||||
<Edit3 class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" :disabled="tag.immutable" @click="confirmDeleteKey = tag.key">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Separator class="my-2" />
|
||||
|
||||
<div class="whitelist-section">
|
||||
<button class="whitelist-toggle" @click="showWhitelistSection = !showWhitelistSection">
|
||||
<span>{{ t("tag.whitelist") }}</span>
|
||||
<span class="whitelist-count">{{ props.whitelist.length }}</span>
|
||||
<svg :class="cn('chevron', showWhitelistSection && 'open')" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showWhitelistSection" class="whitelist-content">
|
||||
<div class="whitelist-chips">
|
||||
<span v-for="key in props.whitelist" :key="key" class="whitelist-chip">
|
||||
{{ key }}
|
||||
<button class="chip-remove" @click="removeWhitelistKey(key)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitelist-add-row">
|
||||
<Input v-model="whitelistInput" :placeholder="t('tag.key')" @keydown.enter="addWhitelistKey" />
|
||||
<Button variant="outline" size="sm" @click="addWhitelistKey">
|
||||
{{ t("common.add") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="showAddDialog">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ editingTag?.key && !editingTag?.key.startsWith("_new") ? t("common.edit") : t("common.add") }}
|
||||
{{ t("tag.management") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="form-field">
|
||||
<Label>{{ t("tag.key") }}</Label>
|
||||
<Input v-model="editingTag!.key" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<Label>{{ t("tag.value") }}</Label>
|
||||
<Input v-model="editingTag!.value" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<Label>{{ t("tag.description") }}</Label>
|
||||
<textarea v-model="editingTag!.description" class="desc-textarea" rows="3" />
|
||||
</div>
|
||||
<label class="immutable-field">
|
||||
<Switch v-model="editingTag!.immutable" size="sm" />
|
||||
<span>{{ t("tag.immutable") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showAddDialog = false">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button @click="handleSave">
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showImportDialog">
|
||||
<DialogContent class="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ t("tag.batchImport") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<textarea v-model="importData" class="import-textarea" rows="6" :placeholder="t('tag.batchImport')" />
|
||||
<div v-if="importPreview.length > 0" class="import-preview">
|
||||
<p class="preview-count">{{ importPreview.length }} {{ t("common.rows") }}</p>
|
||||
<table class="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("tag.key") }}</th>
|
||||
<th>{{ t("tag.value") }}</th>
|
||||
<th>{{ t("tag.description") }}</th>
|
||||
<th>{{ t("tag.immutable") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in importPreview.slice(0, 10)" :key="idx">
|
||||
<td>{{ item.key }}</td>
|
||||
<td>{{ item.value }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.immutable }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showImportDialog = false">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button variant="secondary" :disabled="!importData" @click="parseImport">
|
||||
{{ t("common.parse") }}
|
||||
</Button>
|
||||
<Button :disabled="importPreview.length === 0" @click="handleImport">
|
||||
{{ t("common.import") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
v-model:open="confirmDeleteKey !== null"
|
||||
@update:open="
|
||||
(v) => {
|
||||
if (!v) confirmDeleteKey = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ t("common.confirm") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="py-4 text-sm">
|
||||
{{ t("tag.confirmDelete", { key: confirmDeleteKey }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="confirmDeleteKey = null">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteKey && handleDelete(confirmDeleteKey)">
|
||||
{{ t("common.delete") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-management-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-wrapper :deep(input) {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.expired-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tag-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tag-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tag-table tbody tr:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
padding: 32px 12px !important;
|
||||
}
|
||||
|
||||
.cell-key {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-desc {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.immutable-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.whitelist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.whitelist-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.whitelist-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.whitelist-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.whitelist-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.whitelist-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.whitelist-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.desc-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.desc-textarea:focus {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.immutable-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.import-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.import-textarea:focus {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.import-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { CheckCircle2, XCircle, GitMerge } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface ConflictItem {
|
||||
objectName: string;
|
||||
conflictType: string;
|
||||
sourceValue: string;
|
||||
targetValue: string;
|
||||
autoResolvable: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
conflicts: ConflictItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
resolveWithSource: [objectName: string];
|
||||
resolveWithTarget: [objectName: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-md bg-card">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<GitMerge class="w-4 h-4 text-primary" />
|
||||
<h3 class="text-sm font-medium">{{ t("conflictMatrix.title") }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="conflicts.length === 0" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{{ t("conflictMatrix.noConflicts") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="p-3">
|
||||
<div class="border rounded overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.objectName") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.conflictType") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.sourceValue") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.targetValue") }}</th>
|
||||
<th class="text-center px-2 py-1.5 font-medium text-muted-foreground">{{ t("conflictMatrix.autoResolvable") }}</th>
|
||||
<th class="text-right px-2 py-1.5 font-medium text-muted-foreground">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in conflicts" :key="c.objectName" class="border-t" :class="c.autoResolvable ? 'bg-emerald-500/5' : 'bg-red-500/5'">
|
||||
<td class="px-2 py-1.5 font-mono">{{ c.objectName }}</td>
|
||||
<td class="px-2 py-1.5">{{ c.conflictType }}</td>
|
||||
<td class="px-2 py-1.5 font-mono max-w-[200px] truncate" :title="c.sourceValue">{{ c.sourceValue }}</td>
|
||||
<td class="px-2 py-1.5 font-mono max-w-[200px] truncate" :title="c.targetValue">{{ c.targetValue }}</td>
|
||||
<td class="px-2 py-1.5 text-center">
|
||||
<CheckCircle2 v-if="c.autoResolvable" class="w-4 h-4 text-green-500 inline-block" />
|
||||
<XCircle v-else class="w-4 h-4 text-red-500 inline-block" />
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">
|
||||
<div v-if="!c.autoResolvable" class="flex items-center justify-end gap-1">
|
||||
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors" @click="emit('resolveWithSource', c.objectName)">
|
||||
{{ t("conflictMatrix.resolveWithSource") }}
|
||||
</button>
|
||||
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground hover:bg-muted/60 transition-colors" @click="emit('resolveWithTarget', c.objectName)">
|
||||
{{ t("conflictMatrix.resolveWithTarget") }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-[10px] text-green-600 dark:text-green-400 font-medium">
|
||||
{{ t("conflictMatrix.resolved") }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Upload, Check, X, Trash2 } from "@lucide/vue";
|
||||
import FieldMappingPanel from "@/components/diff/FieldMappingPanel.vue";
|
||||
import type { FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
mappings: FieldMappingEntry[];
|
||||
sourceDbType: string;
|
||||
targetDbType: string;
|
||||
sourceConnectionId?: string;
|
||||
sourceDatabase?: string;
|
||||
targetConnectionId?: string;
|
||||
targetDatabase?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", value: boolean): void;
|
||||
(e: "save", value: FieldMappingEntry[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const editingMappings = ref<FieldMappingEntry[]>([]);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// 打开时深拷贝当前映射为本地副本(单向数据流,取消不写回)
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
editingMappings.value = props.mappings.map((m) => ({ ...m }));
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleUpdateMappings(v: FieldMappingEntry[]) {
|
||||
editingMappings.value = v;
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const payload = {
|
||||
sourceDbType: props.sourceDbType,
|
||||
targetDbType: props.targetDbType,
|
||||
mappings: editingMappings.value,
|
||||
};
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
// Use File System Access API (modern Chromium) for a native Save As dialog,
|
||||
// fallback to Blob download for other browsers.
|
||||
if ("showSaveFilePicker" in window) {
|
||||
(window as any)
|
||||
.showSaveFilePicker({
|
||||
suggestedName: "field-mappings.json",
|
||||
types: [{ description: "JSON", accept: { "application/json": [".json"] } }],
|
||||
})
|
||||
.then((handle: FileSystemFileHandle) => {
|
||||
return handle.createWritable().then((writable: FileSystemWritableFileStream) => {
|
||||
writable.write(content);
|
||||
return writable.close();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// User cancelled or API failed — fallback to Blob download
|
||||
fallbackDownload(content);
|
||||
});
|
||||
} else {
|
||||
fallbackDownload(content);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
editingMappings.value = [];
|
||||
}
|
||||
|
||||
function fallbackDownload(content: string) {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "field-mappings.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result)) as any;
|
||||
|
||||
// Support both new format { sourceDbType, targetDbType, mappings }
|
||||
// and legacy flat format FieldMappingEntry[]
|
||||
let mappings: any[];
|
||||
let fileSourceDbType: string | undefined;
|
||||
let fileTargetDbType: string | undefined;
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
mappings = parsed;
|
||||
} else if (parsed?.mappings && Array.isArray(parsed.mappings)) {
|
||||
mappings = parsed.mappings;
|
||||
fileSourceDbType = parsed.sourceDbType;
|
||||
fileTargetDbType = parsed.targetDbType;
|
||||
} else {
|
||||
throw new Error("invalid structure");
|
||||
}
|
||||
|
||||
const normalized: FieldMappingEntry[] = mappings.map((item: any) => ({
|
||||
sourceType: String(item.sourceType ?? ""),
|
||||
targetType: String(item.targetType ?? ""),
|
||||
paramStrategy: item.paramStrategy === "strip" || item.paramStrategy === "custom" ? item.paramStrategy : "preserve",
|
||||
customParams: item.customParams ? String(item.customParams) : undefined,
|
||||
}));
|
||||
if (normalized.some((m) => !m.sourceType || !m.targetType)) {
|
||||
throw new Error("missing sourceType/targetType");
|
||||
}
|
||||
|
||||
// Warn if exported source/target types differ from current
|
||||
if (fileSourceDbType && fileTargetDbType && (fileSourceDbType !== props.sourceDbType || fileTargetDbType !== props.targetDbType)) {
|
||||
toast(t("diff.fieldMapping.importTypeMismatch", { src: fileSourceDbType, tgt: fileTargetDbType }), 5000);
|
||||
}
|
||||
|
||||
editingMappings.value = normalized;
|
||||
toast(t("diff.fieldMapping.importSuccess"), 2000);
|
||||
} catch {
|
||||
toast(t("diff.fieldMapping.importError"), 3000);
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
};
|
||||
reader.onerror = () => toast(t("diff.fieldMapping.importError"), 3000);
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
emit(
|
||||
"save",
|
||||
editingMappings.value.map((m) => ({ ...m })),
|
||||
);
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="handleClose">
|
||||
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium">{{ t("diff.fieldMapping.title") }}</h3>
|
||||
<Button variant="ghost" size="sm" @click="handleClose">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FieldMappingPanel
|
||||
:mappings="editingMappings"
|
||||
:source-db-type="sourceDbType"
|
||||
:target-db-type="targetDbType"
|
||||
:source-connection-id="sourceConnectionId"
|
||||
:source-database="sourceDatabase"
|
||||
:target-connection-id="targetConnectionId"
|
||||
:target-database="targetDatabase"
|
||||
@update:mappings="handleUpdateMappings"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-3 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="handleClear" :disabled="editingMappings.length === 0">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.fieldMapping.clearMappings") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="handleImportClick">
|
||||
<Upload class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.fieldMapping.import") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="handleExport">
|
||||
<Download class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.fieldMapping.export") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" @click="handleDone">
|
||||
<Check class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.fieldMapping.done") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden" @change="onFileSelected" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, ArrowRight, Wand2 } from "@lucide/vue";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { findPreset } from "@/lib/fieldMappingPresets";
|
||||
import type { FieldMappingEntry, FieldMappingParamStrategy } from "@/types/schemaDiff";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
mappings: FieldMappingEntry[];
|
||||
sourceDbType: string;
|
||||
targetDbType: string;
|
||||
/** Same source as table structure editor: live types via listDataTypes(connection, database). */
|
||||
sourceConnectionId?: string;
|
||||
sourceDatabase?: string;
|
||||
targetConnectionId?: string;
|
||||
targetDatabase?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:mappings", value: FieldMappingEntry[]): void;
|
||||
}>();
|
||||
|
||||
const sameType = computed(() => props.sourceDbType === props.targetDbType);
|
||||
|
||||
const sourceTypeOptions = ref<string[]>([]);
|
||||
const targetTypeOptions = ref<string[]>([]);
|
||||
const selectedPresetId = ref<string>("");
|
||||
|
||||
const availablePresets = computed(() => {
|
||||
const preset = findPreset(props.sourceDbType, props.targetDbType);
|
||||
return preset ? [preset] : [];
|
||||
});
|
||||
|
||||
/** Same merge semantics as TableStructureEditor (local copy; do not touch structure editor code). */
|
||||
function mergeDataTypeOptions(primary: readonly string[], fallback: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const option of [...primary, ...fallback]) {
|
||||
const trimmed = option.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Match TableStructureEditor: listDataTypes(connection, database) + getDataTypeOptions(dbType). */
|
||||
async function loadTypeOptions(dbType: string, connectionId?: string, database?: string): Promise<string[]> {
|
||||
const fallback = getDataTypeOptions((dbType || undefined) as DatabaseType | undefined);
|
||||
if (!connectionId || !database) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const live = await api.listDataTypes(connectionId, database);
|
||||
return mergeDataTypeOptions(live, fallback);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
const m = a.length,
|
||||
n = b.length;
|
||||
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
const curr = [i];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
curr[j] = a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? prev[j - 1] : Math.min(prev[j], curr[j - 1], prev[j - 1]) + 1;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
function nameSimilarity(a: string, b: string): number {
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
if (maxLen === 0) return 1;
|
||||
return 1 - levenshtein(a, b) / maxLen;
|
||||
}
|
||||
|
||||
function autoGenerateMappings() {
|
||||
const srcTypes = sourceTypeOptions.value;
|
||||
const tgtTypes = targetTypeOptions.value;
|
||||
if (srcTypes.length === 0 || tgtTypes.length === 0) return;
|
||||
|
||||
const used = new Set<string>();
|
||||
const result: FieldMappingEntry[] = [];
|
||||
|
||||
for (const src of srcTypes) {
|
||||
let bestTarget = "";
|
||||
let bestSim = 0;
|
||||
for (const tgt of tgtTypes) {
|
||||
if (used.has(tgt)) continue;
|
||||
const sim = nameSimilarity(src, tgt);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestTarget = tgt;
|
||||
}
|
||||
}
|
||||
if (bestTarget && bestSim >= 0.3) {
|
||||
result.push({ sourceType: src, targetType: bestTarget, paramStrategy: "preserve" });
|
||||
used.add(bestTarget);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
emit("update:mappings", result);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceTypes() {
|
||||
sourceTypeOptions.value = await loadTypeOptions(props.sourceDbType, props.sourceConnectionId, props.sourceDatabase);
|
||||
}
|
||||
|
||||
async function loadTargetTypes() {
|
||||
targetTypeOptions.value = await loadTypeOptions(props.targetDbType, props.targetConnectionId, props.targetDatabase);
|
||||
}
|
||||
|
||||
function addMapping() {
|
||||
emit("update:mappings", [...props.mappings, { sourceType: "", targetType: "", paramStrategy: "preserve" as FieldMappingParamStrategy }]);
|
||||
}
|
||||
|
||||
function removeMapping(index: number) {
|
||||
const next = props.mappings.filter((_, i) => i !== index);
|
||||
emit("update:mappings", next);
|
||||
}
|
||||
|
||||
function updateMapping(index: number, field: keyof FieldMappingEntry, value: any) {
|
||||
const next = props.mappings.map((m, i) => (i === index ? { ...m, [field]: value } : m));
|
||||
emit("update:mappings", next);
|
||||
}
|
||||
|
||||
function applyPreset(presetId: string) {
|
||||
selectedPresetId.value = presetId;
|
||||
const preset = findPreset(props.sourceDbType, props.targetDbType);
|
||||
if (preset) {
|
||||
emit("update:mappings", [...preset.mappings]);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.sourceDbType, props.sourceConnectionId, props.sourceDatabase] as const,
|
||||
() => {
|
||||
void loadSourceTypes();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => [props.targetDbType, props.targetConnectionId, props.targetDatabase] as const,
|
||||
() => {
|
||||
void loadTargetTypes();
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-generate mappings when both type lists are ready, no preset exists, and no mappings yet
|
||||
watch([sourceTypeOptions, targetTypeOptions, availablePresets], ([src, tgt, presets]) => {
|
||||
if (src.length > 0 && tgt.length > 0 && props.mappings.length === 0 && presets.length === 0 && !sameType.value) {
|
||||
autoGenerateMappings();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadSourceTypes();
|
||||
void loadTargetTypes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-lg bg-card">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<span class="text-xs font-medium">{{ t("diff.fieldMapping.title") }}</span>
|
||||
<span v-if="!sameType" class="flex items-center gap-1 ml-1">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{{ sourceDbType }}</span>
|
||||
<ArrowRight class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-mono bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">{{ targetDbType }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sameType" class="px-3 py-4 text-xs text-muted-foreground text-center">
|
||||
{{ t("diff.fieldMapping.sameTypeHint") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="p-3 max-h-[42vh] overflow-auto">
|
||||
<div v-if="availablePresets.length > 0" class="mb-3">
|
||||
<label class="text-[10px] font-medium text-muted-foreground mb-1 block">
|
||||
{{ t("diff.fieldMapping.preset") }}
|
||||
</label>
|
||||
<Select :model-value="selectedPresetId" @update:model-value="(v: any) => applyPreset(v)">
|
||||
<SelectTrigger class="h-8 w-full text-xs">
|
||||
<SelectValue :placeholder="t('diff.fieldMapping.selectPreset')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="p in availablePresets" :key="p.id" :value="p.id">{{ p.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="mappings.length === 0" class="flex flex-col items-center justify-center py-6 gap-3">
|
||||
<p class="text-xs text-muted-foreground">{{ t("diff.fieldMapping.noMappings") }}</p>
|
||||
<Button v-if="availablePresets.length === 0" variant="outline" size="sm" class="h-7 text-xs gap-1" @click="autoGenerateMappings" :disabled="sameType || sourceTypeOptions.length === 0 || targetTypeOptions.length === 0">
|
||||
<Wand2 class="w-3.5 h-3.5" />
|
||||
{{ t("diff.fieldMapping.autoGenerate") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr_100px_auto] gap-2 items-center px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.sourceType") }}</span>
|
||||
<div />
|
||||
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.targetType") }}</span>
|
||||
<span class="text-[10px] font-medium text-muted-foreground">{{ t("diff.fieldMapping.paramStrategy") }}</span>
|
||||
<div />
|
||||
</div>
|
||||
<div v-for="(mapping, i) in mappings" :key="i" class="space-y-1">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr_100px_auto] gap-2 items-center">
|
||||
<SearchableSelect
|
||||
:model-value="mapping.sourceType"
|
||||
@update:model-value="(v: string) => updateMapping(i, 'sourceType', v)"
|
||||
:options="sourceTypeOptions"
|
||||
:placeholder="t('diff.fieldMapping.sourceType')"
|
||||
:search-placeholder="t('diff.fieldMapping.sourceType')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs font-mono"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
allow-custom
|
||||
/>
|
||||
<ArrowRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<SearchableSelect
|
||||
:model-value="mapping.targetType"
|
||||
@update:model-value="(v: string) => updateMapping(i, 'targetType', v)"
|
||||
:options="targetTypeOptions"
|
||||
:placeholder="t('diff.fieldMapping.targetType')"
|
||||
:search-placeholder="t('diff.fieldMapping.targetType')"
|
||||
:empty-text="t('common.noResults')"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs font-mono"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
allow-custom
|
||||
/>
|
||||
<Select :model-value="mapping.paramStrategy" @update:model-value="(v: any) => updateMapping(i, 'paramStrategy', v)">
|
||||
<SelectTrigger class="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preserve">{{ t("diff.fieldMapping.strategyPreserve") }}</SelectItem>
|
||||
<SelectItem value="strip">{{ t("diff.fieldMapping.strategyStrip") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("diff.fieldMapping.strategyCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="icon-sm" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" @click="removeMapping(i)">
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="mapping.paramStrategy === 'custom'" class="flex items-center gap-2 pl-1">
|
||||
<input
|
||||
:value="mapping.customParams || ''"
|
||||
@input="(e: any) => updateMapping(i, 'customParams', (e.target as HTMLInputElement).value)"
|
||||
class="h-7 w-32 rounded border border-input bg-background px-2 text-xs font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
:placeholder="t('diff.fieldMapping.customParamsPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="addMapping">
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
{{ t("diff.fieldMapping.addMapping") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Clock, WifiOff, Zap, Layers, CheckCircle2, XCircle } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface ImpactReport {
|
||||
overallRisk: "safe" | "caution" | "dangerous" | "blocked";
|
||||
ddlRiskLevel: "safe" | "caution" | "dangerous" | "blocked";
|
||||
estimatedLocks: Array<{ lockType: string; objects: string[]; duration: string }>;
|
||||
estimatedTotalDuration: string;
|
||||
recommendedStrategy: "online" | "lazy" | "offline" | "batch";
|
||||
warnings: string[];
|
||||
requiresMaintenanceWindow: boolean;
|
||||
isReversible: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
report: ImpactReport | null;
|
||||
}>();
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
safe: "#22c55e",
|
||||
caution: "#eab308",
|
||||
dangerous: "#f97316",
|
||||
blocked: "#ef4444",
|
||||
};
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
safe: t("impact.riskSafe"),
|
||||
caution: t("impact.riskCaution"),
|
||||
dangerous: t("impact.riskDangerous"),
|
||||
blocked: t("impact.riskBlocked"),
|
||||
};
|
||||
|
||||
const strategyIcons: Record<string, any> = {
|
||||
online: Zap,
|
||||
lazy: Clock,
|
||||
offline: WifiOff,
|
||||
batch: Layers,
|
||||
};
|
||||
|
||||
const strategyLabels: Record<string, string> = {
|
||||
online: t("impact.strategyOnline"),
|
||||
lazy: t("impact.strategyLazy"),
|
||||
offline: t("impact.strategyOffline"),
|
||||
batch: t("impact.strategyBatch"),
|
||||
};
|
||||
|
||||
const strategyIcon = computed(() => (props.report ? strategyIcons[props.report.recommendedStrategy] : null));
|
||||
const strategyLabel = computed(() => (props.report ? strategyLabels[props.report.recommendedStrategy] : ""));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-md bg-card">
|
||||
<!-- Header -->
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="text-sm font-medium">{{ t("impact.title") }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- No report state -->
|
||||
<div v-if="!report" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{{ t("impact.noReport") }}
|
||||
</div>
|
||||
|
||||
<!-- Report content -->
|
||||
<div v-else class="p-3 space-y-4">
|
||||
<!-- Risk Summary -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ t("impact.overallRisk") }}:</span>
|
||||
<span class="px-2 py-0.5 rounded text-[11px] font-semibold text-white" :style="{ backgroundColor: riskColors[report.overallRisk] }">
|
||||
{{ riskLabels[report.overallRisk] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ t("impact.ddlRisk") }}:</span>
|
||||
<span class="px-2 py-0.5 rounded text-[11px] font-semibold text-white" :style="{ backgroundColor: riskColors[report.ddlRiskLevel] }">
|
||||
{{ riskLabels[report.ddlRiskLevel] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Strategy & Impact Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
|
||||
<component :is="strategyIcon" class="w-4 h-4 text-primary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("impact.recommendedStrategy") }}</div>
|
||||
<div class="text-xs font-medium truncate">{{ strategyLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
|
||||
<Clock class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("impact.estimatedDuration") }}</div>
|
||||
<div class="text-xs font-medium truncate">{{ report.estimatedTotalDuration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
|
||||
<component :is="report.requiresMaintenanceWindow ? AlertTriangle : CheckCircle2" class="w-4 h-4 shrink-0" :class="report.requiresMaintenanceWindow ? 'text-amber-500' : 'text-green-500'" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("impact.maintenanceWindow") }}</div>
|
||||
<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium" :class="report.requiresMaintenanceWindow ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'">
|
||||
{{ report.requiresMaintenanceWindow ? t("impact.yes") : t("impact.no") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 rounded border bg-muted/20">
|
||||
<component :is="report.isReversible ? CheckCircle2 : XCircle" class="w-4 h-4 shrink-0" :class="report.isReversible ? 'text-green-500' : 'text-red-500'" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] text-muted-foreground">{{ t("impact.reversible") }}</div>
|
||||
<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium" :class="report.isReversible ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'">
|
||||
{{ report.isReversible ? t("impact.yes") : t("impact.no") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lock Analysis -->
|
||||
<div v-if="report.estimatedLocks.length > 0">
|
||||
<h4 class="text-xs font-medium mb-2">{{ t("impact.lockAnalysis") }}</h4>
|
||||
<div class="border rounded overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockType") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockObjects") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("impact.lockDuration") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(lock, i) in report.estimatedLocks" :key="i" class="border-t">
|
||||
<td class="px-2 py-1.5 font-mono">{{ lock.lockType }}</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<span v-for="(obj, j) in lock.objects" :key="j" class="inline-block mr-1 mb-0.5 px-1.5 py-0.5 rounded bg-muted/40 text-[10px] font-mono">
|
||||
{{ obj }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ lock.duration }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="report.warnings.length > 0">
|
||||
<h4 class="text-xs font-medium mb-2">{{ t("impact.warnings") }}</h4>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(warning, i) in report.warnings" :key="i" class="flex items-start gap-2 px-2 py-1.5 rounded text-xs bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/30">
|
||||
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>{{ warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, CheckCircle, Clock, Hourglass, Loader2, XCircle } from "@lucide/vue";
|
||||
|
||||
type OscExecutionStatus = "preparing" | "copying" | "cut_over" | "completed" | "failed" | "postponed";
|
||||
|
||||
interface OscOperation {
|
||||
toolType: "gh-ost" | "pt-osc";
|
||||
tableName: string;
|
||||
status: OscExecutionStatus;
|
||||
progressPercent: number;
|
||||
estimatedRemainingSecs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
operations: OscOperation[];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const statusConfig: Record<OscExecutionStatus, { labelKey: string; icon: any; badgeClass: string; barClass: string }> = {
|
||||
preparing: {
|
||||
labelKey: "osc.preparing",
|
||||
icon: Hourglass,
|
||||
badgeClass: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
barClass: "bg-yellow-500",
|
||||
},
|
||||
copying: {
|
||||
labelKey: "osc.copying",
|
||||
icon: Loader2,
|
||||
badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
barClass: "bg-blue-500",
|
||||
},
|
||||
cut_over: {
|
||||
labelKey: "osc.cutOver",
|
||||
icon: Clock,
|
||||
badgeClass: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
||||
barClass: "bg-purple-500",
|
||||
},
|
||||
completed: {
|
||||
labelKey: "osc.completed",
|
||||
icon: CheckCircle,
|
||||
badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
barClass: "bg-green-500",
|
||||
},
|
||||
failed: {
|
||||
labelKey: "osc.failed",
|
||||
icon: XCircle,
|
||||
badgeClass: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
barClass: "bg-red-500",
|
||||
},
|
||||
postponed: {
|
||||
labelKey: "osc.postponed",
|
||||
icon: AlertTriangle,
|
||||
badgeClass: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
barClass: "bg-orange-500",
|
||||
},
|
||||
};
|
||||
|
||||
function formatRemaining(seconds?: number): string {
|
||||
if (seconds == null) return "-";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
const toolTypeLabelKey: Record<string, string> = {
|
||||
"gh-ost": "osc.ghost",
|
||||
"pt-osc": "osc.ptosc",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border bg-card">
|
||||
<div class="border-b px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("osc.title") }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="operations.length === 0" class="flex items-center justify-center px-4 py-12 text-xs text-muted-foreground">
|
||||
{{ t("osc.noActiveOsc") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div v-for="op in operations" :key="`${op.toolType}-${op.tableName}`" class="px-4 py-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="statusConfig[op.status].icon" class="h-4 w-4" :class="{ 'animate-spin': op.status === 'copying' }" />
|
||||
<span class="text-xs font-medium">{{ op.tableName }}</span>
|
||||
<Badge variant="outline" class="text-[10px]">{{ t(toolTypeLabelKey[op.toolType] || op.toolType) }}</Badge>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium" :class="statusConfig[op.status].badgeClass">
|
||||
<component :is="statusConfig[op.status].icon" class="h-3 w-3" />
|
||||
{{ t(statusConfig[op.status].labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-1.5 flex items-center gap-3">
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="statusConfig[op.status].barClass" :style="{ width: `${Math.min(op.progressPercent, 100)}%` }" />
|
||||
</div>
|
||||
<span class="w-10 text-right text-[10px] tabular-nums text-muted-foreground">{{ Math.min(op.progressPercent, 100) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="h-3 w-3" />
|
||||
<span>{{ t("osc.estimatedRemaining") }}: {{ formatRemaining(op.estimatedRemainingSecs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="op.status === 'failed' && op.error" class="mt-2 rounded-md bg-destructive/10 px-2.5 py-1.5 text-[10px] text-destructive">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<AlertTriangle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{{ op.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, Check, Clock, Layers, SkipForward } from "@lucide/vue";
|
||||
|
||||
interface ConflictItem {
|
||||
objectName: string;
|
||||
conflictType: string;
|
||||
sourceValue: string;
|
||||
targetValue: string;
|
||||
autoResolvable: boolean;
|
||||
}
|
||||
|
||||
interface RebaseInfo {
|
||||
id: string;
|
||||
baselineId: string;
|
||||
totalObjects: number;
|
||||
autoResolvedCount: number;
|
||||
conflictCount: number;
|
||||
conflicts: ConflictItem[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
rebaseInfo: RebaseInfo | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
overwriteBaseline: [];
|
||||
skipObject: [objectName: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border bg-card">
|
||||
<div class="border-b px-4 py-3">
|
||||
<h3 class="text-sm font-medium">{{ t("rebase.title") }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="!rebaseInfo" class="flex items-center justify-center px-4 py-12 text-xs text-muted-foreground">
|
||||
{{ t("rebase.notes") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 gap-4 border-b px-4 py-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">{{ t("rebase.objectCount") }}</div>
|
||||
<div class="text-sm font-medium">{{ rebaseInfo.totalObjects }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">{{ t("rebase.autoResolved") }}</div>
|
||||
<div class="text-sm font-medium text-green-600 dark:text-green-400">{{ rebaseInfo.autoResolvedCount }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">{{ t("rebase.conflictCount") }}</div>
|
||||
<div class="text-sm font-medium" :class="rebaseInfo.conflictCount > 0 ? 'text-destructive' : ''">{{ rebaseInfo.conflictCount }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">{{ t("rebase.notes") }}</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock class="h-3 w-3" />
|
||||
{{ formatDate(rebaseInfo.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<Layers class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ t("rebase.driftReport") }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">{{ t("rebase.currentBaseline") }}</span>
|
||||
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{{ rebaseInfo.baselineId }}</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">{{ t("rebase.newBaseline") }}</span>
|
||||
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{{ rebaseInfo.id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rebaseInfo.conflicts.length > 0" class="border-b px-4 py-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<AlertTriangle class="h-4 w-4 text-amber-500" />
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ t("rebase.conflicts") }}</span>
|
||||
<Badge variant="destructive" class="ml-auto text-xs">{{ rebaseInfo.conflictCount }}</Badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(conflict, index) in rebaseInfo.conflicts" :key="conflict.objectName" class="rounded-md border px-3 py-2 text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium" :class="conflict.autoResolvable ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span class="font-medium">{{ conflict.objectName }}</span>
|
||||
<Badge variant="outline" class="text-[10px]">{{ conflict.conflictType }}</Badge>
|
||||
<Badge v-if="conflict.autoResolvable" variant="secondary" class="text-[10px]"> auto </Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" class="h-7 gap-1 text-xs" @click="emit('skipObject', conflict.objectName)">
|
||||
<SkipForward class="h-3 w-3" />
|
||||
{{ t("rebase.skipObject") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-1.5 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-[10px] text-muted-foreground">Source</div>
|
||||
<code class="mt-0.5 block truncate rounded bg-muted/50 px-1 py-0.5 font-mono">{{ conflict.sourceValue }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] text-muted-foreground">Target</div>
|
||||
<code class="mt-0.5 block truncate rounded bg-muted/50 px-1 py-0.5 font-mono">{{ conflict.targetValue }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 px-4 py-3">
|
||||
<Button size="sm" class="gap-1.5 text-xs" @click="emit('overwriteBaseline')">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{{ t("rebase.overwriteBaseline") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -11,7 +11,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
@@ -39,6 +39,8 @@ const emit = defineEmits<{
|
||||
(e: "update:targetDatabase", value: string): void;
|
||||
(e: "update:targetSchema", value: string): void;
|
||||
(e: "update:ignoreComments", value: boolean): void;
|
||||
(e: "update:fieldMappings", value: FieldMappingEntry[]): void;
|
||||
(e: "open-field-mapping"): void;
|
||||
(e: "compare"): void;
|
||||
(e: "saveConfig"): void;
|
||||
(e: "loadConfig"): void;
|
||||
@@ -60,6 +62,11 @@ const sqlConnections = computed(() => store.connections.filter((c: any) => !["mo
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
const sourceDbType = computed(() => sourceConfig.value?.db_type || "");
|
||||
const targetDbType = computed(() => targetConfig.value?.db_type || "");
|
||||
const showFieldMapping = computed(() => sourceDbType.value && targetDbType.value && sourceDbType.value !== targetDbType.value);
|
||||
const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -473,6 +480,11 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<Settings class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.options") }}
|
||||
</Button>
|
||||
<Button v-if="showFieldMapping" variant="outline" size="sm" @click="$emit('open-field-mapping')">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5 mr-1" />
|
||||
{{ t("diff.openFieldMapping") }}
|
||||
<span v-if="activeFieldMappings.length > 0" class="ml-1 inline-flex items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">{{ activeFieldMappings.length }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" :disabled="!canCompare || loading" @click="$emit('compare')">
|
||||
<GitCompareArrows class="w-3.5 h-3.5 mr-1" />
|
||||
|
||||
@@ -9,10 +9,10 @@ import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
|
||||
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
|
||||
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
|
||||
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
|
||||
import { FileCode, ScrollText, Copy, Play } from "@lucide/vue";
|
||||
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
|
||||
import type { SchemaDiffObject, CompatibilityWarning, DependencyGraph, PermissionDiff, MissingRollbackObject, RollbackCompleteness } from "@/lib/schema/schemaDiff";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -36,19 +36,39 @@ const props = defineProps<{
|
||||
selectedObject: SchemaDiffObject | null;
|
||||
deploySql: string;
|
||||
deploySqlAll: string;
|
||||
compatibilityWarnings?: CompatibilityWarning[];
|
||||
rollbackSql?: string;
|
||||
deploySqlMode?: "forward" | "rollback";
|
||||
dependencyGraph?: DependencyGraph | null;
|
||||
permissionDiffs?: PermissionDiff[];
|
||||
rollbackCompleteness?: RollbackCompleteness;
|
||||
missingRollbackObjects?: MissingRollbackObject[];
|
||||
canExecute?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
executeScript: [];
|
||||
"update:deploySqlMode": [mode: "forward" | "rollback"];
|
||||
}>();
|
||||
|
||||
const activeTab = ref<"ddl" | "script" | "scriptAll">("ddl");
|
||||
const activeTab = ref<"ddl" | "script" | "scriptAll" | "warnings" | "depGraph" | "permissions" | "rollbackCompare">("ddl");
|
||||
const diffContainerRef = ref<HTMLDivElement>();
|
||||
const leftPaneRef = ref<HTMLDivElement>();
|
||||
const rightPaneRef = ref<HTMLDivElement>();
|
||||
const containerSize = ref({ width: 0, height: 0 });
|
||||
const connectorKey = ref(0);
|
||||
|
||||
const rollbackDiffContainerRef = ref<HTMLDivElement>();
|
||||
const rollbackLeftPaneRef = ref<HTMLDivElement>();
|
||||
const rollbackRightPaneRef = ref<HTMLDivElement>();
|
||||
const rollbackContainerSize = ref({ width: 0, height: 0 });
|
||||
const rollbackConnectorKey = ref(0);
|
||||
|
||||
const rollbackHunks = computed(() => {
|
||||
if (!props.rollbackSql) return [];
|
||||
return buildHunks(props.deploySql, props.rollbackSql);
|
||||
});
|
||||
|
||||
const hunks = computed(() => {
|
||||
if (!props.selectedObject?.sourceDdl && !props.selectedObject?.targetDdl) return [];
|
||||
return buildHunks(props.selectedObject?.sourceDdl || "", props.selectedObject?.targetDdl || "");
|
||||
@@ -61,6 +81,13 @@ const { syncScroll, measureHunks } = useDiffScrollSync({
|
||||
hunks,
|
||||
});
|
||||
|
||||
const { syncScroll: rollbackSyncScroll, measureHunks: rollbackMeasureHunks } = useDiffScrollSync({
|
||||
container: rollbackDiffContainerRef,
|
||||
leftPane: rollbackLeftPaneRef,
|
||||
rightPane: rollbackRightPaneRef,
|
||||
hunks: rollbackHunks,
|
||||
});
|
||||
|
||||
// Cache char-level diff segments so we don't recompute on every render
|
||||
const modifySegments = computed(() => {
|
||||
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
|
||||
@@ -77,6 +104,21 @@ const modifySegments = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const rollbackModifySegments = computed(() => {
|
||||
const map = new Map<string, { leftSegments: Segment[]; rightSegments: Segment[] }>();
|
||||
for (const hunk of rollbackHunks.value) {
|
||||
if (hunk.type !== "modify") continue;
|
||||
for (let i = 0; i < hunk.leftLines.length; i++) {
|
||||
const left = hunk.leftLines[i];
|
||||
const right = hunk.rightLines[i];
|
||||
if (left.isPadding || right.isPadding) continue;
|
||||
const key = `${hunk.id}:${i}`;
|
||||
map.set(key, renderModifyLine(left, right));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let measureRaf: number | null = null;
|
||||
let measureTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -101,6 +143,37 @@ function handleScroll(from: "left" | "right") {
|
||||
requestMeasureDebounced();
|
||||
}
|
||||
|
||||
let rollbackMeasureRaf: number | null = null;
|
||||
let rollbackMeasureTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function rollbackRequestMeasure() {
|
||||
if (rollbackMeasureRaf) return;
|
||||
rollbackMeasureRaf = requestAnimationFrame(() => {
|
||||
rollbackMeasureRaf = null;
|
||||
rollbackMeasureHunks();
|
||||
rollbackConnectorKey.value++;
|
||||
});
|
||||
}
|
||||
|
||||
function rollbackRequestMeasureDebounced() {
|
||||
if (rollbackMeasureTimeout) clearTimeout(rollbackMeasureTimeout);
|
||||
rollbackMeasureTimeout = setTimeout(() => {
|
||||
rollbackRequestMeasure();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function rollbackHandleScroll(from: "left" | "right") {
|
||||
rollbackSyncScroll(from);
|
||||
rollbackRequestMeasureDebounced();
|
||||
}
|
||||
|
||||
function rollbackUpdateContainerSize() {
|
||||
const el = rollbackDiffContainerRef.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
rollbackContainerSize.value = { width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedObject?.id,
|
||||
async () => {
|
||||
@@ -122,6 +195,11 @@ function onSplitpanesResized() {
|
||||
requestMeasure();
|
||||
}
|
||||
|
||||
function rollbackOnSplitpanesResized() {
|
||||
rollbackUpdateContainerSize();
|
||||
rollbackRequestMeasure();
|
||||
}
|
||||
|
||||
function lineBackground(line: DiffLine): string | undefined {
|
||||
if (line.isPadding) return undefined;
|
||||
if (line.type === "delete") {
|
||||
@@ -232,6 +310,19 @@ function copyDeploySqlAll() {
|
||||
|
||||
<template>
|
||||
<div class="border rounded-md flex flex-col h-full">
|
||||
<!-- Forward / Rollback SQL mode toggle -->
|
||||
<div v-if="rollbackSql" class="flex items-center gap-1 px-3 py-1 border-b bg-muted/20 shrink-0">
|
||||
<span class="text-[11px] text-muted-foreground mr-2">{{ t("diff.deployMode") }}:</span>
|
||||
<button class="text-xs px-2 py-1 rounded transition-colors" :class="deploySqlMode === 'forward' ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'forward')">{{ t("diff.forwardSql") }}</button>
|
||||
<button class="text-xs px-2 py-1 rounded transition-colors" :class="deploySqlMode === 'rollback' ? 'bg-destructive text-destructive-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'rollback')">{{ t("diff.rollbackSql") }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rollbackCompleteness === 'incomplete'" class="px-3 py-1.5 border-b bg-amber-50 dark:bg-amber-950/30 text-xs text-amber-800 dark:text-amber-200 shrink-0">
|
||||
{{ t("diff.rollbackIncompleteBanner") }}
|
||||
<ul v-if="missingRollbackObjects?.length" class="mt-1 list-disc pl-4">
|
||||
<li v-for="(m, i) in missingRollbackObjects" :key="i">{{ m.kind }} {{ m.name }}{{ m.table ? ` @ ${m.table}` : "" }} — {{ m.reason }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b shrink-0">
|
||||
<button class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'ddl' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'ddl'">
|
||||
@@ -246,6 +337,29 @@ function copyDeploySqlAll() {
|
||||
<ScrollText class="w-3.5 h-3.5" />
|
||||
{{ t("diff.deployScriptAll") }}
|
||||
</button>
|
||||
<button v-if="rollbackSql" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'rollbackCompare' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'rollbackCompare'">
|
||||
<FileDiff class="w-3.5 h-3.5" />
|
||||
{{ t("rollbackComparison.title") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="compatibilityWarnings && compatibilityWarnings.length > 0"
|
||||
class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors relative"
|
||||
:class="activeTab === 'warnings' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-b-2 border-amber-500' : 'hover:bg-muted/50'"
|
||||
@click="activeTab = 'warnings'"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
|
||||
{{ t("diff.compatibilityWarnings") }}
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-amber-500 text-white font-bold">{{ compatibilityWarnings.length }}</span>
|
||||
</button>
|
||||
<button v-if="dependencyGraph && dependencyGraph.nodes.length > 0" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'depGraph' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'depGraph'">
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
{{ t("diff.dependencyGraph") }}
|
||||
</button>
|
||||
<button v-if="permissionDiffs && permissionDiffs.length > 0" class="px-3 py-1.5 text-xs font-medium flex items-center gap-1 transition-colors" :class="activeTab === 'permissions' ? 'bg-primary/10 text-primary border-b-2 border-primary' : 'hover:bg-muted/50'" @click="activeTab = 'permissions'">
|
||||
<span class="w-2 h-2 rounded-full bg-purple-500 shrink-0" />
|
||||
{{ t("diff.operation") }}
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] bg-purple-500 text-white font-bold">{{ permissionDiffs.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DDL Compare -->
|
||||
@@ -335,6 +449,82 @@ function copyDeploySqlAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rollback Comparison -->
|
||||
<div v-else-if="activeTab === 'rollbackCompare'" class="flex-1 overflow-hidden relative">
|
||||
<div v-if="!rollbackSql" class="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDdlAvailable") }}
|
||||
</div>
|
||||
<div v-else ref="rollbackDiffContainerRef" class="absolute inset-0 font-mono text-xs leading-relaxed">
|
||||
<Splitpanes class="h-full" @resized="rollbackOnSplitpanesResized">
|
||||
<Pane min-size="20">
|
||||
<div ref="rollbackLeftPaneRef" class="h-full overflow-y-auto border-r" @scroll="rollbackHandleScroll('left')">
|
||||
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
{{ t("rollbackComparison.forwardSql") }}
|
||||
</div>
|
||||
<div v-for="hunk in rollbackHunks" :key="`left-${hunk.id}`" :data-hunk-id="hunk.id">
|
||||
<div
|
||||
v-for="(line, idx) in hunk.leftLines"
|
||||
:key="`l-${hunk.id}-${idx}`"
|
||||
class="flex min-h-[1.5em]"
|
||||
:class="{
|
||||
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
|
||||
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
|
||||
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.leftLines.length - 1,
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
|
||||
<template v-if="line.type === 'modify' && !line.isPadding">
|
||||
<template v-for="(segment, si) in rollbackModifySegments.get(`${hunk.id}:${idx}`)?.leftSegments ?? []" :key="`ls-${si}`">
|
||||
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane min-size="20">
|
||||
<div ref="rollbackRightPaneRef" class="h-full overflow-y-auto" @scroll="rollbackHandleScroll('right')">
|
||||
<div class="sticky top-0 bg-muted/50 px-3 py-1.5 text-xs font-medium border-b z-10">
|
||||
{{ t("rollbackComparison.rollbackSql") }}
|
||||
</div>
|
||||
<div v-for="hunk in rollbackHunks" :key="`right-${hunk.id}`" :data-hunk-id="hunk.id">
|
||||
<div
|
||||
v-for="(line, idx) in hunk.rightLines"
|
||||
:key="`r-${hunk.id}-${idx}`"
|
||||
class="flex min-h-[1.5em]"
|
||||
:class="{
|
||||
'border-l border-r border-yellow-500/40': hunk.type === 'modify',
|
||||
'border-t rounded-t-sm': hunk.type === 'modify' && idx === 0,
|
||||
'border-b rounded-b-sm': hunk.type === 'modify' && idx === hunk.rightLines.length - 1,
|
||||
}"
|
||||
:style="{ backgroundColor: lineBackground(line) }"
|
||||
>
|
||||
<span class="text-muted-foreground w-8 text-right pr-2 select-none shrink-0">
|
||||
{{ line.lineNumber ?? "" }}
|
||||
</span>
|
||||
<span class="flex-1 px-1 whitespace-pre" :class="lineTextClass(line)">
|
||||
<template v-if="line.type === 'modify' && !line.isPadding">
|
||||
<template v-for="(segment, si) in rollbackModifySegments.get(`${hunk.id}:${idx}`)?.rightSegments ?? []" :key="`rs-${si}`">
|
||||
<span :style="segment.changed ? { backgroundColor: toRgba(ddlColors.modifiedCharBg, ddlColors.modifiedCharBgAlpha) } : undefined">{{ segment.text }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else>{{ line.isPadding ? "\u00A0" : line.content }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<DiffSvgConnector :key="rollbackConnectorKey" :hunks="rollbackHunks" :container-width="rollbackContainerSize.width" :container-height="rollbackContainerSize.height" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Script -->
|
||||
<div v-else-if="activeTab === 'script'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
|
||||
@@ -344,7 +534,7 @@ function copyDeploySqlAll() {
|
||||
<Copy class="w-3 h-3" />
|
||||
{{ t("diff.copy") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" :disabled="canExecute === false" @click="$emit('executeScript')">
|
||||
<Play class="w-3 h-3" />
|
||||
{{ t("diff.execute") }}
|
||||
</Button>
|
||||
@@ -355,6 +545,63 @@ function copyDeploySqlAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compatibility Warnings -->
|
||||
<div v-else-if="activeTab === 'warnings'" class="flex-1 flex flex-col overflow-hidden p-3">
|
||||
<div v-if="compatibilityWarnings && compatibilityWarnings.length > 0" class="space-y-2">
|
||||
<div v-for="(w, i) in compatibilityWarnings" :key="i" class="flex items-start gap-2 p-2 rounded border bg-amber-50 dark:bg-amber-950/20 text-xs">
|
||||
<span class="shrink-0 w-2 h-2 rounded-full mt-0.5" :class="w.risk === 'high' ? 'bg-red-500' : w.risk === 'medium' ? 'bg-amber-500' : 'bg-yellow-400'" />
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium">{{ w.table }}.{{ w.column }}</span
|
||||
>:
|
||||
<span class="text-muted-foreground">{{ w.sourceType }} → {{ w.targetType }}</span>
|
||||
<p class="text-muted-foreground mt-0.5">{{ w.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependency Graph -->
|
||||
<div v-else-if="activeTab === 'depGraph'" class="flex-1 flex flex-col overflow-hidden p-3">
|
||||
<div v-if="dependencyGraph && dependencyGraph.nodes.length > 0" class="space-y-1">
|
||||
<div v-for="node in dependencyGraph.nodes" :key="node.tableName" class="py-1 text-xs">
|
||||
<span class="font-mono font-medium">{{ node.tableName }}</span>
|
||||
<span v-if="node.dependsOn.length > 0" class="text-muted-foreground ml-2">
|
||||
→ {{ t("diff.dependsOn") }}: <span class="font-mono">{{ node.dependsOn.join(", ") }}</span>
|
||||
</span>
|
||||
<span v-if="node.dependedBy.length > 0" class="text-muted-foreground ml-2">
|
||||
← {{ t("diff.dependedBy") }}: <span class="font-mono">{{ node.dependedBy.join(", ") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div v-else-if="activeTab === 'permissions'" class="flex-1 flex flex-col overflow-hidden p-3">
|
||||
<div v-if="permissionDiffs && permissionDiffs.length > 0" class="space-y-2">
|
||||
<div v-for="(pd, i) in permissionDiffs" :key="i" class="flex items-start gap-2 p-2 rounded border text-xs">
|
||||
<span class="shrink-0 w-2 h-2 rounded-full mt-0.5 bg-purple-500" />
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium">{{ pd.objectName }}</span>
|
||||
<span class="text-muted-foreground"> — {{ pd.permissionType }}</span>
|
||||
<div class="mt-0.5">
|
||||
<span v-if="pd.sourcePermission" class="text-green-600 dark:text-green-400">{{ t("diff.source") }}: {{ pd.sourcePermission }}</span>
|
||||
<span v-if="pd.sourcePermission && pd.targetPermission" class="text-muted-foreground mx-1">→</span>
|
||||
<span v-if="pd.targetPermission" class="text-red-600 dark:text-red-400">{{ t("diff.target") }}: {{ pd.targetPermission }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t("diff.noDifferences") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Script All -->
|
||||
<div v-else-if="activeTab === 'scriptAll'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0">
|
||||
@@ -364,7 +611,7 @@ function copyDeploySqlAll() {
|
||||
<Copy class="w-3 h-3" />
|
||||
{{ t("diff.copy") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" @click="$emit('executeScript')">
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs gap-1" :disabled="canExecute === false" @click="$emit('executeScript')">
|
||||
<Play class="w-3 h-3" />
|
||||
{{ t("diff.executeAll") }}
|
||||
</Button>
|
||||
|
||||
@@ -9,13 +9,15 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schema/schemaDiff";
|
||||
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap } from "@lucide/vue";
|
||||
import type { SchemaDiffObject, DiffOperationType, DiffObjectKind, CompatibilityWarning, RenameCandidate, MissingRollbackObject, RollbackCompleteness } from "@/lib/schema/schemaDiff";
|
||||
import ImpactReportPanel from "@/components/diff/ImpactReportPanel.vue";
|
||||
import type { ImpactReport } from "@/types/governance";
|
||||
import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap, AlertTriangle, ShieldCheck } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const props = defineProps<{
|
||||
deploySql: string;
|
||||
@@ -24,10 +26,19 @@ const props = defineProps<{
|
||||
targetDatabase: string;
|
||||
targetSchema: string;
|
||||
executing: boolean;
|
||||
rollbackSql?: string;
|
||||
deploySqlMode?: "forward" | "rollback";
|
||||
compatibilityWarnings?: CompatibilityWarning[];
|
||||
renameCandidates?: RenameCandidate[];
|
||||
impactReport?: ImpactReport | null;
|
||||
rollbackCompleteness?: RollbackCompleteness;
|
||||
missingRollbackObjects?: MissingRollbackObject[];
|
||||
canExecute?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:deploySql": [sql: string];
|
||||
"update:deploySqlMode": [mode: "forward" | "rollback"];
|
||||
back: [];
|
||||
deploy: [];
|
||||
}>();
|
||||
@@ -92,6 +103,24 @@ const operationCounts = computed(() => {
|
||||
return counts;
|
||||
});
|
||||
|
||||
const riskLevel = computed(() => {
|
||||
const count = props.compatibilityWarnings?.length ?? 0;
|
||||
if (count === 0) return "safe";
|
||||
if (count <= 3) return "caution";
|
||||
return "dangerous";
|
||||
});
|
||||
|
||||
const renameCount = computed(() => props.renameCandidates?.length ?? 0);
|
||||
|
||||
const showImpactReport = ref(false);
|
||||
|
||||
const effectiveSql = computed(() => {
|
||||
if (props.deploySqlMode === "rollback" && props.rollbackSql) {
|
||||
return props.rollbackSql;
|
||||
}
|
||||
return props.deploySql;
|
||||
});
|
||||
|
||||
const operationIcons: Record<DiffOperationType, any> = {
|
||||
modify: ArrowRightLeft,
|
||||
create: PlusCircle,
|
||||
@@ -120,13 +149,13 @@ async function initEditor() {
|
||||
const fontSize = settingsStore.editorSettings.fontSize;
|
||||
const fontFamily = settingsStore.editorSettings.fontFamily;
|
||||
|
||||
const themeExt = await loadEditorTheme(editorTheme, appAppearance, undefined, themePalette.value);
|
||||
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
|
||||
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
|
||||
|
||||
const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres");
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.deploySql,
|
||||
doc: effectiveSql.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
langSql.sql({ dialect }),
|
||||
@@ -144,9 +173,9 @@ async function initEditor() {
|
||||
isEditorReady.value = true;
|
||||
}
|
||||
|
||||
// Watch for external deploySql changes
|
||||
// Watch for external deploySql changes (including mode switch)
|
||||
watch(
|
||||
() => props.deploySql,
|
||||
() => effectiveSql.value,
|
||||
(newVal) => {
|
||||
if (editorView.value && editorView.value.state.doc.toString() !== newVal) {
|
||||
editorView.value.dispatch({
|
||||
@@ -271,14 +300,38 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
</Button>
|
||||
<span class="text-sm font-medium">{{ t("diff.deployReview") }}</span>
|
||||
<span class="text-xs text-muted-foreground"> ({{ t("diff.selectedCount", { selected: topLevelObjects.length, total: topLevelObjects.length }) }}) </span>
|
||||
<!-- Risk level badge -->
|
||||
<span v-if="riskLevel !== 'safe'" class="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium" :class="riskLevel === 'dangerous' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'">
|
||||
<AlertTriangle class="w-3 h-3" />
|
||||
{{ riskLevel === "dangerous" ? t("diff.riskLevel.dangerous", { count: compatibilityWarnings?.length ?? 0 }) : t("diff.riskLevel.caution", { count: compatibilityWarnings?.length ?? 0 }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-green-500">{{ t("diff.create") }}: {{ operationCounts.create }}</span>
|
||||
<span class="text-blue-500">{{ t("diff.modify") }}: {{ operationCounts.modify }}</span>
|
||||
<span class="text-red-500">{{ t("diff.delete") }}: {{ operationCounts.delete }}</span>
|
||||
<span v-if="renameCount > 0" class="text-purple-500">{{ t("diff.renameCount", { count: renameCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact Report toggle -->
|
||||
<div v-if="impactReport" class="border-b shrink-0">
|
||||
<button class="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-accent/50 transition-colors" @click="showImpactReport = !showImpactReport">
|
||||
<ShieldCheck class="w-3.5 h-3.5" :class="impactReport.overallRisk === 'safe' ? 'text-green-500' : impactReport.overallRisk === 'caution' ? 'text-yellow-500' : impactReport.overallRisk === 'dangerous' ? 'text-orange-500' : 'text-red-500'" />
|
||||
<span class="font-medium">{{ t("impact.title") }}</span>
|
||||
<span class="ml-auto text-muted-foreground">{{ showImpactReport ? "▲" : "▼" }}</span>
|
||||
</button>
|
||||
<div v-if="showImpactReport" class="border-t">
|
||||
<ImpactReportPanel :report="impactReport" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rollbackCompleteness === 'incomplete' && deploySqlMode === 'rollback'" class="px-3 py-1.5 border-b bg-amber-50 dark:bg-amber-950/30 text-xs text-amber-800 dark:text-amber-200 shrink-0">
|
||||
{{ t("diff.rollbackIncompleteBanner") }}
|
||||
<ul v-if="missingRollbackObjects?.length" class="mt-1 list-disc pl-4">
|
||||
<li v-for="(m, i) in missingRollbackObjects" :key="i">{{ m.kind }} {{ m.name }}{{ m.table ? ` @ ${m.table}` : "" }} — {{ m.reason }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<Splitpanes class="flex-1 min-h-0">
|
||||
<Pane size="30" min-size="20">
|
||||
@@ -305,6 +358,11 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-t shrink-0 gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- SQL mode toggle -->
|
||||
<div v-if="props.rollbackSql" class="flex items-center gap-1 mr-2 border rounded">
|
||||
<button class="text-xs px-2 py-1 rounded transition-colors" :class="props.deploySqlMode === 'forward' ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'forward')">{{ t("diff.deployMode") }}</button>
|
||||
<button class="text-xs px-2 py-1 rounded transition-colors" :class="props.deploySqlMode === 'rollback' ? 'bg-destructive text-destructive-foreground' : 'hover:bg-accent'" @click="$emit('update:deploySqlMode', 'rollback')">{{ t("diff.rollbackMode") }}</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs gap-1" @click="handleCopy">
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
{{ t("diff.copyScript") }}
|
||||
@@ -322,7 +380,7 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="$emit('back')">
|
||||
{{ t("diff.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing" @click="handleDeploy">
|
||||
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing || canExecute === false" @click="handleDeploy">
|
||||
<Loader2 v-if="executing" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Play v-else class="w-3.5 h-3.5" />
|
||||
{{ t("diff.deployToServer") }}
|
||||
|
||||
@@ -5,22 +5,44 @@ import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
|
||||
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck, ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
|
||||
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
||||
import SchemaDiffDdlPanel from "@/components/diff/SchemaDiffDdlPanel.vue";
|
||||
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
|
||||
import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue";
|
||||
|
||||
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
|
||||
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
|
||||
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, shouldFetchSchemaDiffDdl } from "@/lib/schema/schemaDiffMetadataLoad";
|
||||
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig } from "@/types/schemaDiff";
|
||||
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
|
||||
import type { ObjectSourceKind, TableInfo } from "@/types/database";
|
||||
import { buildDeploySqlForObjects, convertToSchemaDiffObjects, groupDiffObjects, schemaDiffDeployTargetSchema, type OperationGroup, type SchemaDiffObject, type DiffOperationType, type DiffObjectKind, type SchemaDiffPreparation, type TableSchemaDetail } from "@/lib/schema/schemaDiff";
|
||||
import {
|
||||
buildDeploySqlForObjects,
|
||||
convertToSchemaDiffObjects,
|
||||
groupDiffObjects,
|
||||
injectColumnRenameSql,
|
||||
schemaDiffDeployTargetSchema,
|
||||
databaseTypeToDialectKind,
|
||||
normalizeDialectKind,
|
||||
type OperationGroup,
|
||||
type SchemaDiffObject,
|
||||
type DiffOperationType,
|
||||
type DiffObjectKind,
|
||||
type SchemaDiffPreparation,
|
||||
type MissingRollbackObject,
|
||||
type RollbackCompleteness,
|
||||
type TableSchemaDetail,
|
||||
type RenameCandidate,
|
||||
type CompatibilityWarning,
|
||||
type PermissionDiff,
|
||||
type DependencyGraph,
|
||||
} from "@/lib/schema/schemaDiff";
|
||||
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
@@ -53,6 +75,16 @@ const ignoreComments = ref(false);
|
||||
|
||||
// Options panel
|
||||
const showOptionsPanel = ref(false);
|
||||
const showFieldMappingDialog = ref(false);
|
||||
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
|
||||
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
|
||||
|
||||
// Clear stale field mappings when source and target are the same type
|
||||
watch([sourceDbType, targetDbType], ([src, tgt]) => {
|
||||
if (src && src === tgt && activeConfig.value?.options.fieldMappings?.length) {
|
||||
handleFieldMappingsUpdate([]);
|
||||
}
|
||||
});
|
||||
const optionTree = computed(() => {
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
const dbType = targetConfig?.db_type || "postgres";
|
||||
@@ -70,7 +102,22 @@ const executing = ref(false);
|
||||
const lastDiffResult = ref<SchemaDiffPreparation | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
const showResultDialog = ref(false);
|
||||
const deployResult = ref<{ success: boolean; message: string; affectedRows?: number } | null>(null);
|
||||
const deployResult = ref<{ success: boolean; status?: string; message: string; affectedRows?: number; error?: string } | null>(null);
|
||||
|
||||
// Phase 4 result fields
|
||||
const rollbackSql = ref("");
|
||||
const rollbackCompleteness = ref<RollbackCompleteness>("complete");
|
||||
const missingRollbackObjects = ref<MissingRollbackObject[]>([]);
|
||||
const renameCandidates = ref<RenameCandidate[]>([]);
|
||||
const compatibilityWarnings = ref<CompatibilityWarning[]>([]);
|
||||
const permissionDiffs = ref<PermissionDiff[]>([]);
|
||||
const dependencyGraph = ref<DependencyGraph | null>(null);
|
||||
|
||||
// Rename candidates panel
|
||||
const showRenamePanel = ref(true);
|
||||
|
||||
// Rollback / forward SQL mode in deploy step
|
||||
const deploySqlMode = ref<"forward" | "rollback">("forward");
|
||||
|
||||
// Dialog size memory (width + height + splitpanes ratio)
|
||||
const DIALOG_SIZE_KEY = "dbx-schema-diff-size";
|
||||
@@ -274,6 +321,13 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
|
||||
if (activeConfig.value) {
|
||||
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(updated, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a JDBC table_type to an ObjectSourceKind for getTableDdl routing.
|
||||
* Views and materialized views need the object_type parameter so the
|
||||
* backend can call DBMS_METADATA.GET_DDL with the correct type. */
|
||||
@@ -324,6 +378,15 @@ async function handleCompare() {
|
||||
loading.value = true;
|
||||
step.value = "compare";
|
||||
|
||||
// Reset Phase 4 state to prevent stale data from previous compares
|
||||
rollbackSql.value = "";
|
||||
rollbackCompleteness.value = "complete";
|
||||
missingRollbackObjects.value = [];
|
||||
renameCandidates.value = [];
|
||||
compatibilityWarnings.value = [];
|
||||
permissionDiffs.value = [];
|
||||
dependencyGraph.value = null;
|
||||
|
||||
try {
|
||||
const sourceConfig = store.getConfig(sourceConnectionId.value);
|
||||
const targetConfig = store.getConfig(targetConnectionId.value);
|
||||
@@ -404,17 +467,53 @@ async function handleCompare() {
|
||||
ignoreComments: ignoreComments.value,
|
||||
cascadeDelete: opts?.cascadeDelete ?? false,
|
||||
compareColumnOrder: opts.compareColumnOrder,
|
||||
detectRenames: opts?.detectRenames ?? false,
|
||||
detectTableRenames: opts?.detectTableRenames ?? false,
|
||||
renameThreshold: opts?.renameThreshold ?? 0.5,
|
||||
enableRollback: opts?.enableRollback ?? false,
|
||||
batchPatterns: opts?.batchPatterns
|
||||
? opts.batchPatterns
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
sourceDialect: opts?.sourceDialect ? normalizeDialectKind(opts.sourceDialect) : sourceConfig?.db_type ? databaseTypeToDialectKind(sourceConfig.db_type) : undefined,
|
||||
targetDialect: opts?.targetDialect ? normalizeDialectKind(opts.targetDialect) : targetConfig?.db_type ? databaseTypeToDialectKind(targetConfig.db_type) : undefined,
|
||||
compatibilityThreshold: opts?.compatibilityThreshold ?? 0.5,
|
||||
fieldMappings:
|
||||
opts?.fieldMappings?.map((m: FieldMappingEntry) => ({
|
||||
sourceType: m.sourceType,
|
||||
targetType: m.targetType,
|
||||
paramStrategy: m.paramStrategy ?? "preserve",
|
||||
customParams: m.customParams,
|
||||
})) || [],
|
||||
});
|
||||
|
||||
// Extract new result fields
|
||||
rollbackSql.value = result.rollbackSyncSql ?? "";
|
||||
rollbackCompleteness.value = result.rollbackCompleteness ?? "complete";
|
||||
missingRollbackObjects.value = result.missingRollbackObjects ?? [];
|
||||
renameCandidates.value = result.renameCandidates ?? [];
|
||||
compatibilityWarnings.value = result.compatibilityWarnings ?? [];
|
||||
permissionDiffs.value = result.permissionDiffs ?? [];
|
||||
dependencyGraph.value = result.dependencyGraph ?? null;
|
||||
|
||||
// Convert to unified objects
|
||||
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs);
|
||||
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates);
|
||||
|
||||
// Group by operation type and object kind
|
||||
diffGroups.value = groupDiffObjects(diffObjects.value);
|
||||
|
||||
// Save full result and generate deploy SQL
|
||||
// Save full result and apply column rename detection to SQL
|
||||
lastDiffResult.value = result;
|
||||
deploySqlAll.value = result.syncSql;
|
||||
if (opts?.detectRenames && opts.renameThreshold) {
|
||||
deploySqlAll.value = injectColumnRenameSql(deploySqlAll.value, result.diffs, opts.renameThreshold);
|
||||
if (rollbackSql.value) {
|
||||
rollbackSql.value = injectColumnRenameSql(rollbackSql.value, result.diffs, opts.renameThreshold, true);
|
||||
}
|
||||
}
|
||||
deploySqlMode.value = "forward";
|
||||
regenerateDeploySql();
|
||||
|
||||
step.value = "result";
|
||||
@@ -498,29 +597,112 @@ function regenerateDeploySql() {
|
||||
deploySql.value = buildDeploySqlForObjects(diffObjects.value);
|
||||
}
|
||||
|
||||
function switchDeploySqlMode(mode: "forward" | "rollback") {
|
||||
if (mode === "rollback" && rollbackCompleteness.value === "incomplete") {
|
||||
toast(t("diff.rollbackIncompleteBlocked"), 4000);
|
||||
return;
|
||||
}
|
||||
deploySqlMode.value = mode;
|
||||
if (mode === "rollback" && rollbackSql.value) {
|
||||
deploySql.value = rollbackSql.value;
|
||||
} else {
|
||||
regenerateDeploySql();
|
||||
}
|
||||
}
|
||||
|
||||
const canExecuteDeploy = computed(() => {
|
||||
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function applyRename(rc: RenameCandidate) {
|
||||
let found = false;
|
||||
for (const obj of diffObjects.value) {
|
||||
// Backend-detected renamed diff (diff_type = "renamed")
|
||||
if (obj.renameMetadata?.sourceName === rc.sourceName && obj.renameMetadata?.targetName === rc.targetName) {
|
||||
obj.renameMetadata.confirmed = true;
|
||||
obj.selected = true;
|
||||
found = true;
|
||||
}
|
||||
// Legacy delete+create pair
|
||||
if (obj.operationType === "delete" && obj.name === rc.sourceName) {
|
||||
obj.deploySql = `-- Renamed to ${rc.targetName}\n${obj.deploySql ?? ""}`;
|
||||
obj.renameMetadata = { confirmed: true, targetName: rc.targetName, score: rc.score };
|
||||
found = true;
|
||||
}
|
||||
if (obj.operationType === "create" && obj.name === rc.targetName) {
|
||||
obj.deploySql = `-- Renamed from ${rc.sourceName}\n${obj.deploySql ?? ""}`;
|
||||
obj.sourceName = rc.sourceName;
|
||||
obj.renameMetadata = { confirmed: true, sourceName: rc.sourceName, score: rc.score };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
regenerateDeploySql();
|
||||
toast(t("diff.renameApplied"), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreRename(index: number) {
|
||||
const rc = renameCandidates.value[index];
|
||||
if (rc) {
|
||||
for (const obj of diffObjects.value) {
|
||||
if (obj.renameMetadata?.sourceName === rc.sourceName && obj.renameMetadata?.targetName === rc.targetName) {
|
||||
obj.renameMetadata.confirmed = false;
|
||||
obj.selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
renameCandidates.value.splice(index, 1);
|
||||
regenerateDeploySql();
|
||||
}
|
||||
|
||||
async function handleExecuteScript() {
|
||||
if (!deploySql.value || deploySql.value.startsWith("-- ")) {
|
||||
toast(t("diff.noObjectsSelected"), 3000);
|
||||
return;
|
||||
}
|
||||
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
|
||||
toast(t("diff.rollbackIncompleteBlocked"), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeDeploySql();
|
||||
}
|
||||
|
||||
async function executeDeploySql() {
|
||||
executing.value = true;
|
||||
try {
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: store.getConfig(targetConnectionId.value),
|
||||
const targetConnection = store.getConfig(targetConnectionId.value);
|
||||
const failed = await executeWithProductionSqlGuard({
|
||||
connection: targetConnection,
|
||||
database: targetDatabase.value,
|
||||
sql: deploySql.value,
|
||||
source: t("production.sourceSchemaDiff"),
|
||||
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
||||
execute: async () => {
|
||||
const txLog = await api.executeScriptWith2pc(targetConnectionId.value, targetDatabase.value, [deploySql.value], targetSchema.value);
|
||||
return txLog;
|
||||
},
|
||||
});
|
||||
if (!result) return;
|
||||
toast(t("diff.executeSuccess"), 3000);
|
||||
if (failed === undefined) return;
|
||||
showDeployTxResult(failed);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
deployResult.value = {
|
||||
success: false,
|
||||
message: e?.message || String(e),
|
||||
};
|
||||
showResultDialog.value = true;
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showDeployTxResult(txLog: any) {
|
||||
deployResult.value = buildDeployTxResult(txLog, t);
|
||||
showResultDialog.value = true;
|
||||
}
|
||||
async function handleSelectObject(obj: SchemaDiffObject) {
|
||||
selectedObjectId.value = obj.id;
|
||||
|
||||
@@ -575,9 +757,9 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) {
|
||||
|
||||
function handleSaveConfig() {
|
||||
if (activeConfig.value) {
|
||||
const name = window.prompt(t("diff.saveConfigPrompt") || "Please enter config name:", activeConfig.value.name || "Default");
|
||||
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
|
||||
if (name === null) return; // User cancelled
|
||||
const configToSave = { ...activeConfig.value, name: name.trim() || "Default" };
|
||||
const configToSave = { ...activeConfig.value, name: name.trim() || t("diff.defaultConfigName") };
|
||||
saveToHistory(configToSave);
|
||||
toast(t("diff.configSaved"), 2000);
|
||||
}
|
||||
@@ -631,36 +813,21 @@ function handleDeployReview() {
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
|
||||
toast(t("diff.rollbackIncompleteBlocked"), 5000);
|
||||
return;
|
||||
}
|
||||
showConfirmDialog.value = true;
|
||||
}
|
||||
|
||||
async function onConfirmDeploy() {
|
||||
showConfirmDialog.value = false;
|
||||
executing.value = true;
|
||||
try {
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: store.getConfig(targetConnectionId.value),
|
||||
database: targetDatabase.value,
|
||||
sql: deploySql.value,
|
||||
source: t("production.sourceSchemaDiff"),
|
||||
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
||||
});
|
||||
if (!result) return;
|
||||
deployResult.value = {
|
||||
success: true,
|
||||
message: t("diff.deploySuccess"),
|
||||
affectedRows: result.affected_rows,
|
||||
};
|
||||
showResultDialog.value = true;
|
||||
} catch (e: any) {
|
||||
deployResult.value = {
|
||||
success: false,
|
||||
message: e?.message || String(e),
|
||||
};
|
||||
showResultDialog.value = true;
|
||||
} finally {
|
||||
executing.value = false;
|
||||
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
|
||||
toast(t("diff.rollbackIncompleteBlocked"), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeDeploySql();
|
||||
}
|
||||
|
||||
const deployStats = computed(() => {
|
||||
@@ -724,6 +891,8 @@ const targetConnectionInfo = computed(() => {
|
||||
@save-config="handleSaveConfig"
|
||||
@load-history-config="handleLoadHistoryConfig"
|
||||
@delete-history-config="handleDeleteHistoryConfig"
|
||||
@update:field-mappings="handleFieldMappingsUpdate"
|
||||
@open-field-mapping="showFieldMappingDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Compare Loading -->
|
||||
@@ -734,6 +903,49 @@ const targetConnectionInfo = computed(() => {
|
||||
|
||||
<!-- Result Step -->
|
||||
<template v-else-if="step === 'result'">
|
||||
<!-- Rename Candidates Panel -->
|
||||
<div v-if="renameCandidates.length > 0" class="border-b bg-amber-50 dark:bg-amber-950/20 shrink-0 overflow-hidden">
|
||||
<button class="flex items-center gap-2 px-3 py-1.5 w-full text-left text-xs font-medium hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors" @click="showRenamePanel = !showRenamePanel">
|
||||
<ChevronDown v-if="showRenamePanel" class="w-3 h-3" />
|
||||
<ChevronRight v-else class="w-3 h-3" />
|
||||
{{ t("diff.renameCandidates") }}
|
||||
<span class="ml-auto text-muted-foreground font-normal">{{ renameCandidates.length }} candidate(s)</span>
|
||||
</button>
|
||||
<div v-if="showRenamePanel" class="px-3 pb-2 text-xs">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-muted-foreground border-b">
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.sourceTable") }}</th>
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.targetTable") }}</th>
|
||||
<th class="text-left py-1 pr-4">{{ t("diff.similarity") }}</th>
|
||||
<th class="text-left py-1">{{ t("common.action") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(rc, i) in renameCandidates" :key="i" class="border-b border-amber-200/50 dark:border-amber-800/30">
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.sourceName }}</td>
|
||||
<td class="py-1 pr-4 font-mono">{{ rc.targetName }}</td>
|
||||
<td class="py-1 pr-4">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="rc.score >= 0.8 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : rc.score >= 0.5 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'"
|
||||
>{{ (rc.score * 100).toFixed(0) }}%</span
|
||||
>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors mr-1" @click="applyRename(rc)">
|
||||
{{ t("diff.confirmRename") }}
|
||||
</button>
|
||||
<button class="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 transition-colors" @click="ignoreRename(i)">
|
||||
{{ t("diff.ignore") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Splitpanes horizontal class="flex-1 min-h-0" @resized="handleSplitpanesResized">
|
||||
<Pane :size="splitpanesSize" min-size="20">
|
||||
<div class="h-full overflow-auto">
|
||||
@@ -750,14 +962,45 @@ const targetConnectionInfo = computed(() => {
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="100 - splitpanesSize" min-size="20">
|
||||
<SchemaDiffDdlPanel :selected-object="selectedObject" :deploy-sql="deploySql" :deploy-sql-all="deploySqlAll" @execute-script="handleExecuteScript" />
|
||||
<SchemaDiffDdlPanel
|
||||
:selected-object="selectedObject"
|
||||
:deploy-sql="deploySql"
|
||||
:deploy-sql-all="deploySqlAll"
|
||||
:compatibility-warnings="compatibilityWarnings"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:dependency-graph="dependencyGraph"
|
||||
:permission-diffs="permissionDiffs"
|
||||
:rollback-completeness="rollbackCompleteness"
|
||||
:missing-rollback-objects="missingRollbackObjects"
|
||||
:can-execute="canExecuteDeploy"
|
||||
@update:deploy-sql-mode="switchDeploySqlMode"
|
||||
@execute-script="handleExecuteScript"
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
<!-- Deploy Review Step -->
|
||||
<template v-else-if="step === 'deploy-review'">
|
||||
<SchemaDiffDeployStep v-model:deploy-sql="deploySql" :selected-objects="diffObjects" :target-connection-id="targetConnectionId" :target-database="targetDatabase" :target-schema="targetSchema" :executing="executing" @back="step = 'result'" @deploy="handleDeploy" />
|
||||
<SchemaDiffDeployStep
|
||||
v-model:deploy-sql="deploySql"
|
||||
:selected-objects="diffObjects"
|
||||
:target-connection-id="targetConnectionId"
|
||||
:target-database="targetDatabase"
|
||||
:target-schema="targetSchema"
|
||||
:executing="executing"
|
||||
:rollback-sql="rollbackSql"
|
||||
:deploy-sql-mode="deploySqlMode"
|
||||
:compatibility-warnings="compatibilityWarnings"
|
||||
:rename-candidates="renameCandidates"
|
||||
:rollback-completeness="rollbackCompleteness"
|
||||
:missing-rollback-objects="missingRollbackObjects"
|
||||
:can-execute="canExecuteDeploy"
|
||||
@update:deploy-sql-mode="switchDeploySqlMode"
|
||||
@back="step = 'result'"
|
||||
@deploy="handleDeploy"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -837,12 +1080,23 @@ const targetConnectionInfo = computed(() => {
|
||||
<DialogTitle class="flex items-center gap-2" :class="deployResult?.success ? 'text-green-500' : 'text-destructive'">
|
||||
<AlertTriangle v-if="!deployResult?.success" class="h-5 w-5" />
|
||||
<CircleCheck v-else class="h-5 w-5" />
|
||||
{{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}
|
||||
<template v-if="deployResult?.status === 'mixed'">{{ t("diff.deployMixedTitle") }}</template>
|
||||
<template v-else-if="deployResult?.status === 'rolled_back'">{{ t("diff.deployRolledBackTitle") }}</template>
|
||||
<template v-else>{{ deployResult?.success ? t("diff.deploySuccess") : t("diff.deployFailed") }}</template>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-2">
|
||||
<div v-if="deployResult?.success" class="space-y-2">
|
||||
<div v-if="deployResult?.status === 'mixed'" class="space-y-2">
|
||||
<p class="text-sm text-destructive-foreground">{{ deployResult.message }}</p>
|
||||
<div class="bg-yellow-50 border border-yellow-300 p-3 rounded text-xs text-yellow-800">
|
||||
{{ t("diff.deployMixedWarning") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="deployResult?.status === 'rolled_back'" class="space-y-2">
|
||||
<p class="text-sm text-destructive-foreground">{{ deployResult.message }}</p>
|
||||
</div>
|
||||
<div v-else-if="deployResult?.success" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">{{ t("diff.deploySuccessMessage") }}</p>
|
||||
<div class="bg-muted p-3 rounded text-xs font-mono">
|
||||
<div>{{ t("diff.affectedRows") }}: {{ deployResult.affectedRows ?? 0 }}</div>
|
||||
@@ -875,11 +1129,25 @@ const targetConnectionInfo = computed(() => {
|
||||
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
|
||||
<Button variant="ghost" size="sm" @click="showOptionsPanel = false">✕</Button>
|
||||
<Button variant="ghost" size="sm" @click="showOptionsPanel = false" :aria-label="t('common.close')">✕</Button>
|
||||
</div>
|
||||
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Mapping Dialog Overlay -->
|
||||
<FieldMappingDialog
|
||||
:open="showFieldMappingDialog"
|
||||
:mappings="activeConfig?.options.fieldMappings ?? []"
|
||||
:source-db-type="sourceDbType"
|
||||
:target-db-type="targetDbType"
|
||||
:source-connection-id="sourceConnectionId"
|
||||
:source-database="sourceDatabase"
|
||||
:target-connection-id="targetConnectionId"
|
||||
:target-database="targetDatabase"
|
||||
@update:open="showFieldMappingDialog = $event"
|
||||
@save="handleFieldMappingsUpdate"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +147,36 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Advanced options -->
|
||||
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.advancedSection") }}</div>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
|
||||
<div v-if="localOptions.detectRenames || localOptions.detectTableRenames" class="flex items-center gap-2 pt-1">
|
||||
<input id="schema-diff-rename-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.renameThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
|
||||
<span class="text-xs font-mono w-10 text-right">{{ localOptions.renameThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-else class="pt-1 text-xs text-muted-foreground italic">
|
||||
{{ t("schemaDiff.options.detectRenames") }}
|
||||
</div>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<input id="schema-diff-compat-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.compatibilityThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
|
||||
<span class="text-xs font-mono w-10 text-right">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
|
||||
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
|
||||
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
|
||||
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
|
||||
|
||||
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.batchSection") }}</div>
|
||||
|
||||
<div class="flex items-center gap-2 col-span-2 pt-1">
|
||||
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, ShieldAlert } from "@lucide/vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface TagViolation {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
tagName: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
violations: TagViolation[];
|
||||
blocking: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
registerTag: [tagName: string];
|
||||
removeTag: [tagName: string];
|
||||
ignore: [tagName: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-md bg-card">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<ShieldAlert class="w-4 h-4 text-amber-500" />
|
||||
<h3 class="text-sm font-medium">{{ t("strictTag.title") }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="blocking" class="mx-3 mt-3 flex items-center gap-2 px-3 py-2 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-900/30">
|
||||
<AlertTriangle class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{{ t("strictTag.blockingExecution") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="violations.length === 0" class="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{{ t("strictTag.noViolations") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="px-3 pt-3 pb-1 text-xs text-muted-foreground">
|
||||
{{ t("strictTag.violationsFound", { count: violations.length }) }}
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
<div class="border rounded overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.fileName") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.lineNumber") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.tagName") }}</th>
|
||||
<th class="text-left px-2 py-1.5 font-medium text-muted-foreground">{{ t("strictTag.suggestion") }}</th>
|
||||
<th class="text-right px-2 py-1.5 font-medium text-muted-foreground">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in violations" :key="`${v.fileName}:${v.lineNumber}:${v.tagName}`" class="border-t">
|
||||
<td class="px-2 py-1.5 font-mono">{{ v.fileName }}</td>
|
||||
<td class="px-2 py-1.5">{{ v.lineNumber }}</td>
|
||||
<td class="px-2 py-1.5 font-mono">{{ v.tagName }}</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">{{ v.suggestion }}</td>
|
||||
<td class="px-2 py-1.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors" @click="emit('registerTag', v.tagName)">
|
||||
{{ t("strictTag.registerTag") }}
|
||||
</button>
|
||||
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors" @click="emit('removeTag', v.tagName)">
|
||||
{{ t("strictTag.removeTag") }}
|
||||
</button>
|
||||
<button class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/40 text-muted-foreground hover:bg-muted/60 transition-colors" @click="emit('ignore', v.tagName)">
|
||||
{{ t("strictTag.ignore") }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4000,10 +4000,54 @@ export default {
|
||||
port: "Port",
|
||||
copied: "Copied to clipboard",
|
||||
saveConfigPrompt: "Please enter config name:",
|
||||
defaultConfigName: "Default",
|
||||
dependsOn: "depends on",
|
||||
dependedBy: "depended by",
|
||||
close: "Close",
|
||||
searchConnection: "Search connections...",
|
||||
searchDatabase: "Search databases...",
|
||||
searchSchema: "Search schemas...",
|
||||
openFieldMapping: "Field Mapping",
|
||||
fieldMapping: {
|
||||
title: "Field Type Mapping",
|
||||
sameTypeHint: "Source and target are the same database type. Field mapping is only needed when comparing across different database types.",
|
||||
preset: "Built-in Preset",
|
||||
selectPreset: "Select a preset...",
|
||||
noMappings: "No field mappings defined",
|
||||
sourceType: "Source Type",
|
||||
targetType: "Target Type",
|
||||
paramStrategy: "Param Strategy",
|
||||
strategyPreserve: "Preserve",
|
||||
strategyStrip: "Strip",
|
||||
strategyCustom: "Custom",
|
||||
customParamsPlaceholder: "e.g. (100)",
|
||||
addMapping: "Add Mapping",
|
||||
autoGenerate: "Auto-Generate",
|
||||
import: "Import",
|
||||
export: "Export",
|
||||
done: "Done",
|
||||
importTypeMismatch: "The imported mappings were exported for {src} → {tgt}, but the current comparison is {curSrc} → {curTgt}. The type mappings may not be accurate. Continue anyway?",
|
||||
importSuccess: "Field mappings imported successfully",
|
||||
importError: "Failed to import field mappings. Please check the JSON format.",
|
||||
clearMappings: "Clear",
|
||||
staleMappingWarning: "The database type has changed from {prevSrc} → {prevTgt} to {curSrc} → {curTgt}. Existing field mappings may no longer be valid. Clear them?",
|
||||
},
|
||||
deployMode: "Deploy",
|
||||
forwardSql: "Forward SQL",
|
||||
rollbackSql: "Rollback SQL",
|
||||
rollbackMode: "Rollback",
|
||||
deployMixed: "Deployment partially completed. Some statements may already be applied ({executedCount}/{statementCount}). DDL may not be transactional.",
|
||||
deployRolledBack: "All changes have been rolled back.",
|
||||
rollbackIncompleteBlocked: "Rollback SQL is incomplete (missing objects). Execution is blocked.",
|
||||
rollbackIncompleteBanner: "Rollback is incomplete — missing trigger/table DDL. Fix missing objects before executing.",
|
||||
deployMixedTitle: "Partially Deployed",
|
||||
deployRolledBackTitle: "Rolled Back",
|
||||
deployMixedWarning: "Some statements may already be applied while others failed. The database may be in an inconsistent state. Review the transaction log and take manual corrective action before retrying.",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "Rollback Comparison",
|
||||
forwardSql: "Forward SQL",
|
||||
rollbackSql: "Rollback SQL",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "Compare Options",
|
||||
@@ -4052,6 +4096,17 @@ export default {
|
||||
cascadeDelete: "Use CASCADE delete",
|
||||
sequenceLastValues: "Compare sequence last values",
|
||||
compareColumnOrder: "Compare column order",
|
||||
detectRenames: "Detect renames",
|
||||
detectTableRenames: "Detect table renames",
|
||||
enableRollback: "Enable rollback",
|
||||
advancedSection: "Advanced",
|
||||
renameThreshold: "Rename threshold",
|
||||
compatibilityThreshold: "Compatibility threshold",
|
||||
sourceDialect: "Source dialect",
|
||||
targetDialect: "Target dialect",
|
||||
batchSection: "Batch",
|
||||
batchPatterns: "Table patterns (comma-separated)",
|
||||
dialectAuto: "auto",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -3785,6 +3785,50 @@ export default withEnglishFallback({
|
||||
searchConnection: "Buscar conexiones...",
|
||||
searchDatabase: "Buscar bases de datos...",
|
||||
searchSchema: "Buscar esquemas...",
|
||||
defaultConfigName: "Predeterminado",
|
||||
dependsOn: "depende de",
|
||||
dependedBy: "dependido por",
|
||||
openFieldMapping: "Mapeo de Campos",
|
||||
fieldMapping: {
|
||||
title: "Mapeo de Tipos de Campo",
|
||||
sameTypeHint: "Origen y destino son del mismo tipo de base de datos. El mapeo de campos solo es necesario al comparar entre diferentes tipos de bases de datos.",
|
||||
preset: "Preajuste Integrado",
|
||||
selectPreset: "Seleccionar un preajuste...",
|
||||
noMappings: "No hay mapeos de campos definidos",
|
||||
sourceType: "Tipo de Origen",
|
||||
targetType: "Tipo de Destino",
|
||||
paramStrategy: "Estrategia de Parámetros",
|
||||
strategyPreserve: "Preservar",
|
||||
strategyStrip: "Eliminar",
|
||||
strategyCustom: "Personalizado",
|
||||
customParamsPlaceholder: "ej. (100)",
|
||||
addMapping: "Agregar Mapeo",
|
||||
autoGenerate: "Auto-Generar",
|
||||
import: "Importar",
|
||||
export: "Exportar",
|
||||
done: "Listo",
|
||||
importTypeMismatch: "Los mapeos importados se exportaron para {src} → {tgt}, pero la comparación actual es {curSrc} → {curTgt}. Los mapeos de tipos pueden no ser precisos. ¿Continuar de todos modos?",
|
||||
importSuccess: "Mapeos de campos importados correctamente",
|
||||
importError: "Error al importar mapeos de campos. Verifique el formato JSON.",
|
||||
clearMappings: "Limpiar",
|
||||
staleMappingWarning: "El tipo de base de datos ha cambiado de {prevSrc} → {prevTgt} a {curSrc} → {curTgt}. Los mapeos de campos existentes pueden no ser válidos. ¿Limpiarlos?",
|
||||
},
|
||||
deployMode: "Desplegar",
|
||||
forwardSql: "SQL Directo",
|
||||
rollbackSql: "SQL de Reversión",
|
||||
rollbackMode: "Reversión",
|
||||
deployMixed: "Despliegue parcialmente completado. Algunas declaraciones pueden haberse aplicado ya ({executedCount}/{statementCount}). El DDL puede no ser transaccional.",
|
||||
deployRolledBack: "Todos los cambios han sido revertidos.",
|
||||
rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
|
||||
rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
|
||||
deployMixedTitle: "Parcialmente Desplegado",
|
||||
deployRolledBackTitle: "Revertido",
|
||||
deployMixedWarning: "Algunas declaraciones pueden haberse aplicado mientras que otras fallaron. La base de datos puede estar en un estado inconsistente. Revise el registro de transacciones y tome medidas correctivas manuales antes de reintentar.",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "Comparación de Reversión",
|
||||
forwardSql: "SQL Directo",
|
||||
rollbackSql: "SQL de Reversión",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "Opciones de comparación",
|
||||
@@ -3833,6 +3877,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "Use CASCADE delete",
|
||||
sequenceLastValues: "Compare sequence last values",
|
||||
compareColumnOrder: "Compare column order",
|
||||
detectRenames: "Detectar renombrados",
|
||||
detectTableRenames: "Detectar renombrados de tabla",
|
||||
enableRollback: "Habilitar reversión",
|
||||
advancedSection: "Avanzado",
|
||||
renameThreshold: "Umbral de renombrado",
|
||||
compatibilityThreshold: "Umbral de compatibilidad",
|
||||
sourceDialect: "Dialecto de origen",
|
||||
targetDialect: "Dialecto de destino",
|
||||
batchSection: "Lote",
|
||||
batchPatterns: "Patrones de tabla (separados por comas)",
|
||||
dialectAuto: "automático",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -3783,6 +3783,50 @@ export default withEnglishFallback({
|
||||
searchConnection: "Cerca connessioni...",
|
||||
searchDatabase: "Cerca database...",
|
||||
searchSchema: "Cerca schemi...",
|
||||
defaultConfigName: "Predefinito",
|
||||
dependsOn: "dipende da",
|
||||
dependedBy: "dipendente da",
|
||||
openFieldMapping: "Mappatura Campi",
|
||||
fieldMapping: {
|
||||
title: "Mappatura Tipi di Campo",
|
||||
sameTypeHint: "Origine e destinazione sono lo stesso tipo di database. La mappatura dei campi è necessaria solo quando si confrontano tipi di database diversi.",
|
||||
preset: "Preimpostazione Integrata",
|
||||
selectPreset: "Seleziona una preimpostazione...",
|
||||
noMappings: "Nessuna mappatura di campi definita",
|
||||
sourceType: "Tipo Origine",
|
||||
targetType: "Tipo Destinazione",
|
||||
paramStrategy: "Strategia Parametri",
|
||||
strategyPreserve: "Preserva",
|
||||
strategyStrip: "Rimuovi",
|
||||
strategyCustom: "Personalizzato",
|
||||
customParamsPlaceholder: "es. (100)",
|
||||
addMapping: "Aggiungi Mappatura",
|
||||
autoGenerate: "Genera Automaticamente",
|
||||
import: "Importa",
|
||||
export: "Esporta",
|
||||
done: "Fatto",
|
||||
importTypeMismatch: "Le mappature importate sono state esportate per {src} → {tgt}, ma il confronto corrente è {curSrc} → {curTgt}. Le mappature dei tipi potrebbero non essere accurate. Continuare comunque?",
|
||||
importSuccess: "Mappature dei campi importate con successo",
|
||||
importError: "Importazione mappature campi non riuscita. Verificare il formato JSON.",
|
||||
clearMappings: "Cancella",
|
||||
staleMappingWarning: "Il tipo di database è cambiato da {prevSrc} → {prevTgt} a {curSrc} → {curTgt}. Le mappature dei campi esistenti potrebbero non essere più valide. Cancellarle?",
|
||||
},
|
||||
deployMode: "Distribuisci",
|
||||
forwardSql: "SQL Diretto",
|
||||
rollbackSql: "SQL di Rollback",
|
||||
rollbackMode: "Rollback",
|
||||
deployMixed: "Distribuzione parzialmente completata. Alcune istruzioni potrebbero essere già applicate ({executedCount}/{statementCount}). Il DDL potrebbe non essere transazionale.",
|
||||
deployRolledBack: "Tutte le modifiche sono state annullate.",
|
||||
rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
|
||||
rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
|
||||
deployMixedTitle: "Parzialmente Distribuito",
|
||||
deployRolledBackTitle: "Annullato",
|
||||
deployMixedWarning: "Alcune istruzioni potrebbero essere già applicate mentre altre sono fallite. Il database potrebbe trovarsi in uno stato inconsistente. Controllare il log delle transazioni e intervenire manualmente prima di riprovare.",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "Confronto Rollback",
|
||||
forwardSql: "SQL Diretto",
|
||||
rollbackSql: "SQL di Rollback",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "Opzioni di Confronto",
|
||||
@@ -3831,6 +3875,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "Usa CASCADE delete",
|
||||
sequenceLastValues: "Confronta ultimi valori sequenza",
|
||||
compareColumnOrder: "Confronta ordine colonne",
|
||||
detectRenames: "Rileva ridenominazioni",
|
||||
detectTableRenames: "Rileva ridenominazioni tabelle",
|
||||
enableRollback: "Abilita rollback",
|
||||
advancedSection: "Avanzate",
|
||||
renameThreshold: "Soglia ridenominazione",
|
||||
compatibilityThreshold: "Soglia compatibilità",
|
||||
sourceDialect: "Dialetto origine",
|
||||
targetDialect: "Dialetto destinazione",
|
||||
batchSection: "Batch",
|
||||
batchPatterns: "Pattern tabella (separati da virgola)",
|
||||
dialectAuto: "auto",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -3784,6 +3784,50 @@ export default withEnglishFallback({
|
||||
searchConnection: "接続を検索...",
|
||||
searchDatabase: "データベースを検索...",
|
||||
searchSchema: "スキーマを検索...",
|
||||
defaultConfigName: "デフォルト",
|
||||
dependsOn: "に依存",
|
||||
dependedBy: "から依存される",
|
||||
openFieldMapping: "フィールドマッピング",
|
||||
fieldMapping: {
|
||||
title: "フィールド型マッピング",
|
||||
sameTypeHint: "ソースとターゲットが同じデータベースタイプです。フィールドマッピングは異なるデータベースタイプ間での比較時のみ必要です。",
|
||||
preset: "組み込みプリセット",
|
||||
selectPreset: "プリセットを選択...",
|
||||
noMappings: "フィールドマッピングが定義されていません",
|
||||
sourceType: "ソース型",
|
||||
targetType: "ターゲット型",
|
||||
paramStrategy: "パラメータ戦略",
|
||||
strategyPreserve: "保持",
|
||||
strategyStrip: "除去",
|
||||
strategyCustom: "カスタム",
|
||||
customParamsPlaceholder: "例: (100)",
|
||||
addMapping: "マッピングを追加",
|
||||
autoGenerate: "自動生成",
|
||||
import: "インポート",
|
||||
export: "エクスポート",
|
||||
done: "完了",
|
||||
importTypeMismatch: "インポートされたマッピングは {src} → {tgt} 用にエクスポートされましたが、現在の比較は {curSrc} → {curTgt} です。型マッピングが正確でない可能性があります。続行しますか?",
|
||||
importSuccess: "フィールドマッピングが正常にインポートされました",
|
||||
importError: "フィールドマッピングのインポートに失敗しました。JSON形式を確認してください。",
|
||||
clearMappings: "クリア",
|
||||
staleMappingWarning: "データベースタイプが {prevSrc} → {prevTgt} から {curSrc} → {curTgt} に変更されました。既存のフィールドマッピングは有効でない可能性があります。クリアしますか?",
|
||||
},
|
||||
deployMode: "デプロイ",
|
||||
forwardSql: "フォワードSQL",
|
||||
rollbackSql: "ロールバックSQL",
|
||||
rollbackMode: "ロールバック",
|
||||
deployMixed: "デプロイが部分的に完了しました。一部のステートメントは既に適用済みの可能性があります({executedCount}/{statementCount})。DDL はトランザクション対象外の場合があります。",
|
||||
deployRolledBack: "すべての変更がロールバックされました。",
|
||||
rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
|
||||
rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
|
||||
deployMixedTitle: "部分的にデプロイ",
|
||||
deployRolledBackTitle: "ロールバック済み",
|
||||
deployMixedWarning: "一部のステートメントは既に適用済みで、他のステートメントは失敗した可能性があります。データベースが不整合な状態にある可能性があります。再試行前にトランザクションログを確認し、手動で修正してください。",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "ロールバック比較",
|
||||
forwardSql: "フォワードSQL",
|
||||
rollbackSql: "ロールバックSQL",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "比較オプション",
|
||||
@@ -3832,6 +3876,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "CASCADE削除を使用",
|
||||
sequenceLastValues: "シーケンス最終値を比較",
|
||||
compareColumnOrder: "列順序を比較",
|
||||
detectRenames: "名前変更を検出",
|
||||
detectTableRenames: "テーブル名変更を検出",
|
||||
enableRollback: "ロールバックを有効化",
|
||||
advancedSection: "詳細設定",
|
||||
renameThreshold: "名前変更しきい値",
|
||||
compatibilityThreshold: "互換性しきい値",
|
||||
sourceDialect: "ソース方言",
|
||||
targetDialect: "ターゲット方言",
|
||||
batchSection: "バッチ",
|
||||
batchPatterns: "テーブルパターン(カンマ区切り)",
|
||||
dialectAuto: "自動",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -3785,6 +3785,50 @@ export default withEnglishFallback({
|
||||
searchConnection: "Pesquisar conexões...",
|
||||
searchDatabase: "Pesquisar bancos de dados...",
|
||||
searchSchema: "Pesquisar schemas...",
|
||||
defaultConfigName: "Padrão",
|
||||
dependsOn: "depende de",
|
||||
dependedBy: "dependido por",
|
||||
openFieldMapping: "Mapeamento de Campos",
|
||||
fieldMapping: {
|
||||
title: "Mapeamento de Tipos de Campo",
|
||||
sameTypeHint: "Origem e destino são do mesmo tipo de banco de dados. O mapeamento de campos só é necessário ao comparar entre tipos de banco de dados diferentes.",
|
||||
preset: "Predefinição Integrada",
|
||||
selectPreset: "Selecionar uma predefinição...",
|
||||
noMappings: "Nenhum mapeamento de campos definido",
|
||||
sourceType: "Tipo de Origem",
|
||||
targetType: "Tipo de Destino",
|
||||
paramStrategy: "Estratégia de Parâmetros",
|
||||
strategyPreserve: "Preservar",
|
||||
strategyStrip: "Remover",
|
||||
strategyCustom: "Personalizado",
|
||||
customParamsPlaceholder: "ex.: (100)",
|
||||
addMapping: "Adicionar Mapeamento",
|
||||
autoGenerate: "Auto-Gerar",
|
||||
import: "Importar",
|
||||
export: "Exportar",
|
||||
done: "Concluído",
|
||||
importTypeMismatch: "Os mapeamentos importados foram exportados para {src} → {tgt}, mas a comparação atual é {curSrc} → {curTgt}. Os mapeamentos de tipo podem não ser precisos. Continuar mesmo assim?",
|
||||
importSuccess: "Mapeamentos de campos importados com sucesso",
|
||||
importError: "Falha ao importar mapeamentos de campos. Verifique o formato JSON.",
|
||||
clearMappings: "Limpar",
|
||||
staleMappingWarning: "O tipo de banco de dados mudou de {prevSrc} → {prevTgt} para {curSrc} → {curTgt}. Os mapeamentos de campos existentes podem não ser mais válidos. Limpá-los?",
|
||||
},
|
||||
deployMode: "Implantar",
|
||||
forwardSql: "SQL Direto",
|
||||
rollbackSql: "SQL de Reversão",
|
||||
rollbackMode: "Reversão",
|
||||
deployMixed: "Implantação parcialmente concluída. Algumas instruções podem já ter sido aplicadas ({executedCount}/{statementCount}). DDL pode não ser transacional.",
|
||||
deployRolledBack: "Todas as alterações foram revertidas.",
|
||||
rollbackIncompleteBlocked: "Rollback incomplete (missing objects). Execution blocked.",
|
||||
rollbackIncompleteBanner: "Rollback incomplete — missing trigger/table DDL.",
|
||||
deployMixedTitle: "Parcialmente Implantado",
|
||||
deployRolledBackTitle: "Revertido",
|
||||
deployMixedWarning: "Algumas instruções podem já ter sido aplicadas enquanto outras falharam. O banco de dados pode estar em um estado inconsistente. Revise o log de transações e tome ação corretiva manual antes de tentar novamente.",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "Comparação de Reversão",
|
||||
forwardSql: "SQL Direto",
|
||||
rollbackSql: "SQL de Reversão",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "Opções de Comparação",
|
||||
@@ -3833,6 +3877,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "Usar CASCADE delete",
|
||||
sequenceLastValues: "Comparar últimos valores de sequência",
|
||||
compareColumnOrder: "Comparar ordem das colunas",
|
||||
detectRenames: "Detectar renomeações",
|
||||
detectTableRenames: "Detectar renomeações de tabela",
|
||||
enableRollback: "Habilitar rollback",
|
||||
advancedSection: "Avançado",
|
||||
renameThreshold: "Limiar de renomeação",
|
||||
compatibilityThreshold: "Limiar de compatibilidade",
|
||||
sourceDialect: "Dialeto de origem",
|
||||
targetDialect: "Dialeto de destino",
|
||||
batchSection: "Lote",
|
||||
batchPatterns: "Padrões de tabela (separados por vírgula)",
|
||||
dialectAuto: "automático",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -4001,9 +4001,53 @@ export default withEnglishFallback({
|
||||
configDeleted: "配置已删除",
|
||||
noDeployScriptAll: "暂无所选对象的部署脚本",
|
||||
saveConfigPrompt: "请输入配置名称:",
|
||||
defaultConfigName: "默认",
|
||||
dependsOn: "依赖于",
|
||||
dependedBy: "被依赖",
|
||||
searchConnection: "搜索连接...",
|
||||
searchDatabase: "搜索数据库...",
|
||||
searchSchema: "搜索模式...",
|
||||
openFieldMapping: "字段映射",
|
||||
fieldMapping: {
|
||||
title: "字段类型映射",
|
||||
sameTypeHint: "源和目标数据库类型相同,仅在不同类型数据库之间比较时才需要字段映射。",
|
||||
preset: "内置预设",
|
||||
selectPreset: "选择预设...",
|
||||
noMappings: "暂无字段映射",
|
||||
sourceType: "源类型",
|
||||
targetType: "目标类型",
|
||||
paramStrategy: "参数策略",
|
||||
strategyPreserve: "保留",
|
||||
strategyStrip: "去除",
|
||||
strategyCustom: "自定义",
|
||||
customParamsPlaceholder: "如 (100)",
|
||||
addMapping: "添加映射",
|
||||
autoGenerate: "自动生成",
|
||||
import: "导入",
|
||||
export: "导出",
|
||||
done: "完成",
|
||||
importTypeMismatch: "导入的映射文件是 {src} → {tgt} 的,但当前比较是 {curSrc} → {curTgt}。类型映射可能不准确。是否继续?",
|
||||
importSuccess: "字段映射导入成功",
|
||||
importError: "字段映射导入失败,请检查 JSON 格式。",
|
||||
clearMappings: "清空",
|
||||
staleMappingWarning: "数据库类型已从 {prevSrc} → {prevTgt} 变更为 {curSrc} → {curTgt},现有字段映射可能不再有效。是否清除?",
|
||||
},
|
||||
deployMode: "部署",
|
||||
forwardSql: "正向 SQL",
|
||||
rollbackSql: "回滚 SQL",
|
||||
rollbackMode: "回滚",
|
||||
deployMixed: "部署部分完成,部分语句可能已生效({executedCount}/{statementCount})。DDL 可能不支持事务。",
|
||||
deployRolledBack: "所有变更已回滚。",
|
||||
rollbackIncompleteBlocked: "回滚 SQL 不完整(存在缺失对象),已禁止执行。",
|
||||
rollbackIncompleteBanner: "回滚不完整 — 缺少触发器/表 DDL。请先补全缺失对象再执行。",
|
||||
deployMixedTitle: "部分已部署",
|
||||
deployRolledBackTitle: "已回滚",
|
||||
deployMixedWarning: "部分语句可能已生效,其余语句失败。数据库可能处于不一致状态,请在重试前检查事务日志并手动处理。",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "回滚对比",
|
||||
forwardSql: "正向 SQL",
|
||||
rollbackSql: "回滚 SQL",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "比较选项",
|
||||
@@ -4052,6 +4096,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "使用级联删除",
|
||||
sequenceLastValues: "比较序列最后值",
|
||||
compareColumnOrder: "比较字段顺序",
|
||||
detectRenames: "检测重命名",
|
||||
detectTableRenames: "检测表重命名",
|
||||
enableRollback: "启用回滚",
|
||||
advancedSection: "高级选项",
|
||||
renameThreshold: "重命名阈值",
|
||||
compatibilityThreshold: "兼容性阈值",
|
||||
sourceDialect: "源数据库方言",
|
||||
targetDialect: "目标数据库方言",
|
||||
batchSection: "批次模式",
|
||||
batchPatterns: "表名模式(逗号分隔)",
|
||||
dialectAuto: "自动",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -3456,6 +3456,50 @@ export default withEnglishFallback({
|
||||
searchConnection: "搜尋連線...",
|
||||
searchDatabase: "搜尋資料庫...",
|
||||
searchSchema: "搜尋結構描述...",
|
||||
defaultConfigName: "預設",
|
||||
dependsOn: "依賴於",
|
||||
dependedBy: "被依賴",
|
||||
openFieldMapping: "欄位映射",
|
||||
fieldMapping: {
|
||||
title: "欄位類型映射",
|
||||
sameTypeHint: "來源與目標資料庫類型相同,僅在不同類型資料庫之間比較時才需要欄位映射。",
|
||||
preset: "內建預設",
|
||||
selectPreset: "選擇預設...",
|
||||
noMappings: "暫無欄位映射",
|
||||
sourceType: "來源類型",
|
||||
targetType: "目標類型",
|
||||
paramStrategy: "參數策略",
|
||||
strategyPreserve: "保留",
|
||||
strategyStrip: "去除",
|
||||
strategyCustom: "自訂",
|
||||
customParamsPlaceholder: "如 (100)",
|
||||
addMapping: "新增映射",
|
||||
autoGenerate: "自動生成",
|
||||
import: "匯入",
|
||||
export: "匯出",
|
||||
done: "完成",
|
||||
importTypeMismatch: "匯入的映射檔案是 {src} → {tgt} 的,但目前比較是 {curSrc} → {curTgt}。類型映射可能不準確。是否繼續?",
|
||||
importSuccess: "欄位映射匯入成功",
|
||||
importError: "欄位映射匯入失敗,請檢查 JSON 格式。",
|
||||
clearMappings: "清空",
|
||||
staleMappingWarning: "資料庫類型已從 {prevSrc} → {prevTgt} 變更為 {curSrc} → {curTgt},現有欄位映射可能不再有效。是否清除?",
|
||||
},
|
||||
deployMode: "部署",
|
||||
forwardSql: "正向 SQL",
|
||||
rollbackSql: "回溯 SQL",
|
||||
rollbackMode: "回溯",
|
||||
deployMixed: "部署部分完成,部分語句可能已生效({executedCount}/{statementCount})。DDL 可能不支援交易。",
|
||||
deployRolledBack: "所有變更已回溯。",
|
||||
rollbackIncompleteBlocked: "Rollback SQL is incomplete (missing objects). Execution is blocked.",
|
||||
rollbackIncompleteBanner: "Rollback is incomplete — missing trigger/table DDL. Fix missing objects before executing.",
|
||||
deployMixedTitle: "部分已部署",
|
||||
deployRolledBackTitle: "已回溯",
|
||||
deployMixedWarning: "部分語句可能已生效,其餘語句失敗。資料庫可能處於不一致狀態,請在重試前檢查交易日誌並手動處理。",
|
||||
},
|
||||
rollbackComparison: {
|
||||
title: "回溯對比",
|
||||
forwardSql: "正向 SQL",
|
||||
rollbackSql: "回溯 SQL",
|
||||
},
|
||||
schemaDiff: {
|
||||
optionsTitle: "比較選項",
|
||||
@@ -3504,6 +3548,17 @@ export default withEnglishFallback({
|
||||
cascadeDelete: "使用級聯刪除",
|
||||
sequenceLastValues: "比較序列最後值",
|
||||
compareColumnOrder: "比較欄位順序",
|
||||
detectRenames: "檢測重新命名",
|
||||
detectTableRenames: "檢測表重新命名",
|
||||
enableRollback: "啟用回溯",
|
||||
advancedSection: "進階選項",
|
||||
renameThreshold: "重新命名閾值",
|
||||
compatibilityThreshold: "相容性閾值",
|
||||
sourceDialect: "來源資料庫方言",
|
||||
targetDialect: "目標資料庫方言",
|
||||
batchSection: "批次模式",
|
||||
batchPatterns: "資料表模式(逗號分隔)",
|
||||
dialectAuto: "自動",
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mocks.invoke,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("listDialectDataTypes backend adapters", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the Tauri dialect command", async () => {
|
||||
mocks.invoke.mockResolvedValue(["INTEGER", "TEXT"]);
|
||||
const { listDialectDataTypes } = await import("@/lib/backend/tauri");
|
||||
|
||||
await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("list_dialect_data_types", { dialectName: "PostgreSQL" });
|
||||
});
|
||||
|
||||
it("uses the matching Web dialect route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(["INTEGER", "TEXT"]),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { listDialectDataTypes } = await import("@/lib/backend/http");
|
||||
|
||||
await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/dialect/data-types?dialect_name=PostgreSQL");
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,7 @@ export const listExtensions = forward("listExtensions");
|
||||
export const listAvailableExtensions = forward("listAvailableExtensions");
|
||||
export const prepareSchemaDiff = forward("prepareSchemaDiff");
|
||||
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
|
||||
export const listDialectDataTypes = forward("listDialectDataTypes");
|
||||
|
||||
// Query
|
||||
export const executeQuery = forward("executeQuery");
|
||||
@@ -182,6 +183,7 @@ export const executeMulti = forward("executeMulti");
|
||||
export const executeMultiWithProgress = forward("executeMultiWithProgress");
|
||||
export const executeBatch = forward("executeBatch");
|
||||
export const executeScript = forward("executeScript");
|
||||
export const executeScriptWith2pc = forward("executeScriptWith2pc");
|
||||
export const executeInTransaction = forward("executeInTransaction");
|
||||
export const beginManualTransaction = forward("beginManualTransaction");
|
||||
export const executeInManualTransaction = forward("executeInManualTransaction");
|
||||
|
||||
@@ -776,6 +776,10 @@ export async function listAvailableExtensions(connectionId: string, database: st
|
||||
return get(`/api/schema/available-extensions?${qs({ connection_id: connectionId, database })}`);
|
||||
}
|
||||
|
||||
export async function listDialectDataTypes(dialectName: string): Promise<string[]> {
|
||||
return get(`/api/dialect/data-types?${qs({ dialect_name: dialectName })}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -867,6 +871,10 @@ export async function executeScript(connectionId: string, database: string, sql:
|
||||
return post("/api/query/execute-script", { connectionId, database, sql, schema });
|
||||
}
|
||||
|
||||
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<any> {
|
||||
return post("/api/query/execute-script-2pc", { connectionId, database, statements, schema });
|
||||
}
|
||||
|
||||
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise<QueryResult> {
|
||||
return post("/api/query/execute-in-transaction", { connectionId, database, statements, schema });
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
SavedSqlLibrary,
|
||||
SshConfigHostEntry,
|
||||
TunnelProfile,
|
||||
TransactionLog,
|
||||
} from "@/types/database";
|
||||
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
@@ -999,6 +1000,10 @@ export async function executeScript(connectionId: string, database: string, sql:
|
||||
return invoke("execute_script", { connectionId, database, sql, schema });
|
||||
}
|
||||
|
||||
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<TransactionLog> {
|
||||
return invoke("execute_script_with_2pc", { connectionId, database, statements, schema });
|
||||
}
|
||||
|
||||
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise<QueryResult> {
|
||||
return invoke("execute_in_transaction", { connectionId, database, statements, schema });
|
||||
}
|
||||
@@ -1284,6 +1289,10 @@ export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions):
|
||||
return invoke("prepare_schema_diff", { options });
|
||||
}
|
||||
|
||||
export async function listDialectDataTypes(dialectName: string): Promise<string[]> {
|
||||
return invoke("list_dialect_data_types", { dialectName });
|
||||
}
|
||||
|
||||
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string, functionDiffs?: FunctionDiff[], sequenceDiffs?: SequenceDiff[], ruleDiffs?: RuleDiff[], ownerDiffs?: OwnerDiff[], cascadeDelete?: boolean): Promise<string> {
|
||||
return invoke("generate_schema_sync_sql", {
|
||||
diffs,
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export type DataCompareCellValue = QueryResult["rows"][number][number];
|
||||
|
||||
export type SamplingStrategy = "Random" | "ExtremeValues" | "Hybrid";
|
||||
|
||||
export interface DegradationThreshold {
|
||||
fullCompareMaxRows: number;
|
||||
sampleMaxRows: number;
|
||||
sampleSize: number;
|
||||
extremeSampleCount: number;
|
||||
}
|
||||
|
||||
export interface DataCompareChangedCell {
|
||||
column: string;
|
||||
source: DataCompareCellValue;
|
||||
@@ -57,6 +66,9 @@ export interface DataCompareFromTablesOptions {
|
||||
columns: string[];
|
||||
keyColumns: string[];
|
||||
fetchBatchSize?: number;
|
||||
degradationThreshold?: DegradationThreshold;
|
||||
samplingStrategy?: SamplingStrategy;
|
||||
enableChecksum?: boolean;
|
||||
}
|
||||
|
||||
export interface DataCompareMissingTargetOptions {
|
||||
@@ -70,6 +82,9 @@ export interface DataCompareMissingTargetOptions {
|
||||
targetTable: string;
|
||||
keyColumns: string[];
|
||||
fetchBatchSize?: number;
|
||||
degradationThreshold?: DegradationThreshold;
|
||||
samplingStrategy?: SamplingStrategy;
|
||||
enableChecksum?: boolean;
|
||||
}
|
||||
|
||||
export interface DataCompareFromTablesPreparation extends DataComparePreparation {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FieldMappingEntry } from "@/types/schemaDiff";
|
||||
|
||||
export interface FieldMappingPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
sourceDialect: string;
|
||||
targetDialect: string;
|
||||
mappings: FieldMappingEntry[];
|
||||
}
|
||||
|
||||
export const FIELD_MAPPING_PRESETS: FieldMappingPreset[] = [
|
||||
{
|
||||
id: "mysql-to-dameng",
|
||||
label: "MySQL → 达梦 (DM)",
|
||||
sourceDialect: "mysql",
|
||||
targetDialect: "dameng",
|
||||
mappings: [
|
||||
{ sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
|
||||
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
|
||||
{ sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "INT", targetType: "INT", paramStrategy: "preserve" },
|
||||
{ sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
|
||||
{ sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
|
||||
{ sourceType: "FLOAT", targetType: "FLOAT", paramStrategy: "preserve" },
|
||||
{ sourceType: "DOUBLE", targetType: "DOUBLE", paramStrategy: "preserve" },
|
||||
{ sourceType: "DATE", targetType: "DATE", paramStrategy: "preserve" },
|
||||
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "JSON", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "TINYINT", targetType: "TINYINT", paramStrategy: "preserve" },
|
||||
{ sourceType: "SMALLINT", targetType: "SMALLINT", paramStrategy: "preserve" },
|
||||
{ sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mysql-to-postgresql",
|
||||
label: "MySQL → PostgreSQL",
|
||||
sourceDialect: "mysql",
|
||||
targetDialect: "postgresql",
|
||||
mappings: [
|
||||
{ sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
|
||||
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
|
||||
{ sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
|
||||
{ sourceType: "INT", targetType: "INTEGER", paramStrategy: "preserve" },
|
||||
{ sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
|
||||
{ sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
|
||||
{ sourceType: "FLOAT", targetType: "REAL", paramStrategy: "preserve" },
|
||||
{ sourceType: "DOUBLE", targetType: "DOUBLE PRECISION", paramStrategy: "preserve" },
|
||||
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "BLOB", targetType: "BYTEA", paramStrategy: "strip" },
|
||||
{ sourceType: "JSON", targetType: "JSONB", paramStrategy: "preserve" },
|
||||
{ sourceType: "TINYINT", targetType: "SMALLINT", paramStrategy: "preserve" },
|
||||
{ sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mysql-to-oracle",
|
||||
label: "MySQL → Oracle",
|
||||
sourceDialect: "mysql",
|
||||
targetDialect: "oracle",
|
||||
mappings: [
|
||||
{ sourceType: "VARCHAR", targetType: "VARCHAR2", paramStrategy: "preserve" },
|
||||
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
|
||||
{ sourceType: "TEXT", targetType: "CLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "TINYTEXT", targetType: "CLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "MEDIUMTEXT", targetType: "CLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "LONGTEXT", targetType: "CLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "INT", targetType: "NUMBER", paramStrategy: "preserve" },
|
||||
{ sourceType: "BIGINT", targetType: "NUMBER", paramStrategy: "preserve" },
|
||||
{ sourceType: "DECIMAL", targetType: "NUMBER", paramStrategy: "preserve" },
|
||||
{ sourceType: "FLOAT", targetType: "BINARY_FLOAT", paramStrategy: "preserve" },
|
||||
{ sourceType: "DOUBLE", targetType: "BINARY_DOUBLE", paramStrategy: "preserve" },
|
||||
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
|
||||
{ sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "JSON", targetType: "CLOB", paramStrategy: "strip" },
|
||||
{ sourceType: "BOOLEAN", targetType: "NUMBER(1)", paramStrategy: "custom", customParams: "(1)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findPreset(sourceDialect: string, targetDialect: string): FieldMappingPreset | undefined {
|
||||
// Look for exact forward match
|
||||
const forward = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === sourceDialect && p.targetDialect === targetDialect);
|
||||
if (forward) return forward;
|
||||
|
||||
// Look for reverse match and auto-generate bidirectional preset
|
||||
const reverse = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === targetDialect && p.targetDialect === sourceDialect);
|
||||
if (reverse) {
|
||||
return {
|
||||
id: `${reverse.id}-reverse`,
|
||||
label: `${reverse.label.split(" → ").reverse().join(" → ")}`,
|
||||
sourceDialect,
|
||||
targetDialect,
|
||||
mappings: reverse.mappings.map((m) => ({
|
||||
sourceType: m.targetType,
|
||||
targetType: m.sourceType,
|
||||
paramStrategy: m.paramStrategy === "custom" ? "strip" : m.paramStrategy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
|
||||
|
||||
const t = (key: string, params?: Record<string, any>) => {
|
||||
const fallback: Record<string, string> = {
|
||||
"diff.executeSuccess": "Executed successfully",
|
||||
"diff.deployMixed": "Deployment partially completed. Some statements may already be applied ({executedCount}/{statementCount}). DDL may not be transactional.",
|
||||
"diff.deployRolledBack": "All changes have been rolled back.",
|
||||
"diff.deployFailed": "Deployment failed: {status}",
|
||||
};
|
||||
let msg = fallback[key] || key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
msg = msg.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
describe("buildDeployTxResult", () => {
|
||||
it("returns success for committed transaction", () => {
|
||||
const result = buildDeployTxResult({ status: "committed", transaction_id: "tx1", executedCount: 2 }, t);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe("committed");
|
||||
expect(result.message).toBe("Executed successfully");
|
||||
expect(result.executedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns failure with mixed status for partially committed", () => {
|
||||
const result = buildDeployTxResult(
|
||||
{
|
||||
status: "mixed",
|
||||
participants: [{ id: "1" }, { id: "2" }],
|
||||
executedCount: 1,
|
||||
statementCount: 2,
|
||||
},
|
||||
t,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe("mixed");
|
||||
expect(result.message).toContain("partially completed");
|
||||
expect(result.message).toContain("1/2");
|
||||
expect(result.message).toContain("may not be transactional");
|
||||
expect(result.executedCount).toBe(1);
|
||||
expect(result.statementCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns failure with rolled_back status and error detail", () => {
|
||||
const result = buildDeployTxResult({ status: "rolled_back", error: "syntax error near SELECT", executedCount: 0, statementCount: 2 }, t);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe("rolled_back");
|
||||
expect(result.message).toContain("rolled back");
|
||||
expect(result.message).toContain("syntax error");
|
||||
expect(result.executedCount).toBe(0);
|
||||
expect(result.statementCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns failure for unknown status", () => {
|
||||
const result = buildDeployTxResult({ status: "unknown" }, t);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.message).toContain("unknown");
|
||||
});
|
||||
|
||||
it("returns failure for null/undefined txLog", () => {
|
||||
const result = buildDeployTxResult(null, t);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("maps MySQL-style partial DDL failure (1 of 2 applied) for UI", () => {
|
||||
const result = buildDeployTxResult(
|
||||
{
|
||||
status: "mixed",
|
||||
executedCount: 1,
|
||||
statementCount: 2,
|
||||
error: "Statement 2 failed: table already exists",
|
||||
metadata: { atomicity: "partial_effects_possible", ddl_atomic: false },
|
||||
},
|
||||
t,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe("mixed");
|
||||
expect(result.executedCount).toBe(1);
|
||||
expect(result.statementCount).toBe(2);
|
||||
expect(result.message).toContain("1/2");
|
||||
expect(result.message).toMatch(/may already be applied|may not be transactional/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface DeployTxResult {
|
||||
success: boolean;
|
||||
status?: string;
|
||||
message: string;
|
||||
affectedRows?: number;
|
||||
error?: string;
|
||||
executedCount?: number;
|
||||
statementCount?: number;
|
||||
}
|
||||
|
||||
export function buildDeployTxResult(txLog: any, t: (key: string, params?: Record<string, any>) => string): DeployTxResult {
|
||||
const status = txLog?.status;
|
||||
const error = txLog?.error ?? txLog?.metadata?.error;
|
||||
const executedCount = txLog?.executedCount ?? txLog?.executed_count;
|
||||
const statementCount = txLog?.statementCount ?? txLog?.statement_count;
|
||||
const affectedRows = txLog?.metadata?.affected_rows ?? txLog?.affectedRows;
|
||||
|
||||
if (status === "committed") {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
message: t("diff.executeSuccess"),
|
||||
affectedRows,
|
||||
executedCount,
|
||||
statementCount,
|
||||
};
|
||||
}
|
||||
if (status === "mixed") {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message: t("diff.deployMixed", {
|
||||
participants: txLog?.participants?.length ?? 0,
|
||||
executedCount: executedCount ?? 0,
|
||||
statementCount: statementCount ?? 0,
|
||||
}),
|
||||
error,
|
||||
executedCount,
|
||||
statementCount,
|
||||
};
|
||||
}
|
||||
if (status === "rolled_back") {
|
||||
const detail = error ? `: ${error}` : "";
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message: `${t("diff.deployRolledBack")}${detail}`,
|
||||
error,
|
||||
executedCount: executedCount ?? 0,
|
||||
statementCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: status || "unknown",
|
||||
message: t("diff.deployFailed", { status: status || "unknown" }),
|
||||
error,
|
||||
executedCount,
|
||||
statementCount,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,111 @@
|
||||
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
|
||||
|
||||
const DIALECT_KIND_MAP: Record<string, string> = {
|
||||
mysql: "mysql",
|
||||
doris: "mysql",
|
||||
starrocks: "mysql",
|
||||
goldendb: "mysql",
|
||||
sundb: "mysql",
|
||||
databend: "mysql",
|
||||
gbase: "mysql",
|
||||
postgres: "postgres",
|
||||
gaussdb: "postgres",
|
||||
kwdb: "postgres",
|
||||
opengauss: "postgres",
|
||||
highgo: "postgres",
|
||||
vastbase: "postgres",
|
||||
kingbase: "postgres",
|
||||
firebird: "postgres",
|
||||
redshift: "postgres",
|
||||
vertica: "postgres",
|
||||
exasol: "postgres",
|
||||
sqlite: "sqlite",
|
||||
rqlite: "sqlite",
|
||||
turso: "sqlite",
|
||||
duckdb: "duckdb",
|
||||
sqlserver: "sql_server",
|
||||
access: "sql_server",
|
||||
oracle: "oracle",
|
||||
dameng: "oracle",
|
||||
"oceanbase-oracle": "oracle",
|
||||
iris: "oracle",
|
||||
yashandb: "oracle",
|
||||
xugu: "oracle",
|
||||
h2: "h2",
|
||||
clickhouse: "click_house",
|
||||
manticoresearch: "manticore_search",
|
||||
informix: "informix",
|
||||
questdb: "questdb",
|
||||
};
|
||||
|
||||
export function databaseTypeToDialectKind(dbType: DatabaseType): string {
|
||||
return DIALECT_KIND_MAP[dbType] ?? "unsupported";
|
||||
}
|
||||
|
||||
const DIALECT_ALIAS_MAP: Record<string, string> = {
|
||||
access: "sql_server",
|
||||
mssql: "sql_server",
|
||||
"sql server": "sql_server",
|
||||
postgresql: "postgres",
|
||||
sqlite3: "sqlite",
|
||||
"oceanbase-oracle": "oracle",
|
||||
oceanbase: "oracle",
|
||||
dameng: "oracle",
|
||||
iris: "oracle",
|
||||
yashandb: "oracle",
|
||||
xugu: "oracle",
|
||||
gaussdb: "postgres",
|
||||
kwdb: "postgres",
|
||||
opengauss: "postgres",
|
||||
highgo: "postgres",
|
||||
vastbase: "postgres",
|
||||
kingbase: "postgres",
|
||||
firebird: "postgres",
|
||||
redshift: "postgres",
|
||||
vertica: "postgres",
|
||||
exasol: "postgres",
|
||||
doris: "mysql",
|
||||
starrocks: "mysql",
|
||||
goldendb: "mysql",
|
||||
sundb: "mysql",
|
||||
databend: "mysql",
|
||||
gbase: "mysql",
|
||||
rqlite: "sqlite",
|
||||
turso: "sqlite",
|
||||
manticore: "manticore_search",
|
||||
questdb: "questdb",
|
||||
clickhouse: "click_house",
|
||||
};
|
||||
|
||||
export function normalizeDialectKind(input: string): string {
|
||||
const lower = input.trim().toLowerCase();
|
||||
if (DIALECT_KIND_MAP[lower]) return DIALECT_KIND_MAP[lower];
|
||||
if (DIALECT_ALIAS_MAP[lower]) return DIALECT_ALIAS_MAP[lower];
|
||||
return lower;
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
if (m === 0) return n;
|
||||
if (n === 0) return m;
|
||||
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
const curr = [i];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : Math.min(prev[j], curr[j - 1], prev[j - 1]) + 1;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
function nameSimilarity(a: string, b: string): number {
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
if (maxLen === 0) return 1;
|
||||
return 1 - levenshteinDistance(a, b) / maxLen;
|
||||
}
|
||||
|
||||
export interface ColumnDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
name: string;
|
||||
@@ -65,7 +171,7 @@ export interface OwnerDiff {
|
||||
}
|
||||
|
||||
export interface TableDiff {
|
||||
type: "added" | "removed" | "modified";
|
||||
type: "added" | "removed" | "modified" | "renamed";
|
||||
objectType?: "table" | "view";
|
||||
name: string;
|
||||
columns?: ColumnDiff[];
|
||||
@@ -88,6 +194,11 @@ export interface TableSchemaDetail {
|
||||
ddl?: string;
|
||||
}
|
||||
|
||||
export interface FieldMappingEntry {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
}
|
||||
|
||||
export interface SchemaDiffPreparationOptions {
|
||||
sourceTables: TableInfo[];
|
||||
targetTables: TableInfo[];
|
||||
@@ -106,8 +217,58 @@ export interface SchemaDiffPreparationOptions {
|
||||
ignoreComments?: boolean;
|
||||
cascadeDelete?: boolean;
|
||||
compareColumnOrder?: boolean;
|
||||
detectRenames?: boolean;
|
||||
detectTableRenames?: boolean;
|
||||
renameThreshold?: number;
|
||||
enableRollback?: boolean;
|
||||
batchPatterns?: string[];
|
||||
sourceDialect?: string;
|
||||
targetDialect?: string;
|
||||
compatibilityThreshold?: number;
|
||||
fieldMappings?: FieldMappingEntry[];
|
||||
}
|
||||
|
||||
export interface RenameCandidate {
|
||||
sourceName: string;
|
||||
targetName: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface CompatibilityWarning {
|
||||
table: string;
|
||||
column: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
risk: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PermissionDiff {
|
||||
objectName: string;
|
||||
permissionType: string;
|
||||
sourcePermission: string | null;
|
||||
targetPermission: string | null;
|
||||
}
|
||||
|
||||
export interface DependencyNode {
|
||||
tableName: string;
|
||||
dependsOn: string[];
|
||||
dependedBy: string[];
|
||||
}
|
||||
|
||||
export interface DependencyGraph {
|
||||
nodes: DependencyNode[];
|
||||
}
|
||||
|
||||
export interface MissingRollbackObject {
|
||||
kind: string;
|
||||
name: string;
|
||||
table?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type RollbackCompleteness = "complete" | "incomplete";
|
||||
|
||||
export interface SchemaDiffPreparation {
|
||||
diffs: TableDiff[];
|
||||
functionDiffs?: FunctionDiff[];
|
||||
@@ -115,6 +276,15 @@ export interface SchemaDiffPreparation {
|
||||
ruleDiffs?: RuleDiff[];
|
||||
ownerDiffs?: OwnerDiff[];
|
||||
syncSql: string;
|
||||
rollbackSyncSql?: string;
|
||||
rollbackCompleteness?: RollbackCompleteness;
|
||||
missingRollbackObjects?: MissingRollbackObject[];
|
||||
renameCandidates?: RenameCandidate[];
|
||||
rollbackGraph?: unknown;
|
||||
compatibilityWarnings?: CompatibilityWarning[];
|
||||
permissionDiffs?: PermissionDiff[];
|
||||
permissionSyncSql?: string;
|
||||
dependencyGraph?: DependencyGraph;
|
||||
}
|
||||
|
||||
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
|
||||
@@ -146,10 +316,17 @@ export interface SchemaDiffObject {
|
||||
sourceDdl?: string;
|
||||
targetDdl?: string;
|
||||
deploySql?: string;
|
||||
rollbackDdl?: string;
|
||||
changes?: string[];
|
||||
children?: SchemaDiffObject[];
|
||||
/** Function arguments signature (for PostgreSQL overloaded functions) */
|
||||
arguments?: string;
|
||||
renameMetadata?: {
|
||||
confirmed: boolean;
|
||||
sourceName?: string;
|
||||
targetName?: string;
|
||||
score?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaDiffGroup {
|
||||
@@ -164,6 +341,7 @@ export interface SchemaDiffGroup {
|
||||
export function getOperationType(diffType: string): DiffOperationType {
|
||||
switch (diffType) {
|
||||
case "modified":
|
||||
case "renamed":
|
||||
return "modify";
|
||||
case "added":
|
||||
return "create";
|
||||
@@ -203,23 +381,27 @@ function buildSequenceDdl(seq: SequenceInfo): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = []): SchemaDiffObject[] {
|
||||
export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = [], renameCandidates?: RenameCandidate[]): SchemaDiffObject[] {
|
||||
const objects: SchemaDiffObject[] = [];
|
||||
|
||||
for (const diff of tableDiffs) {
|
||||
const opType = getOperationType(diff.type);
|
||||
const isRenamed = diff.type === "renamed";
|
||||
const newName = isRenamed && renameCandidates ? (renameCandidates.find((rc) => rc.sourceName === diff.name)?.targetName ?? diff.name) : undefined;
|
||||
|
||||
const obj: SchemaDiffObject = {
|
||||
id: `table-${diff.name}`,
|
||||
operationType: opType,
|
||||
objectKind: diff.objectType === "view" ? "view" : "table",
|
||||
name: diff.name,
|
||||
sourceName: diff.type === "added" ? undefined : diff.name,
|
||||
targetName: diff.type === "removed" ? undefined : diff.name,
|
||||
targetName: diff.type === "removed" ? undefined : isRenamed ? newName : diff.name,
|
||||
selected: opType !== "none",
|
||||
sourceDdl: diff.ddl,
|
||||
targetDdl: diff.targetDdl,
|
||||
deploySql: diff.syncSql,
|
||||
deploySql: isRenamed && newName ? (diff.objectType === "view" ? `ALTER VIEW ${diff.name} RENAME TO ${newName};` : `RENAME TABLE ${diff.name} TO ${newName};`) : diff.syncSql,
|
||||
changes: diff.columns?.flatMap((c) => c.changes || []),
|
||||
renameMetadata: isRenamed && newName ? { confirmed: true, sourceName: diff.name, targetName: newName, score: renameCandidates?.find((rc) => rc.sourceName === diff.name)?.score } : undefined,
|
||||
children: [
|
||||
...(diff.columns?.map((c) => ({
|
||||
id: `col-${diff.name}-${c.name}`,
|
||||
@@ -324,6 +506,24 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-mark rename candidates on diff objects (for UI display before user confirms)
|
||||
if (renameCandidates && renameCandidates.length > 0) {
|
||||
for (const rc of renameCandidates) {
|
||||
for (const obj of objects) {
|
||||
// Backend-detected renames: diff_type = "renamed", already has metadata set above
|
||||
if (obj.renameMetadata) continue;
|
||||
// Legacy: mark rename candidates on delete+create pairs (fallback for older backends)
|
||||
if (obj.operationType === "delete" && obj.name === rc.sourceName) {
|
||||
obj.renameMetadata = { confirmed: false, targetName: rc.targetName, score: rc.score };
|
||||
}
|
||||
if (obj.operationType === "create" && obj.name === rc.targetName) {
|
||||
obj.renameMetadata = { confirmed: false, sourceName: rc.sourceName, score: rc.score };
|
||||
obj.sourceName = rc.sourceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
@@ -382,6 +582,133 @@ function generateDropSql(obj: SchemaDiffObject): string {
|
||||
return `DROP ${sqlType} IF EXISTS ${obj.name};`;
|
||||
}
|
||||
|
||||
/** Detect column renames in raw SQL and replace DROP+ADD with RENAME COLUMN. */
|
||||
export function injectColumnRenameSql(sql: string, diffs: TableDiff[], threshold: number, reverse = false): string {
|
||||
if (!sql || !threshold) return sql;
|
||||
|
||||
// Build rename pairs: for each table, match removed columns with added columns by similarity
|
||||
const replacements: { table: string; oldName: string; newName: string }[] = [];
|
||||
for (const diff of diffs) {
|
||||
if (!diff.columns || diff.type !== "modified") continue;
|
||||
const removedCols = diff.columns.filter((c) => c.type === "removed");
|
||||
const addedCols = diff.columns.filter((c) => c.type === "added");
|
||||
if (removedCols.length === 0 || addedCols.length === 0) continue;
|
||||
|
||||
const used = new Set<string>();
|
||||
for (const rc of removedCols) {
|
||||
let best: (typeof addedCols)[0] | null = null;
|
||||
let bestSim = 0;
|
||||
for (const ac of addedCols) {
|
||||
if (used.has(ac.name)) continue;
|
||||
const sim = nameSimilarity(rc.name, ac.name);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
best = ac;
|
||||
}
|
||||
}
|
||||
if (best && bestSim >= threshold) {
|
||||
// rc = removed column (exists in source, NOT in target → needs to be ADDED to target)
|
||||
// best = added column (exists in target, NOT in source → needs to be DROPPED from target)
|
||||
// To sync target → source: rename target's "best.name" to source's "rc.name"
|
||||
replacements.push({ table: diff.name, oldName: best.name, newName: rc.name });
|
||||
used.add(best.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacements.length === 0) return sql;
|
||||
|
||||
// Process each ALTER TABLE block
|
||||
const lines = sql.split("\n");
|
||||
const out: string[] = [];
|
||||
let currentTable = "";
|
||||
let inAlter = false;
|
||||
let alterStart = -1;
|
||||
const tableRenames = new Map<string, typeof replacements>();
|
||||
|
||||
for (const r of replacements) {
|
||||
const list = tableRenames.get(r.table) || [];
|
||||
list.push(r);
|
||||
tableRenames.set(r.table, list);
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Detect ALTER TABLE <table>
|
||||
const alterMatch = trimmed.match(/^ALTER\s+TABLE\s+(?:`[^`]+`\.)?`?(\w+)`?/i);
|
||||
if (alterMatch && !inAlter) {
|
||||
currentTable = alterMatch[1];
|
||||
const renames = tableRenames.get(currentTable);
|
||||
if (renames) {
|
||||
inAlter = true;
|
||||
alterStart = i;
|
||||
continue; // skip current ALTER TABLE line, we'll rebuild it
|
||||
}
|
||||
}
|
||||
|
||||
if (!inAlter) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside an ALTER TABLE block being rewritten
|
||||
const endOfAlter = trimmed === ";" || trimmed.endsWith(";") || (i + 1 < lines.length && lines[i + 1].trim().toUpperCase().startsWith("ALTER")) || i === lines.length - 1;
|
||||
|
||||
if (endOfAlter) {
|
||||
const renames = tableRenames.get(currentTable)!;
|
||||
// Emit RENAME COLUMN statements
|
||||
out.push(`-- Alter table: ${currentTable} (column renames detected)`);
|
||||
for (const r of renames) {
|
||||
const fromName = reverse ? r.newName : r.oldName;
|
||||
const toName = reverse ? r.oldName : r.newName;
|
||||
out.push(`ALTER TABLE ${currentTable} RENAME COLUMN ${fromName} TO ${toName};`);
|
||||
}
|
||||
// Collect remaining ALTER clauses (non-renamed columns)
|
||||
const remaining: string[] = [];
|
||||
for (let j = alterStart + 1; j <= i; j++) {
|
||||
const l = lines[j].trim();
|
||||
if (!l || l === ";") continue;
|
||||
// Forward: ADD for newName, DROP for oldName. Reverse: ADD for oldName, DROP for newName.
|
||||
const isAddRename = l.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => l.includes(reverse ? r.oldName : r.newName));
|
||||
const isDropRename = l.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => l.includes(reverse ? r.newName : r.oldName));
|
||||
if (isAddRename || isDropRename) continue;
|
||||
// Clean trailing comma if next line is removed
|
||||
let cleaned = l;
|
||||
let nextIdx = j + 1;
|
||||
while (nextIdx <= i) {
|
||||
const nextLine = lines[nextIdx].trim();
|
||||
if (!nextLine) {
|
||||
nextIdx++;
|
||||
continue;
|
||||
}
|
||||
const nextIsAddRename = nextLine.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.oldName : r.newName));
|
||||
const nextIsDropRename = nextLine.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.newName : r.oldName));
|
||||
if (nextIsAddRename || nextIsDropRename) {
|
||||
cleaned = cleaned.replace(/,\s*$/, "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
remaining.push(cleaned);
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
const last = remaining[remaining.length - 1].replace(/,\s*$/, "").replace(/;\s*$/, "");
|
||||
remaining[remaining.length - 1] = last;
|
||||
out.push(`ALTER TABLE ${currentTable}`);
|
||||
for (const r of remaining) {
|
||||
out.push(` ${r}`);
|
||||
}
|
||||
out.push(";");
|
||||
}
|
||||
inAlter = false;
|
||||
currentTable = "";
|
||||
}
|
||||
}
|
||||
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
export interface ObjectTypeGroup {
|
||||
kind: DiffObjectKind;
|
||||
label: string;
|
||||
|
||||
@@ -23,6 +23,9 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
|
||||
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
|
||||
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
|
||||
{ id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false },
|
||||
{ id: "detectRenames", labelKey: "schemaDiff.options.detectRenames", defaultChecked: false },
|
||||
{ id: "detectTableRenames", labelKey: "schemaDiff.options.detectTableRenames", defaultChecked: false },
|
||||
{ id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false },
|
||||
];
|
||||
|
||||
export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record<string, SchemaDiffOptionItem[]> = {
|
||||
|
||||
@@ -640,6 +640,26 @@ export interface QueryResultRun {
|
||||
tableMeta?: QueryTab["tableMeta"];
|
||||
}
|
||||
|
||||
export interface ParticipantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TransactionLog {
|
||||
transaction_id: string;
|
||||
status: string;
|
||||
participants: ParticipantInfo[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata: unknown;
|
||||
/** camelCase fields from SchemaDiffDeployResult */
|
||||
transactionId?: string;
|
||||
executedCount?: number;
|
||||
statementCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SqlTextSpan {
|
||||
start_line: number;
|
||||
start_column: number;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
export interface ConfigAuditEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
operator: string;
|
||||
reason: string;
|
||||
keyPath: string;
|
||||
changeDiff: unknown;
|
||||
configSnapshot: unknown;
|
||||
}
|
||||
|
||||
export interface ConfigVersionSnapshot {
|
||||
id: string;
|
||||
keyPath: string;
|
||||
version: number;
|
||||
snapshotJson: unknown;
|
||||
checksum: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditQuery {
|
||||
keyPath?: string;
|
||||
operator?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
totalEntries: number;
|
||||
entries: ConfigAuditEntry[];
|
||||
}
|
||||
|
||||
export type ApprovalStatus = "draft" | "pending_approval" | "approved" | "rejected";
|
||||
|
||||
export interface ApprovalRecord {
|
||||
id: string;
|
||||
configDomain: string;
|
||||
changeDescription: string;
|
||||
status: ApprovalStatus;
|
||||
requester: string;
|
||||
reviewer?: string;
|
||||
reviewedAt?: string;
|
||||
webhookUrl?: string;
|
||||
draftConfigJson: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriftAlert {
|
||||
id: string;
|
||||
sourceEnv: string;
|
||||
targetEnv: string;
|
||||
configKey: string;
|
||||
expectedChecksum: string;
|
||||
actualChecksum: string;
|
||||
detailsJson: unknown;
|
||||
detectedAt: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface DriftReport {
|
||||
sourceEnv: string;
|
||||
targetEnv: string;
|
||||
keyPath: string;
|
||||
sourceChecksum: string;
|
||||
targetChecksum: string;
|
||||
mismatchedFields: string[];
|
||||
sourceChangedAt?: string;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
export type DdlRiskLevel = "safe" | "caution" | "dangerous" | "blocked";
|
||||
|
||||
export type ExecStrategy = "online" | "lazy" | "offline" | "batch";
|
||||
|
||||
export interface LockInfo {
|
||||
lockType: string;
|
||||
objects: string[];
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export interface ImpactReport {
|
||||
overallRisk: DdlRiskLevel;
|
||||
ddlRiskLevel: DdlRiskLevel;
|
||||
estimatedLocks: LockInfo[];
|
||||
estimatedTotalDuration: string;
|
||||
recommendedStrategy: ExecStrategy;
|
||||
warnings: string[];
|
||||
requiresMaintenanceWindow: boolean;
|
||||
isReversible: boolean;
|
||||
}
|
||||
|
||||
export type OscExecutionStatus = "preparing" | "copying" | "cut_over" | "completed" | "failed" | "postponed";
|
||||
|
||||
export interface OscStatus {
|
||||
toolType: "gh-ost" | "pt-osc";
|
||||
tableName: string;
|
||||
status: OscExecutionStatus;
|
||||
progressPercent: number;
|
||||
estimatedRemainingSecs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type DegradationLevel = "full" | "sample" | "skip_with_risk";
|
||||
|
||||
export interface BusinessTag {
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
immutable: boolean;
|
||||
}
|
||||
|
||||
export interface ConflictItem {
|
||||
objectName: string;
|
||||
conflictType: string;
|
||||
sourceValue: string;
|
||||
targetValue: string;
|
||||
autoResolvable: boolean;
|
||||
}
|
||||
|
||||
export interface RebasePlan {
|
||||
id: string;
|
||||
baselineId: string;
|
||||
conflicts: ConflictItem[];
|
||||
totalObjects: number;
|
||||
autoResolvedCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ConfigDriftSummary {
|
||||
sourceEnv: string;
|
||||
targetEnv: string;
|
||||
driftCount: number;
|
||||
lastDetectedAt: string;
|
||||
hasUnacknowledged: boolean;
|
||||
}
|
||||
@@ -20,6 +20,24 @@ export interface SchemaDiffCompareOptions {
|
||||
tableIncludePattern: string;
|
||||
tableExcludePattern: string;
|
||||
tableFilterPriority: SchemaDiffTableFilterPriority;
|
||||
detectRenames: boolean;
|
||||
renameThreshold: number;
|
||||
detectTableRenames: boolean;
|
||||
enableRollback: boolean;
|
||||
batchPatterns: string;
|
||||
sourceDialect: string;
|
||||
targetDialect: string;
|
||||
compatibilityThreshold: number;
|
||||
fieldMappings: FieldMappingEntry[];
|
||||
}
|
||||
|
||||
export type FieldMappingParamStrategy = "preserve" | "strip" | "custom";
|
||||
|
||||
export interface FieldMappingEntry {
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
paramStrategy: FieldMappingParamStrategy;
|
||||
customParams?: string;
|
||||
}
|
||||
|
||||
export interface SchemaDiffConfig {
|
||||
@@ -69,6 +87,15 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
|
||||
tableIncludePattern: "",
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
detectRenames: false,
|
||||
renameThreshold: 0.5,
|
||||
detectTableRenames: false,
|
||||
enableRollback: false,
|
||||
batchPatterns: "",
|
||||
sourceDialect: "",
|
||||
targetDialect: "",
|
||||
compatibilityThreshold: 0.5,
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
|
||||
@@ -91,6 +118,15 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
|
||||
tableIncludePattern: "",
|
||||
tableExcludePattern: "",
|
||||
tableFilterPriority: "exclude",
|
||||
detectRenames: false,
|
||||
renameThreshold: 0.5,
|
||||
detectTableRenames: false,
|
||||
enableRollback: false,
|
||||
batchPatterns: "",
|
||||
sourceDialect: "",
|
||||
targetDialect: "",
|
||||
compatibilityThreshold: 0.5,
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOptions {
|
||||
|
||||
Reference in new issue
Block a user