modified: backend/src/controllers/schemaController.ts modified: frontend/src/pages/DatabaseSchema.tsx
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
ReactFlow,
|
||
Node,
|
||
Edge,
|
||
Background,
|
||
Controls,
|
||
MiniMap,
|
||
useNodesState,
|
||
useEdgesState,
|
||
MarkerType,
|
||
Position,
|
||
} from '@xyflow/react';
|
||
import '@xyflow/react/dist/style.css';
|
||
import Dagre from '@dagrejs/dagre';
|
||
import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw } from 'lucide-react';
|
||
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||
import { Database } from '@/types';
|
||
|
||
// Custom node for table
|
||
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
||
return (
|
||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 max-w-80 overflow-hidden">
|
||
{/* Table header */}
|
||
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<DatabaseIcon size={14} />
|
||
<span className="truncate">{data.name}</span>
|
||
</div>
|
||
{data.comment && (
|
||
<div className="text-xs text-primary-200 mt-1 font-normal truncate" title={data.comment}>
|
||
{data.comment}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Columns */}
|
||
<div className="divide-y divide-gray-100 max-h-64 overflow-y-auto">
|
||
{data.columns.map((col) => (
|
||
<div
|
||
key={col.name}
|
||
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default"
|
||
title={col.comment || undefined}
|
||
>
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{col.is_primary && <Key size={11} className="text-yellow-500" />}
|
||
{data.foreign_keys.some(fk => fk.column === col.name) && (
|
||
<Link size={11} className="text-blue-500" />
|
||
)}
|
||
</div>
|
||
<span className={`font-medium truncate ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
|
||
{col.name}
|
||
</span>
|
||
<span className="text-gray-400 truncate flex-1 text-right">{col.type}</span>
|
||
{!col.nullable && <span className="text-red-400 text-[10px] flex-shrink-0">NN</span>}
|
||
</div>
|
||
))}
|
||
{data.columns.length === 0 && (
|
||
<div className="px-3 py-2 text-xs text-gray-400 italic">No columns</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* FK summary */}
|
||
{data.foreign_keys.length > 0 && (
|
||
<div className="bg-blue-50 px-3 py-1.5 text-xs text-blue-600 border-t border-blue-100">
|
||
{data.foreign_keys.length} FK
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const nodeTypes = {
|
||
table: TableNode,
|
||
};
|
||
|
||
// Calculate node height based on columns count
|
||
function getNodeHeight(table: TableInfo): number {
|
||
const headerHeight = 44;
|
||
const columnHeight = 28;
|
||
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
|
||
const maxVisibleColumns = 10;
|
||
const visibleColumns = Math.min(table.columns.length, maxVisibleColumns);
|
||
return headerHeight + (visibleColumns * columnHeight) + fkBarHeight + 8;
|
||
}
|
||
|
||
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||
|
||
g.setGraph({
|
||
rankdir: 'LR', // Left to Right
|
||
nodesep: 80, // Horizontal separation between nodes
|
||
ranksep: 120, // Vertical separation between ranks
|
||
marginx: 50,
|
||
marginy: 50,
|
||
});
|
||
|
||
// Add nodes
|
||
schema.tables.forEach((table) => {
|
||
const nodeId = `${table.schema}.${table.name}`;
|
||
const width = 280;
|
||
const height = getNodeHeight(table);
|
||
g.setNode(nodeId, { width, height });
|
||
});
|
||
|
||
// Add edges
|
||
schema.tables.forEach((table) => {
|
||
const sourceId = `${table.schema}.${table.name}`;
|
||
table.foreign_keys.forEach((fk) => {
|
||
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||
if (targetTable) {
|
||
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||
g.setEdge(sourceId, targetId);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Run layout
|
||
Dagre.layout(g);
|
||
|
||
// Create nodes with positions
|
||
const nodes: Node[] = schema.tables.map((table) => {
|
||
const nodeId = `${table.schema}.${table.name}`;
|
||
const nodeWithPosition = g.node(nodeId);
|
||
|
||
return {
|
||
id: nodeId,
|
||
type: 'table',
|
||
position: {
|
||
x: nodeWithPosition.x - nodeWithPosition.width / 2,
|
||
y: nodeWithPosition.y - nodeWithPosition.height / 2,
|
||
},
|
||
data: { ...table } as TableInfo & Record<string, unknown>,
|
||
sourcePosition: Position.Right,
|
||
targetPosition: Position.Left,
|
||
};
|
||
});
|
||
|
||
// Create edges
|
||
const edges: Edge[] = [];
|
||
schema.tables.forEach((table) => {
|
||
const sourceId = `${table.schema}.${table.name}`;
|
||
table.foreign_keys.forEach((fk) => {
|
||
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||
if (targetTable) {
|
||
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||
edges.push({
|
||
id: `${fk.constraint_name}`,
|
||
source: sourceId,
|
||
target: targetId,
|
||
type: 'smoothstep',
|
||
animated: true,
|
||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||
markerEnd: {
|
||
type: MarkerType.ArrowClosed,
|
||
color: '#3b82f6',
|
||
},
|
||
label: `${fk.column} → ${fk.references_column}`,
|
||
labelStyle: { fontSize: 10, fill: '#666' },
|
||
labelBgStyle: { fill: 'white', fillOpacity: 0.9 },
|
||
labelBgPadding: [4, 2] as [number, number],
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
return { nodes, edges };
|
||
}
|
||
|
||
export default function DatabaseSchema() {
|
||
const queryClient = useQueryClient();
|
||
const [selectedDbId, setSelectedDbId] = useState<string>('');
|
||
|
||
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
||
queryKey: ['databases'],
|
||
queryFn: async () => {
|
||
const { data } = await databasesApi.getAll();
|
||
return data.filter((db: Database) => db.type !== 'aql');
|
||
},
|
||
});
|
||
|
||
const { data: schemaResponse, isLoading: isLoadingSchema } = useQuery({
|
||
queryKey: ['schema', selectedDbId],
|
||
queryFn: async () => {
|
||
const { data } = await schemaApi.getSchema(selectedDbId);
|
||
return data;
|
||
},
|
||
enabled: !!selectedDbId,
|
||
});
|
||
|
||
const refreshMutation = useMutation({
|
||
mutationFn: () => schemaApi.refreshSchema(selectedDbId),
|
||
onSuccess: (response) => {
|
||
queryClient.setQueryData(['schema', selectedDbId], response.data);
|
||
},
|
||
});
|
||
|
||
const schema = schemaResponse?.data;
|
||
|
||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||
if (!schema) return { nodes: [], edges: [] };
|
||
return getLayoutedElements(schema);
|
||
}, [schema]);
|
||
|
||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||
|
||
// Update nodes when schema changes
|
||
useEffect(() => {
|
||
if (layoutedNodes.length > 0) {
|
||
setNodes(layoutedNodes);
|
||
setEdges(layoutedEdges);
|
||
}
|
||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||
|
||
if (isLoadingDatabases) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col -m-8 bg-white overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
|
||
{/* Toolbar */}
|
||
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<DatabaseIcon size={18} className="text-gray-500" />
|
||
<select
|
||
value={selectedDbId}
|
||
onChange={(e) => setSelectedDbId(e.target.value)}
|
||
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>
|
||
{databases.map((db: Database) => (
|
||
<option key={db.id} value={db.id}>
|
||
{db.name} ({db.type})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{selectedDbId && (
|
||
<button
|
||
onClick={() => refreshMutation.mutate()}
|
||
disabled={refreshMutation.isPending}
|
||
className="flex items-center gap-2 px-4 py-1.5 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 text-sm font-medium"
|
||
>
|
||
{refreshMutation.isPending ? (
|
||
<Loader2 className="animate-spin" size={16} />
|
||
) : (
|
||
<RefreshCw size={16} />
|
||
)}
|
||
Обновить схему
|
||
</button>
|
||
)}
|
||
|
||
{schema && (
|
||
<div className="text-sm text-gray-500 ml-auto">
|
||
{schema.tables.length} таблиц | Обновлено: {new Date(schema.updated_at).toLocaleString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Schema visualization */}
|
||
<div className="flex-1">
|
||
{!selectedDbId && (
|
||
<div className="flex items-center justify-center h-full text-gray-400">
|
||
Выберите базу данных для просмотра схемы
|
||
</div>
|
||
)}
|
||
|
||
{selectedDbId && isLoadingSchema && (
|
||
<div className="flex items-center justify-center h-full">
|
||
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||
</div>
|
||
)}
|
||
|
||
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
nodeTypes={nodeTypes}
|
||
fitView
|
||
fitViewOptions={{ padding: 0.2 }}
|
||
minZoom={0.1}
|
||
maxZoom={2}
|
||
defaultEdgeOptions={{
|
||
type: 'smoothstep',
|
||
}}
|
||
>
|
||
<Background color="#e5e7eb" gap={16} />
|
||
<Controls />
|
||
<MiniMap
|
||
nodeColor="#6366f1"
|
||
maskColor="rgba(0, 0, 0, 0.1)"
|
||
style={{ background: '#f3f4f6' }}
|
||
/>
|
||
</ReactFlow>
|
||
)}
|
||
|
||
{selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && (
|
||
<div className="flex items-center justify-center h-full text-gray-400">
|
||
В базе данных нет таблиц
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|