modified: frontend/src/pages/DatabaseSchema.tsx
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import Dagre from '@dagrejs/dagre';
|
import Dagre from '@dagrejs/dagre';
|
||||||
import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw, Search, X } from 'lucide-react';
|
import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw } from 'lucide-react';
|
||||||
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||||||
import { Database } from '@/types';
|
import { Database } from '@/types';
|
||||||
|
|
||||||
@@ -93,7 +93,6 @@ function getNodeHeight(table: TableInfo): number {
|
|||||||
const headerHeight = 44;
|
const headerHeight = 44;
|
||||||
const columnHeight = 26;
|
const columnHeight = 26;
|
||||||
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
|
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
|
||||||
// No max limit - show all columns
|
|
||||||
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
|
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +100,9 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
g.setGraph({
|
g.setGraph({
|
||||||
rankdir: 'LR', // Left to Right
|
rankdir: 'LR',
|
||||||
nodesep: 80, // Horizontal separation between nodes
|
nodesep: 80,
|
||||||
ranksep: 120, // Vertical separation between ranks
|
ranksep: 120,
|
||||||
marginx: 50,
|
marginx: 50,
|
||||||
marginy: 50,
|
marginy: 50,
|
||||||
});
|
});
|
||||||
@@ -151,24 +150,18 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
|
|
||||||
// Create edges
|
// Create edges
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
let totalFks = 0;
|
|
||||||
let matchedFks = 0;
|
|
||||||
|
|
||||||
schema.tables.forEach((table) => {
|
schema.tables.forEach((table) => {
|
||||||
const sourceId = `${table.schema}.${table.name}`;
|
const sourceId = `${table.schema}.${table.name}`;
|
||||||
table.foreign_keys.forEach((fk) => {
|
table.foreign_keys.forEach((fk) => {
|
||||||
totalFks++;
|
|
||||||
// Try to find target table - check both same schema and other schemas
|
|
||||||
let targetTable = schema.tables.find(t =>
|
let targetTable = schema.tables.find(t =>
|
||||||
t.name === fk.references_table && t.schema === table.schema
|
t.name === fk.references_table && t.schema === table.schema
|
||||||
);
|
);
|
||||||
// If not found in same schema, try any schema
|
|
||||||
if (!targetTable) {
|
if (!targetTable) {
|
||||||
targetTable = schema.tables.find(t => t.name === fk.references_table);
|
targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetTable) {
|
if (targetTable) {
|
||||||
matchedFks++;
|
|
||||||
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${fk.constraint_name}`,
|
id: `${fk.constraint_name}`,
|
||||||
@@ -186,13 +179,11 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
labelBgStyle: { fill: 'white', fillOpacity: 0.9 },
|
labelBgStyle: { fill: 'white', fillOpacity: 0.9 },
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.warn(`[Schema] FK not matched: ${sourceId}.${fk.column} -> ${fk.references_table}.${fk.references_column}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Schema] Created ${edges.length} edges from ${matchedFks}/${totalFks} FKs`);
|
console.log(`[Schema] Created ${nodes.length} nodes, ${edges.length} edges`);
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
@@ -200,8 +191,6 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
export default function DatabaseSchema() {
|
export default function DatabaseSchema() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedDbId, setSelectedDbId] = useState<string>('');
|
const [selectedDbId, setSelectedDbId] = useState<string>('');
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
|
||||||
const [selectedTables, setSelectedTables] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
||||||
queryKey: ['databases'],
|
queryKey: ['databases'],
|
||||||
@@ -229,84 +218,14 @@ export default function DatabaseSchema() {
|
|||||||
|
|
||||||
const schema = schemaResponse?.data;
|
const schema = schemaResponse?.data;
|
||||||
|
|
||||||
// Filter tables based on search
|
|
||||||
const filteredTableList = useMemo(() => {
|
|
||||||
if (!schema || !searchQuery.trim()) return [];
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return schema.tables
|
|
||||||
.filter(t => t.name.toLowerCase().includes(query))
|
|
||||||
.slice(0, 50); // Limit suggestions
|
|
||||||
}, [schema, searchQuery]);
|
|
||||||
|
|
||||||
// Get related tables (tables connected via FK)
|
|
||||||
const getRelatedTables = useCallback((tableNames: Set<string>, allTables: TableInfo[]): Set<string> => {
|
|
||||||
const related = new Set<string>();
|
|
||||||
tableNames.forEach(name => related.add(name));
|
|
||||||
|
|
||||||
allTables.forEach(table => {
|
|
||||||
const tableKey = `${table.schema}.${table.name}`;
|
|
||||||
if (tableNames.has(table.name) || tableNames.has(tableKey)) {
|
|
||||||
// Add tables that this table references
|
|
||||||
table.foreign_keys.forEach(fk => {
|
|
||||||
related.add(fk.references_table);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Add tables that reference selected tables
|
|
||||||
table.foreign_keys.forEach(fk => {
|
|
||||||
if (tableNames.has(fk.references_table)) {
|
|
||||||
related.add(table.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return related;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter schema for selected tables + related tables
|
|
||||||
const filteredSchema = useMemo((): SchemaData | null => {
|
|
||||||
if (!schema) return null;
|
|
||||||
if (selectedTables.size === 0) return null; // Don't render anything if nothing selected
|
|
||||||
|
|
||||||
const relatedTables = getRelatedTables(selectedTables, schema.tables);
|
|
||||||
const filteredTables = schema.tables.filter(t =>
|
|
||||||
relatedTables.has(t.name) || relatedTables.has(`${t.schema}.${t.name}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tables: filteredTables,
|
|
||||||
updated_at: schema.updated_at,
|
|
||||||
};
|
|
||||||
}, [schema, selectedTables, getRelatedTables]);
|
|
||||||
|
|
||||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||||
if (!filteredSchema || filteredSchema.tables.length === 0) return { nodes: [], edges: [] };
|
if (!schema) return { nodes: [], edges: [] };
|
||||||
return getLayoutedElements(filteredSchema);
|
return getLayoutedElements(schema);
|
||||||
}, [filteredSchema]);
|
}, [schema]);
|
||||||
|
|
||||||
// Add table to selection
|
|
||||||
const addTable = (tableName: string) => {
|
|
||||||
setSelectedTables(prev => new Set([...prev, tableName]));
|
|
||||||
setSearchQuery('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove table from selection
|
|
||||||
const removeTable = (tableName: string) => {
|
|
||||||
setSelectedTables(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(tableName);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear all selected tables
|
|
||||||
const clearSelection = () => {
|
|
||||||
setSelectedTables(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
|
||||||
// Update nodes when schema changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodes(layoutedNodes);
|
setNodes(layoutedNodes);
|
||||||
setEdges(layoutedEdges);
|
setEdges(layoutedEdges);
|
||||||
@@ -328,11 +247,7 @@ export default function DatabaseSchema() {
|
|||||||
<DatabaseIcon size={18} className="text-gray-500" />
|
<DatabaseIcon size={18} className="text-gray-500" />
|
||||||
<select
|
<select
|
||||||
value={selectedDbId}
|
value={selectedDbId}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSelectedDbId(e.target.value)}
|
||||||
setSelectedDbId(e.target.value);
|
|
||||||
setSelectedTables(new Set());
|
|
||||||
setSearchQuery('');
|
|
||||||
}}
|
|
||||||
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
|
||||||
>
|
>
|
||||||
<option value="">Выберите базу данных</option>
|
<option value="">Выберите базу данных</option>
|
||||||
@@ -344,37 +259,6 @@ export default function DatabaseSchema() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedDbId && schema && (
|
|
||||||
<div className="relative flex-1 max-w-md">
|
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Поиск таблиц..."
|
|
||||||
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
{filteredTableList.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-64 overflow-auto z-50">
|
|
||||||
{filteredTableList.map(table => (
|
|
||||||
<button
|
|
||||||
key={`${table.schema}.${table.name}`}
|
|
||||||
onClick={() => addTable(table.name)}
|
|
||||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DatabaseIcon size={14} className="text-gray-400" />
|
|
||||||
<span className="font-medium">{table.name}</span>
|
|
||||||
<span className="text-gray-400 text-xs">{table.schema}</span>
|
|
||||||
{table.comment && (
|
|
||||||
<span className="text-gray-400 text-xs truncate ml-auto max-w-48">{table.comment}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedDbId && (
|
{selectedDbId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshMutation.mutate()}
|
onClick={() => refreshMutation.mutate()}
|
||||||
@@ -397,38 +281,6 @@ export default function DatabaseSchema() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected tables chips */}
|
|
||||||
{selectedTables.size > 0 && (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-gray-50 flex-shrink-0 flex-wrap">
|
|
||||||
<span className="text-xs text-gray-500">Выбрано:</span>
|
|
||||||
{[...selectedTables].map(tableName => (
|
|
||||||
<span
|
|
||||||
key={tableName}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 rounded text-xs"
|
|
||||||
>
|
|
||||||
{tableName}
|
|
||||||
<button
|
|
||||||
onClick={() => removeTable(tableName)}
|
|
||||||
className="hover:text-primary-900"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 ml-2"
|
|
||||||
>
|
|
||||||
Очистить все
|
|
||||||
</button>
|
|
||||||
{filteredSchema && (
|
|
||||||
<span className="text-xs text-gray-400 ml-auto">
|
|
||||||
Показано: {filteredSchema.tables.length} таблиц (включая связанные)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schema visualization */}
|
{/* Schema visualization */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{!selectedDbId && (
|
{!selectedDbId && (
|
||||||
@@ -443,16 +295,6 @@ export default function DatabaseSchema() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDbId && !isLoadingSchema && schema && selectedTables.size === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-400">
|
|
||||||
<div className="text-center">
|
|
||||||
<Search size={48} className="mx-auto mb-4 text-gray-300" />
|
|
||||||
<p>Начните вводить название таблицы в поиске</p>
|
|
||||||
<p className="text-sm mt-1">Выберите таблицы для отображения на схеме</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
|
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -462,8 +304,9 @@ export default function DatabaseSchema() {
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
minZoom={0.1}
|
minZoom={0.01}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
|
onlyRenderVisibleElements={true}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
}}
|
}}
|
||||||
@@ -474,6 +317,8 @@ export default function DatabaseSchema() {
|
|||||||
nodeColor="#6366f1"
|
nodeColor="#6366f1"
|
||||||
maskColor="rgba(0, 0, 0, 0.1)"
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
style={{ background: '#f3f4f6' }}
|
style={{ background: '#f3f4f6' }}
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
)}
|
)}
|
||||||
@@ -483,12 +328,6 @@ export default function DatabaseSchema() {
|
|||||||
В базе данных нет таблиц
|
В базе данных нет таблиц
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDbId && !isLoadingSchema && schema && selectedTables.size > 0 && nodes.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-400">
|
|
||||||
Таблицы не найдены
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user