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:
186
backend/src/controllers/schemaController.ts
Normal file
186
backend/src/controllers/schemaController.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mainPool } from '../config/database';
|
||||||
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
default_value: string | null;
|
||||||
|
is_primary: boolean;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForeignKey {
|
||||||
|
column: string;
|
||||||
|
references_table: string;
|
||||||
|
references_column: string;
|
||||||
|
constraint_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
comment: string | null;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
foreign_keys: ForeignKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemaData {
|
||||||
|
tables: TableInfo[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PostgreSQL schema
|
||||||
|
async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
|
||||||
|
const pool = databasePoolManager.getPool(databaseId);
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database not found or not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tables
|
||||||
|
const tablesResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
t.table_schema,
|
||||||
|
t.table_name,
|
||||||
|
obj_description((t.table_schema || '.' || t.table_name)::regclass, 'pg_class') as table_comment
|
||||||
|
FROM information_schema.tables t
|
||||||
|
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
ORDER BY t.table_schema, t.table_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tables: TableInfo[] = [];
|
||||||
|
|
||||||
|
for (const table of tablesResult.rows) {
|
||||||
|
// Get columns for each table
|
||||||
|
const columnsResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
c.column_name,
|
||||||
|
c.data_type,
|
||||||
|
c.is_nullable,
|
||||||
|
c.column_default,
|
||||||
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
|
||||||
|
col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) as column_comment
|
||||||
|
FROM information_schema.columns c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ku.column_name, ku.table_schema, ku.table_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage ku
|
||||||
|
ON tc.constraint_name = ku.constraint_name
|
||||||
|
AND tc.table_schema = ku.table_schema
|
||||||
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
) pk ON c.column_name = pk.column_name
|
||||||
|
AND c.table_schema = pk.table_schema
|
||||||
|
AND c.table_name = pk.table_name
|
||||||
|
WHERE c.table_schema = $1 AND c.table_name = $2
|
||||||
|
ORDER BY c.ordinal_position
|
||||||
|
`, [table.table_schema, table.table_name]);
|
||||||
|
|
||||||
|
// Get foreign keys
|
||||||
|
const fkResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name AS references_table,
|
||||||
|
ccu.column_name AS references_column,
|
||||||
|
tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_schema = $1
|
||||||
|
AND tc.table_name = $2
|
||||||
|
`, [table.table_schema, table.table_name]);
|
||||||
|
|
||||||
|
tables.push({
|
||||||
|
name: table.table_name,
|
||||||
|
schema: table.table_schema,
|
||||||
|
comment: table.table_comment,
|
||||||
|
columns: columnsResult.rows.map(col => ({
|
||||||
|
name: col.column_name,
|
||||||
|
type: col.data_type,
|
||||||
|
nullable: col.is_nullable === 'YES',
|
||||||
|
default_value: col.column_default,
|
||||||
|
is_primary: col.is_primary,
|
||||||
|
comment: col.column_comment,
|
||||||
|
})),
|
||||||
|
foreign_keys: fkResult.rows.map(fk => ({
|
||||||
|
column: fk.column_name,
|
||||||
|
references_table: fk.references_table,
|
||||||
|
references_column: fk.references_column,
|
||||||
|
constraint_name: fk.constraint_name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tables,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cached schema or return null
|
||||||
|
async function getCachedSchema(databaseId: string): Promise<SchemaData | null> {
|
||||||
|
const result = await mainPool.query(
|
||||||
|
'SELECT schema_data FROM database_schemas WHERE database_id = $1',
|
||||||
|
[databaseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
return result.rows[0].schema_data as SchemaData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save schema to cache
|
||||||
|
async function saveSchemaToCache(databaseId: string, schemaData: SchemaData): Promise<void> {
|
||||||
|
await mainPool.query(`
|
||||||
|
INSERT INTO database_schemas (database_id, schema_data, updated_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (database_id)
|
||||||
|
DO UPDATE SET schema_data = $2, updated_at = NOW()
|
||||||
|
`, [databaseId, JSON.stringify(schemaData)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/workbench/schema/:databaseId
|
||||||
|
export const getSchema = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { databaseId } = req.params;
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
let schema = await getCachedSchema(databaseId);
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
// Parse and cache if not exists
|
||||||
|
schema = await parsePostgresSchema(databaseId);
|
||||||
|
await saveSchemaToCache(databaseId, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: schema });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting schema:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/workbench/schema/:databaseId/refresh
|
||||||
|
export const refreshSchema = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { databaseId } = req.params;
|
||||||
|
|
||||||
|
// Parse fresh schema
|
||||||
|
const schema = await parsePostgresSchema(databaseId);
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
await saveSchemaToCache(databaseId, schema);
|
||||||
|
|
||||||
|
res.json({ success: true, data: schema });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error refreshing schema:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
12
backend/src/migrations/008_add_database_schemas.sql
Normal file
12
backend/src/migrations/008_add_database_schemas.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Database schemas cache table
|
||||||
|
CREATE TABLE IF NOT EXISTS database_schemas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
database_id UUID NOT NULL REFERENCES databases(id) ON DELETE CASCADE,
|
||||||
|
schema_data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(database_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_database_schemas_database_id ON database_schemas(database_id);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { authMiddleware } from '../middleware/auth';
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { executeQuery } from '../controllers/sqlInterfaceController';
|
import { executeQuery } from '../controllers/sqlInterfaceController';
|
||||||
|
import { getSchema, refreshSchema } from '../controllers/schemaController';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -8,4 +9,8 @@ router.use(authMiddleware);
|
|||||||
|
|
||||||
router.post('/execute', executeQuery);
|
router.post('/execute', executeQuery);
|
||||||
|
|
||||||
|
// Schema routes
|
||||||
|
router.get('/schema/:databaseId', getSchema);
|
||||||
|
router.post('/schema/:databaseId/refresh', refreshSchema);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@hookform/resolvers": "^3.3.3",
|
"@hookform/resolvers": "^3.3.3",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "^5.14.2",
|
"@tanstack/react-query": "^5.14.2",
|
||||||
|
"@xyflow/react": "^12.0.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Folders from '@/pages/Folders';
|
|||||||
import Logs from '@/pages/Logs';
|
import Logs from '@/pages/Logs';
|
||||||
import Settings from '@/pages/Settings';
|
import Settings from '@/pages/Settings';
|
||||||
import SqlInterface from '@/pages/SqlInterface';
|
import SqlInterface from '@/pages/SqlInterface';
|
||||||
|
import DatabaseSchema from '@/pages/DatabaseSchema';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -86,6 +87,16 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schema"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout>
|
||||||
|
<DatabaseSchema />
|
||||||
|
</Layout>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/endpoints"
|
path="/endpoints"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
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';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Home, label: 'Главная' },
|
{ to: '/', icon: Home, label: 'Главная' },
|
||||||
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' },
|
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' },
|
||||||
|
{ to: '/schema', icon: GitBranch, label: 'Схема БД' },
|
||||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||||
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
{ 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 }),
|
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;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user