Files
api_builder/frontend/src/pages/DatabaseSchema.tsx
eshmeshek 4fb92470ce modified: backend/src/controllers/databaseManagementController.ts
modified:   backend/src/controllers/schemaController.ts
	modified:   frontend/src/pages/DatabaseSchema.tsx
2026-01-28 00:00:15 +03:00

315 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}