mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
480 lines
17 KiB
Vue
480 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import {
|
|
ArrowUpRight,
|
|
Check,
|
|
Columns3,
|
|
Copy,
|
|
Eye,
|
|
Filter,
|
|
History,
|
|
Link,
|
|
Loader2,
|
|
RefreshCw,
|
|
Search,
|
|
SearchX,
|
|
X,
|
|
} from "lucide-vue-next";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useToast } from "@/composables/useToast";
|
|
import { useConnectionStore } from "@/stores/connectionStore";
|
|
import * as api from "@/lib/api";
|
|
import {
|
|
analyzeFieldLineage,
|
|
summarizeLineageCounts,
|
|
type FieldLineageConfidence,
|
|
type FieldLineageItem,
|
|
type FieldLineageResult,
|
|
type FieldLineageTable,
|
|
type FieldLineageView,
|
|
} from "@/lib/fieldLineage";
|
|
|
|
const props = defineProps<{
|
|
open: boolean;
|
|
prefillConnectionId: string;
|
|
prefillDatabase: string;
|
|
prefillSchema?: string;
|
|
prefillTable: string;
|
|
prefillColumn: string;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
"update:open": [value: boolean];
|
|
"open-target": [
|
|
value: {
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName: string;
|
|
columnName?: string;
|
|
},
|
|
];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const connectionStore = useConnectionStore();
|
|
const dialogOpen = computed({
|
|
get: () => props.open,
|
|
set: (value) => emit("update:open", value),
|
|
});
|
|
|
|
const MAX_TABLES = 180;
|
|
const MAX_VIEW_DDLS = 60;
|
|
const BATCH_SIZE = 6;
|
|
|
|
const loading = ref(false);
|
|
const cancelled = ref(false);
|
|
const error = ref("");
|
|
const progressDone = ref(0);
|
|
const progressTotal = ref(0);
|
|
const result = ref<FieldLineageResult | null>(null);
|
|
const confidenceFilter = ref<"all" | FieldLineageConfidence>("all");
|
|
const searchText = ref("");
|
|
const copiedId = ref("");
|
|
let runId = 0;
|
|
|
|
const targetLabel = computed(() => {
|
|
const scope = props.prefillSchema ? `${props.prefillSchema}.${props.prefillTable}` : props.prefillTable;
|
|
return `${scope}.${props.prefillColumn}`;
|
|
});
|
|
|
|
const counts = computed(() => summarizeLineageCounts(result.value?.items ?? []));
|
|
|
|
const confidenceOptions = computed<Array<{ value: "all" | FieldLineageConfidence; label: string; count: number }>>(
|
|
() => [
|
|
{ value: "all", label: t("lineage.all"), count: result.value?.items.length ?? 0 },
|
|
{ value: "certain", label: t("lineage.certain"), count: counts.value.certain },
|
|
{ value: "likely", label: t("lineage.likely"), count: counts.value.likely },
|
|
{ value: "possible", label: t("lineage.possible"), count: counts.value.possible },
|
|
],
|
|
);
|
|
|
|
const filteredItems = computed(() => {
|
|
const query = searchText.value.trim().toLowerCase();
|
|
return (result.value?.items ?? [])
|
|
.filter((item) => confidenceFilter.value === "all" || item.confidence === confidenceFilter.value)
|
|
.filter((item) => {
|
|
if (!query) return true;
|
|
return [
|
|
item.title,
|
|
item.schema,
|
|
item.table,
|
|
item.column,
|
|
item.sqlSnippet,
|
|
itemKindLabel(item),
|
|
itemDescription(item),
|
|
].some((value) =>
|
|
String(value ?? "")
|
|
.toLowerCase()
|
|
.includes(query),
|
|
);
|
|
})
|
|
.sort((a, b) => itemRank(a) - itemRank(b));
|
|
});
|
|
|
|
const filteredCounts = computed(() => summarizeLineageCounts(filteredItems.value));
|
|
|
|
watch(dialogOpen, (open) => {
|
|
if (open) {
|
|
confidenceFilter.value = "all";
|
|
searchText.value = "";
|
|
void loadLineage();
|
|
} else {
|
|
cancelLoad();
|
|
}
|
|
});
|
|
|
|
function cancelLoad() {
|
|
cancelled.value = true;
|
|
runId++;
|
|
loading.value = false;
|
|
}
|
|
|
|
async function loadLineage() {
|
|
if (!props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable || !props.prefillColumn) return;
|
|
const currentRun = ++runId;
|
|
loading.value = true;
|
|
cancelled.value = false;
|
|
error.value = "";
|
|
result.value = null;
|
|
progressDone.value = 0;
|
|
progressTotal.value = 0;
|
|
|
|
try {
|
|
await connectionStore.ensureConnected(props.prefillConnectionId);
|
|
if (isStale(currentRun)) return;
|
|
|
|
const schema = props.prefillSchema || props.prefillDatabase;
|
|
const tableInfos = prioritizeTargetTable(
|
|
await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema),
|
|
props.prefillTable,
|
|
).slice(0, MAX_TABLES);
|
|
const viewInfos = tableInfos
|
|
.filter((table) => table.table_type.toUpperCase().includes("VIEW"))
|
|
.slice(0, MAX_VIEW_DDLS);
|
|
progressTotal.value = tableInfos.length + viewInfos.length + 1;
|
|
|
|
const tables: FieldLineageTable[] = [];
|
|
for (let i = 0; i < tableInfos.length; i += BATCH_SIZE) {
|
|
if (isStale(currentRun)) return;
|
|
const batch = tableInfos.slice(i, i + BATCH_SIZE);
|
|
const loaded = await Promise.all(
|
|
batch.map(async (table) => {
|
|
try {
|
|
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table.name);
|
|
const foreignKeys = await api.listForeignKeys(
|
|
props.prefillConnectionId,
|
|
props.prefillDatabase,
|
|
schema,
|
|
table.name,
|
|
);
|
|
return {
|
|
schema,
|
|
name: table.name,
|
|
columns: columns.map((column) => column.name),
|
|
foreignKeys,
|
|
};
|
|
} catch {
|
|
return { schema, name: table.name, columns: [], foreignKeys: [] };
|
|
}
|
|
}),
|
|
);
|
|
tables.push(...loaded);
|
|
progressDone.value += batch.length;
|
|
}
|
|
|
|
const views: FieldLineageView[] = [];
|
|
for (const view of viewInfos) {
|
|
if (isStale(currentRun)) return;
|
|
try {
|
|
const ddl = await api.getTableDdl(props.prefillConnectionId, props.prefillDatabase, schema, view.name);
|
|
views.push({ schema, name: view.name, ddl });
|
|
} catch {
|
|
// Some drivers may not expose view DDL consistently; keep the rest of the lineage usable.
|
|
} finally {
|
|
progressDone.value++;
|
|
}
|
|
}
|
|
|
|
const histories = (await api.loadHistory(200, 0))
|
|
.filter((entry) => !entry.database || entry.database === props.prefillDatabase)
|
|
.map((entry) => ({ id: entry.id, sql: entry.sql, executed_at: entry.executed_at }));
|
|
progressDone.value++;
|
|
if (isStale(currentRun)) return;
|
|
|
|
result.value = analyzeFieldLineage({
|
|
target: {
|
|
schema,
|
|
table: props.prefillTable,
|
|
column: props.prefillColumn,
|
|
},
|
|
tables,
|
|
views,
|
|
histories,
|
|
});
|
|
} catch (e: any) {
|
|
if (!isStale(currentRun)) error.value = e?.message || String(e);
|
|
} finally {
|
|
if (!isStale(currentRun)) loading.value = false;
|
|
}
|
|
}
|
|
|
|
function isStale(id: number) {
|
|
return cancelled.value || id !== runId;
|
|
}
|
|
|
|
function prioritizeTargetTable<T extends { name: string }>(tables: T[], targetTable: string): T[] {
|
|
return [...tables].sort((a, b) => Number(a.name !== targetTable) - Number(b.name !== targetTable));
|
|
}
|
|
|
|
function itemIcon(item: FieldLineageItem) {
|
|
if (item.kind === "foreignKey") return Link;
|
|
if (item.kind === "viewReference") return Eye;
|
|
if (item.kind === "historyReference") return History;
|
|
return Columns3;
|
|
}
|
|
|
|
function confidenceVariant(confidence: FieldLineageItem["confidence"]) {
|
|
return confidence === "certain" ? "default" : "secondary";
|
|
}
|
|
|
|
function confidenceTone(confidence: FieldLineageItem["confidence"]) {
|
|
if (confidence === "certain") return "text-emerald-600 bg-emerald-50 border-emerald-200";
|
|
if (confidence === "likely") return "text-blue-600 bg-blue-50 border-blue-200";
|
|
return "text-zinc-600 bg-zinc-50 border-zinc-200";
|
|
}
|
|
|
|
function itemRank(item: FieldLineageItem) {
|
|
const confidenceRank = item.confidence === "certain" ? 0 : item.confidence === "likely" ? 10 : 20;
|
|
const kindRank =
|
|
item.kind === "foreignKey" ? 0 : item.kind === "viewReference" ? 1 : item.kind === "historyReference" ? 2 : 3;
|
|
return confidenceRank + kindRank;
|
|
}
|
|
|
|
function itemKindLabel(item: FieldLineageItem) {
|
|
return t(`lineage.kind.${item.kind}`);
|
|
}
|
|
|
|
function itemPrimaryLabel(item: FieldLineageItem) {
|
|
if (item.kind === "historyReference") return t("lineage.queryHistory");
|
|
if (item.table && item.column) return `${item.table}.${item.column}`;
|
|
if (item.table) return `${item.table}.${props.prefillColumn}`;
|
|
return item.title;
|
|
}
|
|
|
|
function itemDescription(item: FieldLineageItem) {
|
|
if (item.kind === "foreignKey") {
|
|
if (item.direction === "incoming") {
|
|
return t("lineage.description.foreignKeyIncoming", {
|
|
target: `${item.table}.${item.column}`,
|
|
});
|
|
}
|
|
return t("lineage.description.foreignKeyOutgoing", { target: `${item.table}.${item.column}` });
|
|
}
|
|
if (item.kind === "viewReference") {
|
|
return item.confidence === "likely" ? t("lineage.description.viewLikely") : t("lineage.description.viewPossible");
|
|
}
|
|
if (item.kind === "historyReference") {
|
|
return item.confidence === "likely"
|
|
? t("lineage.description.historyLikely")
|
|
: t("lineage.description.historyPossible");
|
|
}
|
|
return t("lineage.description.sameName");
|
|
}
|
|
|
|
function copyItem(item: FieldLineageItem) {
|
|
const text = itemPrimaryLabel(item);
|
|
navigator.clipboard.writeText(text);
|
|
copiedId.value = item.id;
|
|
toast(t("lineage.copied"));
|
|
setTimeout(() => {
|
|
if (copiedId.value === item.id) copiedId.value = "";
|
|
}, 1400);
|
|
}
|
|
|
|
function openItemTarget(item: FieldLineageItem) {
|
|
if (!item.table) return;
|
|
emit("open-target", {
|
|
connectionId: props.prefillConnectionId,
|
|
database: props.prefillDatabase,
|
|
schema: item.schema || props.prefillSchema,
|
|
tableName: item.table,
|
|
columnName: item.column || props.prefillColumn,
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="dialogOpen">
|
|
<DialogScrollContent
|
|
class="h-[78vh] min-h-[560px] max-h-[780px] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden gap-0 p-0 sm:max-w-[980px]"
|
|
>
|
|
<DialogHeader class="border-b px-6 py-4 pr-12">
|
|
<DialogTitle class="flex items-center gap-2 text-lg">
|
|
<Link class="h-5 w-5" />
|
|
{{ t("lineage.title") }}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="flex min-h-0 flex-col overflow-hidden">
|
|
<div class="border-b bg-muted/10 px-6 py-3">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-xs text-muted-foreground">{{ t("lineage.targetField") }}</div>
|
|
<div class="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
|
<span class="max-w-[520px] truncate font-semibold">{{ targetLabel }}</span>
|
|
<Badge variant="outline">{{ props.prefillDatabase }}</Badge>
|
|
<Badge v-if="props.prefillSchema" variant="outline">{{ props.prefillSchema }}</Badge>
|
|
</div>
|
|
</div>
|
|
<div v-if="result" class="flex flex-wrap gap-2 text-xs">
|
|
<Badge>{{ t("lineage.certain") }} {{ counts.certain }}</Badge>
|
|
<Badge variant="secondary">{{ t("lineage.likely") }} {{ counts.likely }}</Badge>
|
|
<Badge variant="outline">{{ t("lineage.possible") }} {{ counts.possible }}</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="result" class="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
|
|
<div class="relative min-w-0 flex-1">
|
|
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input v-model="searchText" class="h-8 pl-8 text-sm" :placeholder="t('lineage.searchPlaceholder')" />
|
|
</div>
|
|
<div class="flex items-center gap-2 overflow-x-auto">
|
|
<Filter class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<Button
|
|
v-for="option in confidenceOptions"
|
|
:key="option.value"
|
|
size="sm"
|
|
:variant="confidenceFilter === option.value ? 'default' : 'outline'"
|
|
class="h-8 shrink-0 px-3"
|
|
@click="confidenceFilter = option.value"
|
|
>
|
|
{{ option.label }} {{ option.count }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
|
<div v-if="loading" class="rounded-md border bg-muted/20 p-4">
|
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 class="h-4 w-4 animate-spin" />
|
|
{{ t("lineage.loading", { done: progressDone, total: progressTotal || "-" }) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="error"
|
|
class="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
|
>
|
|
{{ error }}
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="result && result.items.length === 0"
|
|
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
|
>
|
|
<SearchX class="h-8 w-8" />
|
|
{{ t("lineage.empty") }}
|
|
</div>
|
|
|
|
<template v-else-if="result">
|
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
<span>{{ t("lineage.showing", { shown: filteredItems.length, total: result.items.length }) }}</span>
|
|
<span v-if="filteredItems.length">
|
|
{{ t("lineage.certain") }} {{ filteredCounts.certain }} · {{ t("lineage.likely") }}
|
|
{{ filteredCounts.likely }} · {{ t("lineage.possible") }}
|
|
{{ filteredCounts.possible }}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="filteredItems.length === 0"
|
|
class="flex flex-col items-center justify-center gap-2 rounded-md border py-12 text-sm text-muted-foreground"
|
|
>
|
|
<SearchX class="h-8 w-8" />
|
|
{{ t("lineage.noFiltered") }}
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="item in filteredItems"
|
|
:key="item.id"
|
|
class="rounded-md border bg-background transition-colors hover:bg-muted/25"
|
|
>
|
|
<div class="flex items-start gap-3 p-3">
|
|
<div
|
|
:class="[
|
|
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border',
|
|
confidenceTone(item.confidence),
|
|
]"
|
|
>
|
|
<component :is="itemIcon(item)" class="h-4 w-4" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
<button
|
|
v-if="item.table"
|
|
type="button"
|
|
class="group inline-flex min-w-0 max-w-[560px] items-center gap-1 truncate rounded-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
:title="t('lineage.openTable')"
|
|
@click="openItemTarget(item)"
|
|
>
|
|
<span class="truncate">{{ itemPrimaryLabel(item) }}</span>
|
|
<ArrowUpRight
|
|
class="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
</button>
|
|
<span v-else class="max-w-[560px] truncate font-medium">{{ itemPrimaryLabel(item) }}</span>
|
|
<Badge v-if="item.schema" variant="outline" class="text-[10px]">{{ item.schema }}</Badge>
|
|
<Badge variant="outline" class="text-[10px]">{{ itemKindLabel(item) }}</Badge>
|
|
<Badge :variant="confidenceVariant(item.confidence)" class="text-[10px]">{{
|
|
t(`lineage.${item.confidence}`)
|
|
}}</Badge>
|
|
</div>
|
|
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
|
{{ itemDescription(item) }}
|
|
</p>
|
|
<pre
|
|
v-if="item.sqlSnippet"
|
|
class="mt-2 max-h-20 overflow-auto rounded-md bg-muted/40 p-2 text-xs whitespace-pre-wrap"
|
|
>{{ item.sqlSnippet }}</pre
|
|
>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-8 w-8 shrink-0 p-0"
|
|
:title="t('lineage.copy')"
|
|
@click="copyItem(item)"
|
|
>
|
|
<Check v-if="copiedId === item.id" class="h-4 w-4 text-emerald-600" />
|
|
<Copy v-else class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter class="border-t px-6 py-4">
|
|
<Button v-if="loading" variant="outline" @click="cancelLoad">
|
|
<X class="h-4 w-4" />
|
|
{{ t("lineage.cancel") }}
|
|
</Button>
|
|
<Button v-else variant="outline" @click="loadLineage">
|
|
<RefreshCw class="h-4 w-4" />
|
|
{{ t("lineage.refresh") }}
|
|
</Button>
|
|
<Button @click="dialogOpen = false">{{ t("common.close") }}</Button>
|
|
</DialogFooter>
|
|
</DialogScrollContent>
|
|
</Dialog>
|
|
</template>
|