modified: frontend/src/pages/DatabaseSchema.tsx
This commit is contained in:
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -15,26 +15,27 @@ 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 } from 'lucide-react';
|
import { Database as DatabaseIcon, Loader2, Key, Link, RefreshCw, Search, X, Table2, MessageSquare, Columns } 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';
|
||||||
|
|
||||||
// Calculate column position for handle placement
|
// Search result type
|
||||||
function getColumnYPosition(columnIndex: number): number {
|
interface SearchResult {
|
||||||
const headerHeight = 44;
|
type: 'table' | 'column' | 'comment';
|
||||||
const columnHeight = 26;
|
tableName: string;
|
||||||
return headerHeight + (columnIndex * columnHeight) + columnHeight / 2;
|
tableSchema: string;
|
||||||
|
columnName?: string;
|
||||||
|
displayText: string;
|
||||||
|
secondaryText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom node for table with per-column handles
|
// Custom node for table with per-column handles
|
||||||
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
||||||
// Find which columns are FK sources and which are PK targets
|
|
||||||
const fkColumns = new Set(data.foreign_keys.map(fk => fk.column));
|
const fkColumns = new Set(data.foreign_keys.map(fk => fk.column));
|
||||||
const pkColumns = new Set(data.columns.filter(c => c.is_primary).map(c => c.name));
|
const pkColumns = new Set(data.columns.filter(c => c.is_primary).map(c => c.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
|
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
|
||||||
{/* Table header */}
|
|
||||||
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
|
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DatabaseIcon size={14} />
|
<DatabaseIcon size={14} />
|
||||||
@@ -47,7 +48,6 @@ function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Columns */}
|
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{data.columns.map((col, index) => (
|
{data.columns.map((col, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -55,25 +55,21 @@ function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
|||||||
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative"
|
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative"
|
||||||
title={col.comment || undefined}
|
title={col.comment || undefined}
|
||||||
>
|
>
|
||||||
{/* Target handle for PK columns (incoming FK references) */}
|
|
||||||
{pkColumns.has(col.name) && (
|
{pkColumns.has(col.name) && (
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id={`target-${col.name}`}
|
id={`target-${col.name}`}
|
||||||
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
|
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
|
||||||
style={{ top: getColumnYPosition(index) }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Source handle for FK columns (outgoing references) */}
|
|
||||||
{fkColumns.has(col.name) && (
|
{fkColumns.has(col.name) && (
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id={`source-${col.name}`}
|
id={`source-${col.name}`}
|
||||||
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
|
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
|
||||||
style={{ top: getColumnYPosition(index) }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -95,7 +91,6 @@ function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FK summary */}
|
|
||||||
{data.foreign_keys.length > 0 && (
|
{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">
|
<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
|
{data.foreign_keys.length} FK
|
||||||
@@ -109,7 +104,6 @@ const nodeTypes = {
|
|||||||
table: TableNode,
|
table: TableNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate node height based on columns count
|
|
||||||
function getNodeHeight(table: TableInfo): number {
|
function getNodeHeight(table: TableInfo): number {
|
||||||
const headerHeight = 44;
|
const headerHeight = 44;
|
||||||
const columnHeight = 26;
|
const columnHeight = 26;
|
||||||
@@ -117,16 +111,13 @@ function getNodeHeight(table: TableInfo): number {
|
|||||||
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
|
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate connection weight for layout optimization
|
|
||||||
function calculateTableWeights(schema: SchemaData): Map<string, number> {
|
function calculateTableWeights(schema: SchemaData): Map<string, number> {
|
||||||
const weights = new Map<string, number>();
|
const weights = new Map<string, number>();
|
||||||
|
|
||||||
schema.tables.forEach(table => {
|
schema.tables.forEach(table => {
|
||||||
const key = `${table.schema}.${table.name}`;
|
const key = `${table.schema}.${table.name}`;
|
||||||
// Weight = number of FK connections (both incoming and outgoing)
|
|
||||||
let weight = table.foreign_keys.length;
|
let weight = table.foreign_keys.length;
|
||||||
|
|
||||||
// Add incoming connections
|
|
||||||
schema.tables.forEach(other => {
|
schema.tables.forEach(other => {
|
||||||
other.foreign_keys.forEach(fk => {
|
other.foreign_keys.forEach(fk => {
|
||||||
if (fk.references_table === table.name) {
|
if (fk.references_table === table.name) {
|
||||||
@@ -144,10 +135,8 @@ function calculateTableWeights(schema: SchemaData): Map<string, number> {
|
|||||||
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
||||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
// Calculate weights for better positioning
|
|
||||||
const weights = calculateTableWeights(schema);
|
const weights = calculateTableWeights(schema);
|
||||||
|
|
||||||
// Sort tables by weight (most connected first) for better layout
|
|
||||||
const sortedTables = [...schema.tables].sort((a, b) => {
|
const sortedTables = [...schema.tables].sort((a, b) => {
|
||||||
const weightA = weights.get(`${a.schema}.${a.name}`) || 0;
|
const weightA = weights.get(`${a.schema}.${a.name}`) || 0;
|
||||||
const weightB = weights.get(`${b.schema}.${b.name}`) || 0;
|
const weightB = weights.get(`${b.schema}.${b.name}`) || 0;
|
||||||
@@ -155,15 +144,14 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
});
|
});
|
||||||
|
|
||||||
g.setGraph({
|
g.setGraph({
|
||||||
rankdir: 'LR',
|
rankdir: 'TB', // Top-to-bottom for wider spread
|
||||||
nodesep: 60,
|
nodesep: 40, // Horizontal spacing between nodes
|
||||||
ranksep: 150,
|
ranksep: 80, // Vertical spacing between ranks
|
||||||
marginx: 50,
|
marginx: 30,
|
||||||
marginy: 50,
|
marginy: 30,
|
||||||
ranker: 'tight-tree', // Better for connected graphs
|
ranker: 'network-simplex', // Better distribution across plane
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add nodes
|
|
||||||
sortedTables.forEach((table) => {
|
sortedTables.forEach((table) => {
|
||||||
const nodeId = `${table.schema}.${table.name}`;
|
const nodeId = `${table.schema}.${table.name}`;
|
||||||
const width = 280;
|
const width = 280;
|
||||||
@@ -171,22 +159,19 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
g.setNode(nodeId, { width, height });
|
g.setNode(nodeId, { width, height });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add edges with weights for layout algorithm
|
|
||||||
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) => {
|
||||||
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
if (targetTable) {
|
if (targetTable) {
|
||||||
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
g.setEdge(sourceId, targetId, { weight: 2 }); // Higher weight keeps connected nodes closer
|
g.setEdge(sourceId, targetId, { weight: 2 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run layout
|
|
||||||
Dagre.layout(g);
|
Dagre.layout(g);
|
||||||
|
|
||||||
// Create nodes with positions
|
|
||||||
const nodes: Node[] = schema.tables.map((table) => {
|
const nodes: Node[] = schema.tables.map((table) => {
|
||||||
const nodeId = `${table.schema}.${table.name}`;
|
const nodeId = `${table.schema}.${table.name}`;
|
||||||
const nodeWithPosition = g.node(nodeId);
|
const nodeWithPosition = g.node(nodeId);
|
||||||
@@ -202,7 +187,6 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create edges with column-level handles
|
|
||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
schema.tables.forEach((table) => {
|
schema.tables.forEach((table) => {
|
||||||
@@ -238,14 +222,14 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Schema] Created ${nodes.length} nodes, ${edges.length} edges`);
|
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
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'],
|
||||||
@@ -273,10 +257,121 @@ export default function DatabaseSchema() {
|
|||||||
|
|
||||||
const schema = schemaResponse?.data;
|
const schema = schemaResponse?.data;
|
||||||
|
|
||||||
|
// Search across tables, columns, and comments
|
||||||
|
const searchResults = useMemo((): SearchResult[] => {
|
||||||
|
if (!schema || !searchQuery.trim()) return [];
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
schema.tables.forEach(table => {
|
||||||
|
// Search by table name
|
||||||
|
if (table.name.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'table',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
displayText: table.name,
|
||||||
|
secondaryText: table.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by table comment
|
||||||
|
if (table.comment && table.comment.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'comment',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
displayText: table.name,
|
||||||
|
secondaryText: table.comment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by column name
|
||||||
|
table.columns.forEach(col => {
|
||||||
|
if (col.name.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'column',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
columnName: col.name,
|
||||||
|
displayText: `${table.name}.${col.name}`,
|
||||||
|
secondaryText: col.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by column comment
|
||||||
|
if (col.comment && col.comment.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'comment',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
columnName: col.name,
|
||||||
|
displayText: `${table.name}.${col.name}`,
|
||||||
|
secondaryText: col.comment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.slice(0, 50);
|
||||||
|
}, [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 => {
|
||||||
|
if (tableNames.has(table.name)) {
|
||||||
|
table.foreign_keys.forEach(fk => {
|
||||||
|
related.add(fk.references_table);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
table.foreign_keys.forEach(fk => {
|
||||||
|
if (tableNames.has(fk.references_table)) {
|
||||||
|
related.add(table.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return related;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter schema for selected tables + related tables, or show all if nothing selected
|
||||||
|
const filteredSchema = useMemo((): SchemaData | null => {
|
||||||
|
if (!schema) return null;
|
||||||
|
if (selectedTables.size === 0) return schema; // Show all when nothing selected
|
||||||
|
|
||||||
|
const relatedTables = getRelatedTables(selectedTables, schema.tables);
|
||||||
|
const filteredTables = schema.tables.filter(t => relatedTables.has(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 (!schema) return { nodes: [], edges: [] };
|
if (!filteredSchema || filteredSchema.tables.length === 0) return { nodes: [], edges: [] };
|
||||||
return getLayoutedElements(schema);
|
return getLayoutedElements(filteredSchema);
|
||||||
}, [schema]);
|
}, [filteredSchema]);
|
||||||
|
|
||||||
|
const addTable = (tableName: string) => {
|
||||||
|
setSelectedTables(prev => new Set([...prev, tableName]));
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTable = (tableName: string) => {
|
||||||
|
setSelectedTables(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(tableName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>([]);
|
||||||
@@ -286,6 +381,25 @@ export default function DatabaseSchema() {
|
|||||||
setEdges(layoutedEdges);
|
setEdges(layoutedEdges);
|
||||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||||
|
|
||||||
|
const getSearchIcon = (type: SearchResult['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'table':
|
||||||
|
return <Table2 size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
case 'column':
|
||||||
|
return <Columns size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
case 'comment':
|
||||||
|
return <MessageSquare size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchTypeLabel = (type: SearchResult['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'table': return 'таблица';
|
||||||
|
case 'column': return 'столбец';
|
||||||
|
case 'comment': return 'коммент';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoadingDatabases) {
|
if (isLoadingDatabases) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -302,7 +416,11 @@ 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) => 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"
|
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>
|
||||||
@@ -314,6 +432,39 @@ 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"
|
||||||
|
/>
|
||||||
|
{searchResults.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-80 overflow-auto z-50">
|
||||||
|
{searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${result.tableName}-${result.columnName || ''}-${result.type}-${idx}`}
|
||||||
|
onClick={() => addTable(result.tableName)}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2 border-b border-gray-50 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-gray-100 rounded text-[10px] text-gray-500 flex-shrink-0">
|
||||||
|
{getSearchIcon(result.type)}
|
||||||
|
{getSearchTypeLabel(result.type)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium truncate">{result.displayText}</span>
|
||||||
|
{result.secondaryText && (
|
||||||
|
<span className="text-gray-400 text-xs truncate ml-auto max-w-48">{result.secondaryText}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedDbId && (
|
{selectedDbId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshMutation.mutate()}
|
onClick={() => refreshMutation.mutate()}
|
||||||
@@ -331,11 +482,39 @@ export default function DatabaseSchema() {
|
|||||||
|
|
||||||
{schema && (
|
{schema && (
|
||||||
<div className="text-sm text-gray-500 ml-auto">
|
<div className="text-sm text-gray-500 ml-auto">
|
||||||
{schema.tables.length} таблиц | Обновлено: {new Date(schema.updated_at).toLocaleString()}
|
{filteredSchema ? filteredSchema.tables.length : 0} / {schema.tables.length} таблиц
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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-blue-50 flex-shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-600 font-medium">Фильтр:</span>
|
||||||
|
{[...selectedTables].map(tableName => (
|
||||||
|
<span
|
||||||
|
key={tableName}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-white border border-blue-200 text-blue-700 rounded text-xs shadow-sm"
|
||||||
|
>
|
||||||
|
<Table2 size={12} />
|
||||||
|
{tableName}
|
||||||
|
<button
|
||||||
|
onClick={() => removeTable(tableName)}
|
||||||
|
className="hover:text-blue-900 ml-1"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 ml-2 underline"
|
||||||
|
>
|
||||||
|
Показать все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Schema visualization */}
|
{/* Schema visualization */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{!selectedDbId && (
|
{!selectedDbId && (
|
||||||
|
|||||||
Reference in New Issue
Block a user