modified: frontend/src/pages/DatabaseSchema.tsx

This commit is contained in:
2026-01-28 00:17:44 +03:00
parent 5d3515f791
commit 39b1b0ed5e

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ReactFlow,
@@ -11,17 +11,30 @@ import {
useEdgesState,
MarkerType,
Position,
Handle,
} 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 { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw, Search, X } 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">
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
{/* Connection handles */}
<Handle
type="target"
position={Position.Left}
className="!bg-blue-500 !w-3 !h-3 !border-2 !border-white"
/>
<Handle
type="source"
position={Position.Right}
className="!bg-blue-500 !w-3 !h-3 !border-2 !border-white"
/>
{/* Table header */}
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
<div className="flex items-center gap-2">
@@ -187,6 +200,8 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
export default function DatabaseSchema() {
const queryClient = useQueryClient();
const [selectedDbId, setSelectedDbId] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedTables, setSelectedTables] = useState<Set<string>>(new Set());
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
queryKey: ['databases'],
@@ -214,20 +229,87 @@ export default function DatabaseSchema() {
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(() => {
if (!schema) return { nodes: [], edges: [] };
return getLayoutedElements(schema);
}, [schema]);
if (!filteredSchema || filteredSchema.tables.length === 0) return { nodes: [], edges: [] };
return getLayoutedElements(filteredSchema);
}, [filteredSchema]);
// 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 [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) {
@@ -246,7 +328,11 @@ export default function DatabaseSchema() {
<DatabaseIcon size={18} className="text-gray-500" />
<select
value={selectedDbId}
onChange={(e) => setSelectedDbId(e.target.value)}
onChange={(e) => {
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"
>
<option value="">Выберите базу данных</option>
@@ -258,6 +344,37 @@ export default function DatabaseSchema() {
</select>
</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 && (
<button
onClick={() => refreshMutation.mutate()}
@@ -280,6 +397,38 @@ export default function DatabaseSchema() {
)}
</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 */}
<div className="flex-1">
{!selectedDbId && (
@@ -294,6 +443,16 @@ export default function DatabaseSchema() {
</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 && (
<ReactFlow
nodes={nodes}
@@ -324,6 +483,12 @@ export default function DatabaseSchema() {
В базе данных нет таблиц
</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>
);