new file: backend/src/controllers/schemaController.ts
new file: backend/src/migrations/008_add_database_schemas.sql modified: backend/src/routes/sqlInterface.ts modified: frontend/package.json modified: frontend/src/App.tsx modified: frontend/src/components/Sidebar.tsx new file: frontend/src/pages/DatabaseSchema.tsx modified: frontend/src/services/api.ts
This commit is contained in:
@@ -14,6 +14,7 @@ import Folders from '@/pages/Folders';
|
||||
import Logs from '@/pages/Logs';
|
||||
import Settings from '@/pages/Settings';
|
||||
import SqlInterface from '@/pages/SqlInterface';
|
||||
import DatabaseSchema from '@/pages/DatabaseSchema';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -86,6 +87,16 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/schema"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout>
|
||||
<DatabaseSchema />
|
||||
</Layout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/endpoints"
|
||||
element={
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database } from 'lucide-react';
|
||||
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database, GitBranch } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Home, label: 'Главная' },
|
||||
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' },
|
||||
{ to: '/schema', icon: GitBranch, label: 'Схема БД' },
|
||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
||||
|
||||
270
frontend/src/pages/DatabaseSchema.tsx
Normal file
270
frontend/src/pages/DatabaseSchema.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Database as DatabaseIcon, RefreshCw, Loader2, Key, Link } from 'lucide-react';
|
||||
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||||
import { Database } from '@/types';
|
||||
|
||||
// Custom node for table
|
||||
function TableNode({ data }: { data: TableInfo }) {
|
||||
return (
|
||||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 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>{data.schema}.{data.name}</span>
|
||||
</div>
|
||||
{data.comment && (
|
||||
<div className="text-xs text-primary-200 mt-1 font-normal">{data.comment}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{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"
|
||||
>
|
||||
{col.is_primary && <Key size={12} className="text-yellow-500 flex-shrink-0" />}
|
||||
{data.foreign_keys.some(fk => fk.column === col.name) && (
|
||||
<Link size={12} className="text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
|
||||
{col.name}
|
||||
</span>
|
||||
<span className="text-gray-400 flex-1">{col.type}</span>
|
||||
{!col.nullable && <span className="text-red-400 text-[10px]">NOT NULL</span>}
|
||||
</div>
|
||||
))}
|
||||
{data.columns.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-gray-400 italic">No columns</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column comments tooltip would go here in a more advanced version */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
table: TableNode,
|
||||
};
|
||||
|
||||
function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
const tablePositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Calculate positions in a grid layout
|
||||
const cols = Math.ceil(Math.sqrt(schema.tables.length));
|
||||
const nodeWidth = 280;
|
||||
const nodeHeight = 200;
|
||||
const gapX = 100;
|
||||
const gapY = 80;
|
||||
|
||||
schema.tables.forEach((table, index) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
const x = col * (nodeWidth + gapX);
|
||||
const y = row * (nodeHeight + gapY);
|
||||
|
||||
const fullName = `${table.schema}.${table.name}`;
|
||||
tablePositions.set(table.name, { x, y });
|
||||
tablePositions.set(fullName, { x, y });
|
||||
|
||||
nodes.push({
|
||||
id: fullName,
|
||||
type: 'table',
|
||||
position: { x, y },
|
||||
data: table,
|
||||
});
|
||||
});
|
||||
|
||||
// Create edges for foreign keys
|
||||
schema.tables.forEach((table) => {
|
||||
const sourceId = `${table.schema}.${table.name}`;
|
||||
|
||||
table.foreign_keys.forEach((fk) => {
|
||||
// Try to find target table
|
||||
let targetId = schema.tables.find(t => t.name === fk.references_table);
|
||||
if (targetId) {
|
||||
edges.push({
|
||||
id: `${fk.constraint_name}`,
|
||||
source: sourceId,
|
||||
target: `${targetId.schema}.${targetId.name}`,
|
||||
sourceHandle: fk.column,
|
||||
targetHandle: fk.references_column,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
label: fk.column,
|
||||
labelStyle: { fontSize: 10, fill: '#666' },
|
||||
labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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: initialNodes, edges: initialEdges } = useMemo(() => {
|
||||
if (!schema) return { nodes: [], edges: [] };
|
||||
return generateNodesAndEdges(schema);
|
||||
}, [schema]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
// Update nodes when schema changes
|
||||
useMemo(() => {
|
||||
if (initialNodes.length > 0) {
|
||||
setNodes(initialNodes);
|
||||
setEdges(initialEdges);
|
||||
}
|
||||
}, [initialNodes, initialEdges, 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" 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 && (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -187,4 +187,42 @@ export const sqlInterfaceApi = {
|
||||
api.post<SqlQueryResult>('/workbench/execute', { database_id: databaseId, query }),
|
||||
};
|
||||
|
||||
// Schema API
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
default_value: string | null;
|
||||
is_primary: boolean;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export interface ForeignKey {
|
||||
column: string;
|
||||
references_table: string;
|
||||
references_column: string;
|
||||
constraint_name: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
comment: string | null;
|
||||
columns: ColumnInfo[];
|
||||
foreign_keys: ForeignKey[];
|
||||
}
|
||||
|
||||
export interface SchemaData {
|
||||
tables: TableInfo[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const schemaApi = {
|
||||
getSchema: (databaseId: string) =>
|
||||
api.get<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}`),
|
||||
|
||||
refreshSchema: (databaseId: string) =>
|
||||
api.post<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}/refresh`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user