Compare commits

..

38 Commits

Author SHA1 Message Date
6766cd81a1 Переработано окно эндпоинта, добавлены элементы дебага, добавлена возможность сохранять и загружать конфигурацию эндпоинта, добавлено отображение ошибок при загрузке конфигурации. Исправлены мелкие баги. 2026-03-01 16:00:26 +03:00
7e2e0103fe new file: todo 2026-01-28 01:22:57 +03:00
610bfb24a0 modified: backend/src/server.ts 2026-01-28 01:17:50 +03:00
fd08e2c3e6 modified: frontend/src/components/SqlEditor.tsx 2026-01-28 01:08:53 +03:00
fba8069b13 modified: frontend/src/components/SqlEditor.tsx 2026-01-28 01:04:22 +03:00
a306477d5d modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:58:12 +03:00
4e1044a808 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:55:05 +03:00
47e998fc52 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:54:45 +03:00
49938815fe modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:47:55 +03:00
553202c1b2 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:39:58 +03:00
981b958c41 modified: frontend/src/pages/DatabaseSchema.tsx
modified:   frontend/src/pages/DatabaseSchema.tsx
2026-01-28 00:39:49 +03:00
9293199d28 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:33:18 +03:00
e9e8081882 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:32:28 +03:00
27c5eceaf1 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:26:05 +03:00
c438c4fc83 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:21:09 +03:00
39b1b0ed5e modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-28 00:17:44 +03:00
5d3515f791 modified: docker-compose.external-db.yml 2026-01-28 00:05:12 +03:00
c5b4799dcb modified: backend/src/controllers/schemaController.ts
modified:   frontend/src/pages/DatabaseSchema.tsx
2026-01-28 00:04:01 +03:00
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
c780979b57 modified: backend/src/controllers/schemaController.ts
modified:   frontend/package.json
	modified:   frontend/src/pages/DatabaseSchema.tsx
2026-01-27 23:49:47 +03:00
d8dffb5ee1 modified: frontend/src/pages/DatabaseSchema.tsx 2026-01-27 23:44:33 +03:00
21b4d8e22b modified: Dockerfile 2026-01-27 23:43:38 +03:00
89b5a86bda 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
2026-01-27 23:42:25 +03:00
a873e18d35 modified: frontend/src/pages/SqlInterface.tsx 2026-01-27 23:33:33 +03:00
971e6d3758 modified: frontend/src/components/SqlEditor.tsx 2026-01-27 23:29:03 +03:00
45f039546b modified: backend/src/server.ts
modified:   frontend/src/App.tsx
	modified:   frontend/src/components/Sidebar.tsx
	modified:   frontend/src/services/api.ts
2026-01-27 23:27:35 +03:00
dae878089d modified: frontend/src/components/SqlEditor.tsx
modified:   frontend/src/pages/SqlInterface.tsx
2026-01-27 23:25:56 +03:00
6b507425aa modified: frontend/src/pages/SqlInterface.tsx 2026-01-27 23:23:44 +03:00
eeebcdac57 modified: frontend/src/pages/SqlInterface.tsx 2026-01-27 23:14:44 +03:00
767307857e modified: backend/.env.example
new file:   backend/src/controllers/sqlInterfaceController.ts
	new file:   backend/src/routes/sqlInterface.ts
	modified:   backend/src/server.ts
	modified:   docker-compose.external-db.yml
	modified:   frontend/src/App.tsx
	modified:   frontend/src/components/Sidebar.tsx
	new file:   frontend/src/pages/SqlInterface.tsx
	modified:   frontend/src/services/api.ts
2026-01-27 23:13:44 +03:00
35f81a1663 modified: backend/src/migrations/003_add_scripting.sql
modified:   backend/src/migrations/005_add_aql_support.sql
2025-12-18 15:03:09 +03:00
c879d9e98c modified: Dockerfile
modified:   backend/src/config/database.ts
2025-12-18 15:00:06 +03:00
a8536d7916 modified: backend/src/config/database.ts
modified:   backend/src/server.ts
2025-12-18 14:54:34 +03:00
58a319b66c modified: backend/src/controllers/dynamicApiController.ts
modified:   backend/src/controllers/endpointController.ts
	new file:   backend/src/migrations/007_add_detailed_response.sql
	modified:   frontend/src/components/EndpointModal.tsx
	modified:   frontend/src/types/index.ts
2025-12-18 14:50:33 +03:00
a5d726cf1f modified: Dockerfile 2025-12-18 13:36:30 +03:00
26fbcd0d78 modified: backend/src/server.ts 2025-12-18 13:24:41 +03:00
fac6e390ba modified: Dockerfile 2025-12-18 13:17:32 +03:00
094d0e510c new file: backend/src/migrations/006_seed_admin.sql 2025-12-18 13:10:01 +03:00
34 changed files with 3650 additions and 288 deletions

View File

@@ -9,7 +9,7 @@ WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm install
# Copy frontend source # Copy frontend source
COPY frontend/ ./ COPY frontend/ ./
@@ -44,15 +44,23 @@ FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app
# Copy backend package files and install production deps # Copy backend package files and install production deps
WORKDIR /app/backend
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm ci --only=production && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
# Copy built backend # Copy built backend (to /app/backend/dist)
COPY --from=backend-builder /app/backend/dist ./dist COPY --from=backend-builder /app/backend/dist ./dist
# Copy built frontend to the location expected by backend # Copy migrations (SQL files needed at runtime)
COPY --from=backend-builder /app/backend/src/migrations ./src/migrations
# Copy built frontend (to /app/frontend/dist)
WORKDIR /app
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Set working directory to backend
WORKDIR /app/backend
# Set environment # Set environment
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000

View File

@@ -1,5 +1,7 @@
import { Pool } from 'pg'; import { Pool } from 'pg';
import { config } from './environment'; import { config } from './environment';
import * as fs from 'fs';
import * as path from 'path';
// Main database pool for KIS API Builder metadata // Main database pool for KIS API Builder metadata
export const mainPool = new Pool({ export const mainPool = new Pool({
@@ -28,3 +30,26 @@ export const initializeDatabase = async () => {
throw error; throw error;
} }
}; };
export const runMigrations = async () => {
console.log('🔄 Running migrations...');
try {
// In compiled JS, __dirname is /app/backend/dist/config
// We need to go up to /app/backend and then into src/migrations
const migrationsDir = path.join(__dirname, '../../src/migrations');
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
console.log(` 📄 ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
await mainPool.query(sql);
}
console.log('✅ Migrations completed');
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
}
};

View File

@@ -2,6 +2,7 @@ import { Response } from 'express';
import { AuthRequest } from '../middleware/auth'; import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database'; import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager'; import { databasePoolManager } from '../services/DatabasePoolManager';
import { generateSchemaForDatabase } from './schemaController';
// Только админы могут управлять базами данных // Только админы могут управлять базами данных
export const getDatabases = async (req: AuthRequest, res: Response) => { export const getDatabases = async (req: AuthRequest, res: Response) => {
@@ -92,6 +93,13 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
// Добавить пул подключений (только для не-AQL баз) // Добавить пул подключений (только для не-AQL баз)
if (dbType !== 'aql') { if (dbType !== 'aql') {
await databasePoolManager.reloadPool(newDb.id); await databasePoolManager.reloadPool(newDb.id);
// Generate schema in background for PostgreSQL databases
if (dbType === 'postgresql') {
generateSchemaForDatabase(newDb.id).catch(err => {
console.error('Background schema generation failed:', err.message);
});
}
} }
// Не возвращаем пароль // Не возвращаем пароль

View File

@@ -233,12 +233,16 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
); );
} }
const responseData = { // Build response based on detailed_response flag
const detailedResponse = endpoint.detailed_response || false;
const responseData = detailedResponse
? {
success: true, success: true,
data: result.rows, data: result.rows,
rowCount: result.rowCount, rowCount: result.rowCount,
executionTime: result.executionTime, executionTime: result.executionTime,
}; }
: result.rows;
// Log if needed // Log if needed
if (shouldLog && endpointId) { if (shouldLog && endpointId) {

View File

@@ -2,6 +2,8 @@ import { Response } from 'express';
import { AuthRequest } from '../middleware/auth'; import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database'; import { mainPool } from '../config/database';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ExportedEndpoint, ExportedScriptQuery } from '../types';
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
export const getEndpoints = async (req: AuthRequest, res: Response) => { export const getEndpoints = async (req: AuthRequest, res: Response) => {
try { try {
@@ -85,6 +87,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint, aql_endpoint,
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response,
} = req.body; } = req.body;
if (!name || !method || !path) { if (!name || !method || !path) {
@@ -119,9 +122,9 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
name, description, method, path, database_id, sql_query, parameters, name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging, folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries, execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -143,6 +146,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint || null, aql_endpoint || null,
aql_body || null, aql_body || null,
JSON.stringify(aql_query_params || {}), JSON.stringify(aql_query_params || {}),
detailed_response || false,
] ]
); );
@@ -178,6 +182,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint, aql_endpoint,
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response,
} = req.body; } = req.body;
const result = await mainPool.query( const result = await mainPool.query(
@@ -200,8 +205,9 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint = $16, aql_endpoint = $16,
aql_body = $17, aql_body = $17,
aql_query_params = $18, aql_query_params = $18,
detailed_response = $19,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $19 WHERE id = $20
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -222,6 +228,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint || null, aql_endpoint || null,
aql_body || null, aql_body || null,
aql_query_params ? JSON.stringify(aql_query_params) : null, aql_query_params ? JSON.stringify(aql_query_params) : null,
detailed_response || false,
id, id,
] ]
); );
@@ -309,6 +316,11 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
data: result.rows, data: result.rows,
rowCount: result.rowCount, rowCount: result.rowCount,
executionTime: result.executionTime, executionTime: result.executionTime,
logs: [
{ type: 'info', message: `Query executed in ${result.executionTime}ms, returned ${result.rowCount} rows`, timestamp: Date.now() },
],
queries: [],
processedQuery,
}); });
} else if (execType === 'script') { } else if (execType === 'script') {
if (!script_language || !script_code) { if (!script_language || !script_code) {
@@ -333,7 +345,9 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
res.json({ res.json({
success: true, success: true,
data: scriptResult, data: scriptResult.result,
logs: scriptResult.logs,
queries: scriptResult.queries,
}); });
} else if (execType === 'aql') { } else if (execType === 'aql') {
if (!database_id) { if (!database_id) {
@@ -365,6 +379,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
data: result.rows, data: result.rows,
rowCount: result.rowCount, rowCount: result.rowCount,
executionTime: result.executionTime, executionTime: result.executionTime,
logs: [
{ type: 'info', message: `AQL ${aql_method} ${aql_endpoint} executed in ${result.executionTime}ms`, timestamp: Date.now() },
],
queries: [],
}); });
} else { } else {
return res.status(400).json({ error: 'Invalid execution_type' }); return res.status(400).json({ error: 'Invalid execution_type' });
@@ -373,6 +391,347 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error.message, error: error.message,
detail: error.detail || undefined,
hint: error.hint || undefined,
logs: [],
queries: [],
}); });
} }
}; };
export const exportEndpoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const endpointResult = await mainPool.query(
'SELECT * FROM endpoints WHERE id = $1',
[id]
);
if (endpointResult.rows.length === 0) {
return res.status(404).json({ error: 'Endpoint not found' });
}
const endpoint = endpointResult.rows[0];
// Resolve database_id -> name & type
let databaseName: string | null = null;
let databaseType: string | null = null;
if (endpoint.database_id) {
const dbResult = await mainPool.query(
'SELECT name, type FROM databases WHERE id = $1',
[endpoint.database_id]
);
if (dbResult.rows.length > 0) {
databaseName = dbResult.rows[0].name;
databaseType = dbResult.rows[0].type;
}
}
// Resolve folder_id -> name
let folderName: string | null = null;
if (endpoint.folder_id) {
const folderResult = await mainPool.query(
'SELECT name FROM folders WHERE id = $1',
[endpoint.folder_id]
);
if (folderResult.rows.length > 0) {
folderName = folderResult.rows[0].name;
}
}
// Resolve database_ids in script_queries
const scriptQueries = endpoint.script_queries || [];
const exportedScriptQueries: ExportedScriptQuery[] = [];
for (const sq of scriptQueries) {
let sqDbName: string | undefined;
let sqDbType: string | undefined;
if (sq.database_id) {
const sqDbResult = await mainPool.query(
'SELECT name, type FROM databases WHERE id = $1',
[sq.database_id]
);
if (sqDbResult.rows.length > 0) {
sqDbName = sqDbResult.rows[0].name;
sqDbType = sqDbResult.rows[0].type;
}
}
exportedScriptQueries.push({
name: sq.name,
sql: sq.sql,
database_name: sqDbName,
database_type: sqDbType,
aql_method: sq.aql_method,
aql_endpoint: sq.aql_endpoint,
aql_body: sq.aql_body,
aql_query_params: sq.aql_query_params,
});
}
const exportData: ExportedEndpoint = {
_format: 'kabe_v1',
name: endpoint.name,
description: endpoint.description || '',
method: endpoint.method,
path: endpoint.path,
execution_type: endpoint.execution_type || 'sql',
database_name: databaseName,
database_type: databaseType,
sql_query: endpoint.sql_query || '',
parameters: endpoint.parameters || [],
script_language: endpoint.script_language || null,
script_code: endpoint.script_code || null,
script_queries: exportedScriptQueries,
aql_method: endpoint.aql_method || null,
aql_endpoint: endpoint.aql_endpoint || null,
aql_body: endpoint.aql_body || null,
aql_query_params: endpoint.aql_query_params || null,
is_public: endpoint.is_public || false,
enable_logging: endpoint.enable_logging || false,
detailed_response: endpoint.detailed_response || false,
folder_name: folderName,
};
const encrypted = encryptEndpointData(exportData);
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_');
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`);
res.send(encrypted);
} catch (error) {
console.error('Export endpoint error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const importPreview = async (req: AuthRequest, res: Response) => {
try {
const buffer = req.body as Buffer;
if (!buffer || buffer.length === 0) {
return res.status(400).json({ error: 'No file uploaded' });
}
let exportData: ExportedEndpoint;
try {
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
} catch (err) {
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
}
if (exportData._format !== 'kabe_v1') {
return res.status(400).json({ error: 'Unsupported file format version' });
}
// Collect all referenced database names
const referencedDatabases: { name: string; type: string }[] = [];
if (exportData.database_name) {
referencedDatabases.push({
name: exportData.database_name,
type: exportData.database_type || 'unknown',
});
}
for (const sq of exportData.script_queries || []) {
if (sq.database_name && !referencedDatabases.find(d => d.name === sq.database_name)) {
referencedDatabases.push({
name: sq.database_name,
type: sq.database_type || 'unknown',
});
}
}
// Check which databases exist locally
const localDatabases = await mainPool.query(
'SELECT id, name, type FROM databases WHERE is_active = true'
);
const databaseMapping = referencedDatabases.map(ref => {
const found = localDatabases.rows.find(
(db: any) => db.name === ref.name && db.type === ref.type
);
return {
name: ref.name,
type: ref.type,
found: !!found,
local_id: found?.id || null,
};
});
// Check folder
let folder: { name: string; found: boolean; local_id: string | null } | null = null;
if (exportData.folder_name) {
const folderResult = await mainPool.query(
'SELECT id FROM folders WHERE name = $1',
[exportData.folder_name]
);
folder = {
name: exportData.folder_name,
found: folderResult.rows.length > 0,
local_id: folderResult.rows.length > 0 ? folderResult.rows[0].id : null,
};
}
// Check if path already exists
const pathCheck = await mainPool.query(
'SELECT id FROM endpoints WHERE path = $1',
[exportData.path]
);
res.json({
endpoint: {
name: exportData.name,
description: exportData.description,
method: exportData.method,
path: exportData.path,
execution_type: exportData.execution_type,
is_public: exportData.is_public,
enable_logging: exportData.enable_logging,
detailed_response: exportData.detailed_response,
folder_name: exportData.folder_name,
},
databases: databaseMapping,
all_databases_found: databaseMapping.every(d => d.found),
local_databases: localDatabases.rows.map((db: any) => ({
id: db.id,
name: db.name,
type: db.type,
})),
folder,
path_exists: pathCheck.rows.length > 0,
});
} catch (error) {
console.error('Import preview error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const importEndpoint = async (req: AuthRequest, res: Response) => {
try {
const {
file_data,
database_mapping,
folder_id,
override_path,
} = req.body;
if (!file_data) {
return res.status(400).json({ error: 'No file data provided' });
}
const buffer = Buffer.from(file_data, 'base64');
let exportData: ExportedEndpoint;
try {
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
} catch (err) {
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
}
// Resolve main database_id
let databaseId: string | null = null;
if (exportData.database_name) {
const mappedId = database_mapping?.[exportData.database_name];
if (mappedId) {
databaseId = mappedId;
} else {
const dbResult = await mainPool.query(
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
[exportData.database_name]
);
if (dbResult.rows.length > 0) {
databaseId = dbResult.rows[0].id;
} else {
return res.status(400).json({
error: `Database "${exportData.database_name}" not found and no mapping provided`
});
}
}
}
// Resolve script_queries database_ids
const resolvedScriptQueries = [];
for (const sq of exportData.script_queries || []) {
let sqDatabaseId: string | undefined;
if (sq.database_name) {
const mappedId = database_mapping?.[sq.database_name];
if (mappedId) {
sqDatabaseId = mappedId;
} else {
const sqDbResult = await mainPool.query(
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
[sq.database_name]
);
if (sqDbResult.rows.length > 0) {
sqDatabaseId = sqDbResult.rows[0].id;
} else {
return res.status(400).json({
error: `Database "${sq.database_name}" (script query "${sq.name}") not found and no mapping provided`
});
}
}
}
resolvedScriptQueries.push({
name: sq.name,
sql: sq.sql,
database_id: sqDatabaseId,
aql_method: sq.aql_method,
aql_endpoint: sq.aql_endpoint,
aql_body: sq.aql_body,
aql_query_params: sq.aql_query_params,
});
}
// Resolve folder
let resolvedFolderId: string | null = folder_id || null;
if (!resolvedFolderId && exportData.folder_name) {
const folderResult = await mainPool.query(
'SELECT id FROM folders WHERE name = $1',
[exportData.folder_name]
);
if (folderResult.rows.length > 0) {
resolvedFolderId = folderResult.rows[0].id;
}
}
const finalPath = override_path || exportData.path;
const result = await mainPool.query(
`INSERT INTO endpoints (
name, description, method, path, database_id, sql_query, parameters,
folder_id, user_id, is_public, enable_logging,
execution_type, script_language, script_code, script_queries,
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING *`,
[
exportData.name,
exportData.description || '',
exportData.method,
finalPath,
databaseId,
exportData.sql_query || '',
JSON.stringify(exportData.parameters || []),
resolvedFolderId,
req.user!.id,
exportData.is_public || false,
exportData.enable_logging || false,
exportData.execution_type || 'sql',
exportData.script_language || null,
exportData.script_code || null,
JSON.stringify(resolvedScriptQueries),
exportData.aql_method || null,
exportData.aql_endpoint || null,
exportData.aql_body || null,
JSON.stringify(exportData.aql_query_params || {}),
exportData.detailed_response || false,
]
);
res.status(201).json(result.rows[0]);
} catch (error: any) {
console.error('Import endpoint error:', error);
if (error.code === '23505') {
return res.status(400).json({ error: 'Endpoint path already exists' });
}
res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -0,0 +1,244 @@
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 - optimized with bulk queries
async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
const startTime = Date.now();
console.log(`[Schema] Starting schema parse for database ${databaseId}`);
const pool = databasePoolManager.getPool(databaseId);
if (!pool) {
throw new Error('Database not found or not active');
}
// Get all tables with comments
console.log(`[Schema] Fetching tables...`);
const tablesResult = await pool.query(`
SELECT
t.table_schema,
t.table_name,
pg_catalog.obj_description(c.oid, 'pg_class') as table_comment
FROM information_schema.tables t
LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
AND t.table_type = 'BASE TABLE'
ORDER BY t.table_schema, t.table_name
`);
console.log(`[Schema] Found ${tablesResult.rows.length} tables in ${Date.now() - startTime}ms`);
// Get ALL columns in one query
console.log(`[Schema] Fetching all columns...`);
const columnsStartTime = Date.now();
const allColumnsResult = await pool.query(`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.ordinal_position,
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
pg_catalog.col_description(pc.oid, c.ordinal_position) as column_comment
FROM information_schema.columns c
LEFT JOIN pg_catalog.pg_class pc ON pc.relname = c.table_name
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace AND pn.nspname = c.table_schema
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 NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.table_schema, c.table_name, c.ordinal_position
`);
console.log(`[Schema] Found ${allColumnsResult.rows.length} columns in ${Date.now() - columnsStartTime}ms`);
// Get ALL foreign keys in one query
console.log(`[Schema] Fetching all foreign keys...`);
const fkStartTime = Date.now();
const allFkResult = await pool.query(`
SELECT
tc.table_schema,
tc.table_name,
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 NOT IN ('pg_catalog', 'information_schema')
`);
console.log(`[Schema] Found ${allFkResult.rows.length} foreign keys in ${Date.now() - fkStartTime}ms`);
// Group columns by table
const columnsByTable = new Map<string, any[]>();
for (const col of allColumnsResult.rows) {
const key = `${col.table_schema}.${col.table_name}`;
if (!columnsByTable.has(key)) {
columnsByTable.set(key, []);
}
columnsByTable.get(key)!.push(col);
}
// Group foreign keys by table
const fkByTable = new Map<string, any[]>();
for (const fk of allFkResult.rows) {
const key = `${fk.table_schema}.${fk.table_name}`;
if (!fkByTable.has(key)) {
fkByTable.set(key, []);
}
fkByTable.get(key)!.push(fk);
}
// Build tables array
console.log(`[Schema] Building schema structure...`);
const tables: TableInfo[] = tablesResult.rows.map(table => {
const key = `${table.table_schema}.${table.table_name}`;
const columns = columnsByTable.get(key) || [];
const fks = fkByTable.get(key) || [];
return {
name: table.table_name,
schema: table.table_schema,
comment: table.table_comment,
columns: columns.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: fks.map(fk => ({
column: fk.column_name,
references_table: fk.references_table,
references_column: fk.references_column,
constraint_name: fk.constraint_name,
})),
};
});
const totalTime = Date.now() - startTime;
console.log(`[Schema] Completed! ${tables.length} tables, ${allColumnsResult.rows.length} columns, ${allFkResult.rows.length} FKs in ${totalTime}ms`);
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 });
}
};
// Generate schema for a database (called from other controllers)
export const generateSchemaForDatabase = async (databaseId: string): Promise<void> => {
try {
const schema = await parsePostgresSchema(databaseId);
await saveSchemaToCache(databaseId, schema);
console.log(`Schema generated for database ${databaseId}`);
} catch (error: any) {
console.error(`Error generating schema for database ${databaseId}:`, error.message);
// Don't throw - schema generation is not critical
}
};

View File

@@ -0,0 +1,46 @@
import { Request, Response } from 'express';
import { databasePoolManager } from '../services/DatabasePoolManager';
export const executeQuery = async (req: Request, res: Response) => {
try {
const { database_id, query } = req.body;
if (!database_id) {
return res.status(400).json({ error: 'Database ID is required' });
}
if (!query || typeof query !== 'string' || query.trim() === '') {
return res.status(400).json({ error: 'Query is required' });
}
const pool = databasePoolManager.getPool(database_id);
if (!pool) {
return res.status(404).json({ error: 'Database not found or not active' });
}
const startTime = Date.now();
const result = await pool.query(query);
const executionTime = Date.now() - startTime;
res.json({
success: true,
data: result.rows,
rowCount: result.rowCount,
fields: result.fields?.map(f => ({
name: f.name,
dataTypeID: f.dataTypeID,
})),
executionTime,
command: result.command,
});
} catch (error: any) {
console.error('SQL execution error:', error);
res.status(400).json({
success: false,
error: error.message || 'Query execution failed',
position: error.position,
detail: error.detail,
hint: error.hint,
});
}
};

View File

@@ -1,11 +1,10 @@
-- Добавление поддержки скриптинга для эндпоинтов -- Добавление поддержки скриптинга для эндпоинтов
-- Добавляем новые колонки для скриптинга -- Добавляем новые колонки для скриптинга (каждую отдельно с IF NOT EXISTS)
ALTER TABLE endpoints ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS execution_type VARCHAR(20) DEFAULT 'sql';
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'sql' CHECK (execution_type IN ('sql', 'script')), ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_language VARCHAR(20);
ADD COLUMN script_language VARCHAR(20) CHECK (script_language IN ('javascript', 'python')), ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_code TEXT;
ADD COLUMN script_code TEXT, ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_queries JSONB DEFAULT '[]'::jsonb;
ADD COLUMN script_queries JSONB DEFAULT '[]'::jsonb;
-- Комментарии для документации -- Комментарии для документации
COMMENT ON COLUMN endpoints.execution_type IS 'Тип выполнения: sql - простой SQL запрос, script - скрипт с несколькими запросами'; COMMENT ON COLUMN endpoints.execution_type IS 'Тип выполнения: sql - простой SQL запрос, script - скрипт с несколькими запросами';

View File

@@ -1,40 +1,34 @@
-- Add AQL support to databases table -- Add AQL support to databases table
ALTER TABLE databases ALTER TABLE databases ALTER COLUMN type TYPE VARCHAR(50);
ALTER COLUMN type TYPE VARCHAR(50);
-- Update the type check constraint to include 'aql' -- Update the type check constraint to include 'aql'
ALTER TABLE databases ALTER TABLE databases DROP CONSTRAINT IF EXISTS databases_type_check;
DROP CONSTRAINT IF EXISTS databases_type_check;
ALTER TABLE databases ALTER TABLE databases
ADD CONSTRAINT databases_type_check ADD CONSTRAINT databases_type_check
CHECK (type IN ('postgresql', 'mysql', 'mssql', 'aql')); CHECK (type IN ('postgresql', 'mysql', 'mssql', 'aql'));
-- Add AQL-specific columns to databases table -- Add AQL-specific columns to databases table (each separately)
ALTER TABLE databases ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_base_url TEXT;
ADD COLUMN IF NOT EXISTS aql_base_url TEXT, ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_auth_type VARCHAR(50);
ADD COLUMN IF NOT EXISTS aql_auth_type VARCHAR(50) CHECK (aql_auth_type IN ('basic', 'bearer', 'custom')), ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_auth_value TEXT;
ADD COLUMN IF NOT EXISTS aql_auth_value TEXT, ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_headers JSONB DEFAULT '{}'::jsonb;
ADD COLUMN IF NOT EXISTS aql_headers JSONB DEFAULT '{}'::jsonb;
-- Add AQL support to endpoints table -- Add AQL support to endpoints table
ALTER TABLE endpoints ALTER TABLE endpoints ALTER COLUMN execution_type TYPE VARCHAR(50);
ALTER COLUMN execution_type TYPE VARCHAR(50);
-- Update execution_type check constraint to include 'aql' -- Update execution_type check constraint to include 'aql'
ALTER TABLE endpoints ALTER TABLE endpoints DROP CONSTRAINT IF EXISTS endpoints_execution_type_check;
DROP CONSTRAINT IF EXISTS endpoints_execution_type_check;
ALTER TABLE endpoints ALTER TABLE endpoints
ADD CONSTRAINT endpoints_execution_type_check ADD CONSTRAINT endpoints_execution_type_check
CHECK (execution_type IN ('sql', 'script', 'aql')); CHECK (execution_type IN ('sql', 'script', 'aql'));
-- Add AQL-specific columns to endpoints table -- Add AQL-specific columns to endpoints table (each separately)
ALTER TABLE endpoints ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_method VARCHAR(10);
ADD COLUMN IF NOT EXISTS aql_method VARCHAR(10) CHECK (aql_method IN ('GET', 'POST', 'PUT', 'DELETE')), ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_endpoint TEXT;
ADD COLUMN IF NOT EXISTS aql_endpoint TEXT, ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_body TEXT;
ADD COLUMN IF NOT EXISTS aql_body TEXT, ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_query_params JSONB DEFAULT '{}'::jsonb;
ADD COLUMN IF NOT EXISTS aql_query_params JSONB DEFAULT '{}'::jsonb;
-- Create index for AQL endpoints -- Create index for AQL endpoints
CREATE INDEX IF NOT EXISTS idx_endpoints_execution_type ON endpoints(execution_type); CREATE INDEX IF NOT EXISTS idx_endpoints_execution_type ON endpoints(execution_type);

View File

@@ -0,0 +1,6 @@
-- Create default superadmin user (admin/admin)
-- Password should be changed after first login!
INSERT INTO users (username, password_hash, role, is_superadmin)
VALUES ('admin', '$2b$10$cYj5vFnPIqWDngjGf9guG.b.L2itqRbvcTPqxHPZIWtUHfT353L7W', 'admin', true)
ON CONFLICT (username) DO NOTHING;

View File

@@ -0,0 +1,8 @@
-- Add detailed_response flag to endpoints
-- When false (default): returns only data array
-- When true: returns full response with success, data, rowCount, executionTime
ALTER TABLE endpoints
ADD COLUMN IF NOT EXISTS detailed_response BOOLEAN DEFAULT false;
COMMENT ON COLUMN endpoints.detailed_response IS 'If true, returns detailed response with rowCount and executionTime. Default returns only data array.';

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

View File

@@ -7,6 +7,9 @@ import {
updateEndpoint, updateEndpoint,
deleteEndpoint, deleteEndpoint,
testEndpoint, testEndpoint,
exportEndpoint,
importPreview,
importEndpoint,
} from '../controllers/endpointController'; } from '../controllers/endpointController';
const router = express.Router(); const router = express.Router();
@@ -36,6 +39,44 @@ router.use(authMiddleware);
*/ */
router.get('/', getEndpoints); router.get('/', getEndpoints);
// Import routes must be before /:id to avoid "import" being treated as an id
router.post('/import/preview', express.raw({ type: 'application/octet-stream', limit: '10mb' }), importPreview);
router.post('/import', importEndpoint);
/**
* @swagger
* /api/endpoints/test:
* post:
* tags: [Endpoints]
* summary: Test SQL query
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Query test result
*/
router.post('/test', testEndpoint);
/**
* @swagger
* /api/endpoints:
* post:
* tags: [Endpoints]
* summary: Create new endpoint
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 201:
* description: Endpoint created
*/
router.post('/', createEndpoint);
/** /**
* @swagger * @swagger
* /api/endpoints/{id}: * /api/endpoints/{id}:
@@ -58,23 +99,23 @@ router.get('/:id', getEndpoint);
/** /**
* @swagger * @swagger
* /api/endpoints: * /api/endpoints/{id}/export:
* post: * get:
* tags: [Endpoints] * tags: [Endpoints]
* summary: Create new endpoint * summary: Export endpoint as .kabe file
* security: * security:
* - bearerAuth: [] * - bearerAuth: []
* requestBody: * parameters:
* - in: path
* name: id
* required: true * required: true
* content:
* application/json:
* schema: * schema:
* type: object * type: string
* responses: * responses:
* 201: * 200:
* description: Endpoint created * description: Encrypted .kabe file
*/ */
router.post('/', createEndpoint); router.get('/:id/export', exportEndpoint);
/** /**
* @swagger * @swagger
@@ -116,18 +157,4 @@ router.put('/:id', updateEndpoint);
*/ */
router.delete('/:id', deleteEndpoint); router.delete('/:id', deleteEndpoint);
/**
* @swagger
* /api/endpoints/test:
* post:
* tags: [Endpoints]
* summary: Test SQL query
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Query test result
*/
router.post('/test', testEndpoint);
export default router; export default router;

View File

@@ -0,0 +1,16 @@
import express from 'express';
import { authMiddleware } from '../middleware/auth';
import { executeQuery } from '../controllers/sqlInterfaceController';
import { getSchema, refreshSchema } from '../controllers/schemaController';
const router = express.Router();
router.use(authMiddleware);
router.post('/execute', executeQuery);
// Schema routes
router.get('/schema/:databaseId', getSchema);
router.post('/schema/:databaseId/refresh', refreshSchema);
export default router;

View File

@@ -4,9 +4,8 @@ import helmet from 'helmet';
// import rateLimit from 'express-rate-limit'; // import rateLimit from 'express-rate-limit';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import path from 'path'; import path from 'path';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { config } from './config/environment'; import { config } from './config/environment';
import { initializeDatabase } from './config/database'; import { initializeDatabase, runMigrations } from './config/database';
import { generateDynamicSwagger } from './config/dynamicSwagger'; import { generateDynamicSwagger } from './config/dynamicSwagger';
import { databasePoolManager } from './services/DatabasePoolManager'; import { databasePoolManager } from './services/DatabasePoolManager';
@@ -19,6 +18,7 @@ import databaseRoutes from './routes/databases';
import databaseManagementRoutes from './routes/databaseManagement'; import databaseManagementRoutes from './routes/databaseManagement';
import userRoutes from './routes/users'; import userRoutes from './routes/users';
import logsRoutes from './routes/logs'; import logsRoutes from './routes/logs';
import sqlInterfaceRoutes from './routes/sqlInterface';
import dynamicRoutes from './routes/dynamic'; import dynamicRoutes from './routes/dynamic';
const app: Express = express(); const app: Express = express();
@@ -66,6 +66,10 @@ app.get('/api-docs', async (_req, res, next) => {
const html = swaggerUi.generateHTML(spec, { const html = swaggerUi.generateHTML(spec, {
customCss: '.swagger-ui .topbar { display: none }', customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'KIS API Builder - Документация', customSiteTitle: 'KIS API Builder - Документация',
swaggerOptions: {
persistAuthorization: true, // Keep API key after page refresh
displayRequestDuration: true,
},
}); });
res.send(html); res.send(html);
} catch (error) { } catch (error) {
@@ -88,6 +92,7 @@ app.use('/api/databases', databaseRoutes);
app.use('/api/db-management', databaseManagementRoutes); app.use('/api/db-management', databaseManagementRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/logs', logsRoutes); app.use('/api/logs', logsRoutes);
app.use('/api/workbench', sqlInterfaceRoutes);
// Dynamic API routes (user-created endpoints) // Dynamic API routes (user-created endpoints)
app.use('/api/v1', dynamicRoutes); app.use('/api/v1', dynamicRoutes);
@@ -117,6 +122,8 @@ if (config.nodeEnv === 'production') {
}); });
} else { } else {
// Development mode - proxy to Vite dev server for non-API routes // Development mode - proxy to Vite dev server for non-API routes
// Dynamic import to avoid requiring http-proxy-middleware in production
import('http-proxy-middleware').then(({ createProxyMiddleware }) => {
const viteProxy = createProxyMiddleware({ const viteProxy = createProxyMiddleware({
target: 'http://localhost:5173', target: 'http://localhost:5173',
changeOrigin: true, changeOrigin: true,
@@ -131,6 +138,7 @@ if (config.nodeEnv === 'production') {
// Otherwise, proxy to Vite dev server // Otherwise, proxy to Vite dev server
return viteProxy(req, res, next); return viteProxy(req, res, next);
}); });
});
// 404 handler for API routes only // 404 handler for API routes only
app.use((req: Request, res: Response) => { app.use((req: Request, res: Response) => {
@@ -152,6 +160,7 @@ app.use((err: any, _req: Request, res: Response, _next: any) => {
const startServer = async () => { const startServer = async () => {
try { try {
await initializeDatabase(); await initializeDatabase();
await runMigrations();
await databasePoolManager.initialize(); await databasePoolManager.initialize();
app.listen(config.port, () => { app.listen(config.port, () => {

View File

@@ -0,0 +1,247 @@
import * as vm from 'vm';
import { sqlExecutor } from './SqlExecutor';
import { aqlExecutor } from './AqlExecutor';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
import { databasePoolManager } from './DatabasePoolManager';
interface IsolatedScriptContext {
databaseId: string;
scriptQueries: ScriptQuery[];
requestParams: Record<string, any>;
endpointParameters: EndpointParameter[];
}
export class IsolatedScriptExecutor {
private readonly TIMEOUT_MS = 600000; // 10 minutes
async execute(code: string, context: IsolatedScriptContext): Promise<IsolatedExecutionResult> {
const logs: LogEntry[] = [];
const queries: QueryExecution[] = [];
// Build captured console proxy
const capturedConsole = {
log: (...args: any[]) => {
logs.push({ type: 'log', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
},
error: (...args: any[]) => {
logs.push({ type: 'error', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
},
warn: (...args: any[]) => {
logs.push({ type: 'warn', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
},
info: (...args: any[]) => {
logs.push({ type: 'info', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
},
};
// Build execQuery function with tracking
const execQuery = async (queryName: string, additionalParams: Record<string, any> = {}) => {
const startTime = Date.now();
const query = context.scriptQueries.find(q => q.name === queryName);
if (!query) {
const entry: QueryExecution = {
name: queryName,
executionTime: Date.now() - startTime,
success: false,
error: `Query '${queryName}' not found`,
};
queries.push(entry);
throw new Error(`Query '${queryName}' not found`);
}
const allParams = { ...context.requestParams, ...additionalParams };
const dbId = (query as any).database_id || context.databaseId;
if (!dbId) {
const errMsg = `Database ID not found for query '${queryName}'. Please specify database_id in the Script Queries configuration.`;
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
throw new Error(errMsg);
}
const dbConfig = await databasePoolManager.getDatabaseConfig(dbId);
if (!dbConfig) {
const errMsg = `Database configuration not found for ID: ${dbId}`;
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
throw new Error(errMsg);
}
if (dbConfig.type === 'aql') {
try {
const result = await aqlExecutor.executeAqlQuery(dbId, {
method: query.aql_method || 'GET',
endpoint: query.aql_endpoint || '',
body: query.aql_body || '',
queryParams: query.aql_query_params || {},
parameters: allParams,
});
queries.push({
name: queryName,
executionTime: Date.now() - startTime,
rowCount: result.rowCount,
success: true,
});
return {
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
};
} catch (error: any) {
queries.push({
name: queryName,
executionTime: Date.now() - startTime,
success: false,
error: error.message,
});
return { success: false, error: error.message, data: [], rowCount: 0 };
}
} else {
if (!query.sql) {
const errMsg = `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`;
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
throw new Error(errMsg);
}
try {
let processedQuery = query.sql;
const paramValues: any[] = [];
const paramMatches = query.sql.match(/\$\w+/g) || [];
const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))];
uniqueParams.forEach((paramName, index) => {
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
processedQuery = processedQuery.replace(regex, `$${index + 1}`);
const value = allParams[paramName];
paramValues.push(value !== undefined ? value : null);
});
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
queries.push({
name: queryName,
sql: query.sql,
executionTime: Date.now() - startTime,
rowCount: result.rowCount,
success: true,
});
return {
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
};
} catch (error: any) {
queries.push({
name: queryName,
sql: query.sql,
executionTime: Date.now() - startTime,
success: false,
error: error.message,
});
return { success: false, error: error.message, data: [], rowCount: 0 };
}
}
};
// Create sandbox with null-prototype base
const sandbox = Object.create(null);
sandbox.params = context.requestParams;
sandbox.console = capturedConsole;
sandbox.execQuery = execQuery;
// Safe globals
sandbox.JSON = JSON;
sandbox.Date = Date;
sandbox.Math = Math;
sandbox.parseInt = parseInt;
sandbox.parseFloat = parseFloat;
sandbox.Array = Array;
sandbox.Object = Object;
sandbox.String = String;
sandbox.Number = Number;
sandbox.Boolean = Boolean;
sandbox.RegExp = RegExp;
sandbox.Map = Map;
sandbox.Set = Set;
sandbox.Promise = Promise;
sandbox.Error = Error;
sandbox.TypeError = TypeError;
sandbox.RangeError = RangeError;
sandbox.SyntaxError = SyntaxError;
sandbox.isNaN = isNaN;
sandbox.isFinite = isFinite;
sandbox.undefined = undefined;
sandbox.NaN = NaN;
sandbox.Infinity = Infinity;
sandbox.encodeURIComponent = encodeURIComponent;
sandbox.decodeURIComponent = decodeURIComponent;
sandbox.encodeURI = encodeURI;
sandbox.decodeURI = decodeURI;
// Capped setTimeout/clearTimeout
const timerIds = new Set<ReturnType<typeof setTimeout>>();
sandbox.setTimeout = (fn: Function, ms: number, ...args: any[]) => {
const cappedMs = Math.min(ms || 0, 30000);
const id = setTimeout(() => {
timerIds.delete(id);
fn(...args);
}, cappedMs);
timerIds.add(id);
return id;
};
sandbox.clearTimeout = (id: ReturnType<typeof setTimeout>) => {
timerIds.delete(id);
clearTimeout(id);
};
const vmContext = vm.createContext(sandbox);
// Wrap user code in async IIFE
const wrappedCode = `(async function() { ${code} })()`;
try {
const script = new vm.Script(wrappedCode, { filename: 'user-script.js' });
const resultPromise = script.runInContext(vmContext);
// Race against timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Script execution timeout (10min)')), this.TIMEOUT_MS);
});
const result = await Promise.race([resultPromise, timeoutPromise]);
// Clean up timers
for (const id of timerIds) {
clearTimeout(id);
}
timerIds.clear();
return { result, logs, queries };
} catch (error: any) {
// Clean up timers
for (const id of timerIds) {
clearTimeout(id);
}
timerIds.clear();
throw new Error(`JavaScript execution error: ${error.message}`);
}
}
private stringify(value: any): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
}
export const isolatedScriptExecutor = new IsolatedScriptExecutor();

View File

@@ -1,8 +1,9 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { sqlExecutor } from './SqlExecutor'; import { sqlExecutor } from './SqlExecutor';
import { aqlExecutor } from './AqlExecutor'; import { aqlExecutor } from './AqlExecutor';
import { ScriptQuery, EndpointParameter } from '../types'; import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
import { databasePoolManager } from './DatabasePoolManager'; import { databasePoolManager } from './DatabasePoolManager';
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
interface ScriptContext { interface ScriptContext {
databaseId: string; databaseId: string;
@@ -13,122 +14,19 @@ interface ScriptContext {
export class ScriptExecutor { export class ScriptExecutor {
/** /**
* Выполняет JavaScript скрипт * Выполняет JavaScript скрипт через изолированный VM контекст
*/ */
async executeJavaScript(code: string, context: ScriptContext): Promise<any> { async executeJavaScript(code: string, context: ScriptContext): Promise<IsolatedExecutionResult> {
try { return isolatedScriptExecutor.execute(code, context);
// Создаем функцию execQuery, доступную в скрипте
const execQuery = async (queryName: string, additionalParams: Record<string, any> = {}) => {
const query = context.scriptQueries.find(q => q.name === queryName);
if (!query) {
throw new Error(`Query '${queryName}' not found`);
}
const allParams = { ...context.requestParams, ...additionalParams };
const dbId = (query as any).database_id || context.databaseId;
if (!dbId) {
throw new Error(`Database ID not found for query '${queryName}'. Query database_id: ${(query as any).database_id}, Context databaseId: ${context.databaseId}. Please specify database_id in the Script Queries configuration for query '${queryName}'.`);
}
// Получаем конфигурацию базы данных для определения типа
const dbConfig = await databasePoolManager.getDatabaseConfig(dbId);
if (!dbConfig) {
throw new Error(`Database configuration not found for ID: ${dbId}`);
}
// Проверяем тип базы данных и выполняем соответствующий запрос
if (dbConfig.type === 'aql') {
// AQL запрос
try {
const result = await aqlExecutor.executeAqlQuery(dbId, {
method: query.aql_method || 'GET',
endpoint: query.aql_endpoint || '',
body: query.aql_body || '',
queryParams: query.aql_query_params || {},
parameters: allParams,
});
return {
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
};
} catch (error: any) {
// Возвращаем ошибку как объект, а не бросаем исключение
return {
success: false,
error: error.message,
data: [],
rowCount: 0,
};
}
} else {
// SQL запрос
if (!query.sql) {
throw new Error(`SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`);
}
try {
let processedQuery = query.sql;
const paramValues: any[] = [];
const paramMatches = query.sql.match(/\$\w+/g) || [];
const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))];
uniqueParams.forEach((paramName, index) => {
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
processedQuery = processedQuery.replace(regex, `$${index + 1}`);
const value = allParams[paramName];
paramValues.push(value !== undefined ? value : null);
});
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
console.log(`[execQuery ${queryName}] success, rowCount:`, result.rowCount);
return {
success: true,
data: result.rows,
rowCount: result.rowCount,
executionTime: result.executionTime,
};
} catch (error: any) {
// Возвращаем ошибку как объект, а не бросаем исключение
return {
success: false,
error: error.message,
data: [],
rowCount: 0,
};
}
}
};
// Создаем асинхронную функцию из кода пользователя
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const userFunction = new AsyncFunction('params', 'execQuery', code);
// Устанавливаем таймаут (10 минут)
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Script execution timeout (10min)')), 600000);
});
// Выполняем скрипт с таймаутом
const result = await Promise.race([
userFunction(context.requestParams, execQuery),
timeoutPromise
]);
return result;
} catch (error: any) {
throw new Error(`JavaScript execution error: ${error.message}`);
}
} }
/** /**
* Выполняет Python скрипт в отдельном процессе * Выполняет Python скрипт в отдельном процессе
*/ */
async executePython(code: string, context: ScriptContext): Promise<any> { async executePython(code: string, context: ScriptContext): Promise<IsolatedExecutionResult> {
const logs: LogEntry[] = [];
const queries: QueryExecution[] = [];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Сериализуем параметры в JSON строку // Сериализуем параметры в JSON строку
const paramsJson = JSON.stringify(context.requestParams); const paramsJson = JSON.stringify(context.requestParams);
@@ -179,7 +77,6 @@ print(json.dumps(result))
const python = spawn(pythonCommand, ['-c', wrapperCode]); const python = spawn(pythonCommand, ['-c', wrapperCode]);
let output = ''; let output = '';
let errorOutput = ''; let errorOutput = '';
let queryRequests: any[] = [];
python.stdout.on('data', (data) => { python.stdout.on('data', (data) => {
output += data.toString(); output += data.toString();
@@ -192,12 +89,19 @@ print(json.dumps(result))
// Проверяем на запросы к БД // Проверяем на запросы к БД
const requestMatches = text.matchAll(/__QUERY_REQUEST__(.*?)__END_REQUEST__/g); const requestMatches = text.matchAll(/__QUERY_REQUEST__(.*?)__END_REQUEST__/g);
for (const match of requestMatches) { for (const match of requestMatches) {
const queryStartTime = Date.now();
try { try {
const request = JSON.parse(match[1]); const request = JSON.parse(match[1]);
// Выполняем запрос // Выполняем запрос
const query = context.scriptQueries.find(q => q.name === request.query_name); const query = context.scriptQueries.find(q => q.name === request.query_name);
if (!query) { if (!query) {
queries.push({
name: request.query_name,
executionTime: Date.now() - queryStartTime,
success: false,
error: `Query '${request.query_name}' not found`,
});
python.stdin.write(JSON.stringify({ error: `Query '${request.query_name}' not found` }) + '\n'); python.stdin.write(JSON.stringify({ error: `Query '${request.query_name}' not found` }) + '\n');
continue; continue;
} }
@@ -206,18 +110,18 @@ print(json.dumps(result))
const dbId = (query as any).database_id || context.databaseId; const dbId = (query as any).database_id || context.databaseId;
if (!dbId) { if (!dbId) {
python.stdin.write(JSON.stringify({ const errMsg = `Database ID not found for query '${request.query_name}'.`;
error: `Database ID not found for query '${request.query_name}'. Query database_id: ${(query as any).database_id}, Context databaseId: ${context.databaseId}. Please specify database_id in the Script Queries configuration for query '${request.query_name}'.` queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg });
}) + '\n'); python.stdin.write(JSON.stringify({ error: errMsg }) + '\n');
continue; continue;
} }
// Получаем конфигурацию базы данных для определения типа // Получаем конфигурацию базы данных для определения типа
const dbConfig = await databasePoolManager.getDatabaseConfig(dbId); const dbConfig = await databasePoolManager.getDatabaseConfig(dbId);
if (!dbConfig) { if (!dbConfig) {
python.stdin.write(JSON.stringify({ const errMsg = `Database configuration not found for ID: ${dbId}`;
error: `Database configuration not found for ID: ${dbId}` queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg });
}) + '\n'); python.stdin.write(JSON.stringify({ error: errMsg }) + '\n');
continue; continue;
} }
@@ -233,6 +137,13 @@ print(json.dumps(result))
parameters: allParams, parameters: allParams,
}); });
queries.push({
name: request.query_name,
executionTime: Date.now() - queryStartTime,
rowCount: result.rowCount,
success: true,
});
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: true, success: true,
data: result.rows, data: result.rows,
@@ -240,7 +151,12 @@ print(json.dumps(result))
executionTime: result.executionTime, executionTime: result.executionTime,
}) + '\n'); }) + '\n');
} catch (error: any) { } catch (error: any) {
// Отправляем ошибку как объект, а не через поле error queries.push({
name: request.query_name,
executionTime: Date.now() - queryStartTime,
success: false,
error: error.message,
});
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: false, success: false,
error: error.message, error: error.message,
@@ -251,9 +167,11 @@ print(json.dumps(result))
} else { } else {
// SQL запрос // SQL запрос
if (!query.sql) { if (!query.sql) {
const errMsg = `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`;
queries.push({ name: request.query_name, sql: query.sql, executionTime: Date.now() - queryStartTime, success: false, error: errMsg });
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: false, success: false,
error: `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`, error: errMsg,
data: [], data: [],
rowCount: 0, rowCount: 0,
}) + '\n'); }) + '\n');
@@ -280,6 +198,14 @@ print(json.dumps(result))
paramValues paramValues
); );
queries.push({
name: request.query_name,
sql: query.sql,
executionTime: Date.now() - queryStartTime,
rowCount: result.rowCount,
success: true,
});
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: true, success: true,
data: result.rows, data: result.rows,
@@ -287,6 +213,13 @@ print(json.dumps(result))
executionTime: result.executionTime, executionTime: result.executionTime,
}) + '\n'); }) + '\n');
} catch (error: any) { } catch (error: any) {
queries.push({
name: request.query_name,
sql: query.sql,
executionTime: Date.now() - queryStartTime,
success: false,
error: error.message,
});
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: false, success: false,
error: error.message, error: error.message,
@@ -296,6 +229,12 @@ print(json.dumps(result))
} }
} }
} catch (error: any) { } catch (error: any) {
queries.push({
name: 'unknown',
executionTime: Date.now() - queryStartTime,
success: false,
error: error.message,
});
python.stdin.write(JSON.stringify({ python.stdin.write(JSON.stringify({
success: false, success: false,
error: error.message, error: error.message,
@@ -304,18 +243,38 @@ print(json.dumps(result))
}) + '\n'); }) + '\n');
} }
} }
// Capture non-query stderr output as log entries
const nonQueryLines = text.replace(/__QUERY_REQUEST__.*?__END_REQUEST__/g, '').trim();
if (nonQueryLines) {
nonQueryLines.split('\n').forEach((line: string) => {
const trimmed = line.trim();
if (trimmed) {
logs.push({ type: 'log', message: trimmed, timestamp: Date.now() });
}
});
}
}); });
python.on('close', (code) => { python.on('close', (exitCode) => {
if (code !== 0) { if (exitCode !== 0) {
reject(new Error(`Python execution error: ${errorOutput}`)); reject(new Error(`Python execution error: ${errorOutput}`));
} else { } else {
try { try {
// Последняя строка вывода - результат // Последняя строка вывода - результат, остальные - логи
const lines = output.trim().split('\n'); const lines = output.trim().split('\n');
const resultLine = lines[lines.length - 1]; const resultLine = lines[lines.length - 1];
// Capture print() output lines (everything except the last JSON result)
for (let i = 0; i < lines.length - 1; i++) {
const trimmed = lines[i].trim();
if (trimmed) {
logs.push({ type: 'log', message: trimmed, timestamp: Date.now() });
}
}
const result = JSON.parse(resultLine); const result = JSON.parse(resultLine);
resolve(result); resolve({ result, logs, queries });
} catch (error) { } catch (error) {
reject(new Error(`Failed to parse Python output: ${output}`)); reject(new Error(`Failed to parse Python output: ${output}`));
} }
@@ -337,7 +296,7 @@ print(json.dumps(result))
language: 'javascript' | 'python', language: 'javascript' | 'python',
code: string, code: string,
context: ScriptContext context: ScriptContext
): Promise<any> { ): Promise<IsolatedExecutionResult> {
if (language === 'javascript') { if (language === 'javascript') {
return this.executeJavaScript(code, context); return this.executeJavaScript(code, context);
} else if (language === 'python') { } else if (language === 'python') {

View File

@@ -0,0 +1,35 @@
import crypto from 'crypto';
const ENCRYPTION_KEY = 'kis-api-builder-endpoint-key-32b'; // exactly 32 bytes for AES-256
const ALGORITHM = 'aes-256-gcm';
export function encryptEndpointData(data: object): Buffer {
const json = JSON.stringify(data);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'utf-8'), iv);
const encrypted = Buffer.concat([
cipher.update(json, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Format: [16 bytes IV][16 bytes authTag][...encrypted data]
return Buffer.concat([iv, authTag, encrypted]);
}
export function decryptEndpointData(buffer: Buffer): object {
const iv = buffer.subarray(0, 16);
const authTag = buffer.subarray(16, 32);
const encrypted = buffer.subarray(32);
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'utf-8'), iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return JSON.parse(decrypted.toString('utf8'));
}

View File

@@ -101,6 +101,27 @@ export interface QueryResult {
executionTime: number; executionTime: number;
} }
export interface LogEntry {
type: 'log' | 'error' | 'warn' | 'info';
message: string;
timestamp: number;
}
export interface QueryExecution {
name: string;
sql?: string;
executionTime: number;
rowCount?: number;
success: boolean;
error?: string;
}
export interface IsolatedExecutionResult {
result: any;
logs: LogEntry[];
queries: QueryExecution[];
}
export interface SwaggerEndpoint { export interface SwaggerEndpoint {
tags: string[]; tags: string[];
summary: string; summary: string;
@@ -109,3 +130,38 @@ export interface SwaggerEndpoint {
responses: any; responses: any;
security?: any[]; security?: any[];
} }
export interface ExportedScriptQuery {
name: string;
sql?: string;
database_name?: string;
database_type?: string;
aql_method?: string;
aql_endpoint?: string;
aql_body?: string;
aql_query_params?: Record<string, string>;
}
export interface ExportedEndpoint {
_format: 'kabe_v1';
name: string;
description: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
execution_type: 'sql' | 'script' | 'aql';
database_name: string | null;
database_type: string | null;
sql_query: string;
parameters: EndpointParameter[];
script_language: string | null;
script_code: string | null;
script_queries: ExportedScriptQuery[];
aql_method: string | null;
aql_endpoint: string | null;
aql_body: string | null;
aql_query_params: Record<string, string> | null;
is_public: boolean;
enable_logging: boolean;
detailed_response: boolean;
folder_name: string | null;
}

View File

@@ -13,8 +13,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: kis-api-builder:latest
container_name: kis-api-builder-app container_name: kis-api-builder-app
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
environment: environment:

View File

@@ -8,9 +8,11 @@
"name": "kis-api-builder-frontend", "name": "kis-api-builder-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.8",
"@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.10.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",
@@ -369,6 +371,24 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz",
"integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "2.2.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
"integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
"license": "MIT",
"engines": {
"node": ">17.0.0"
}
},
"node_modules/@emotion/is-prop-valid": { "node_modules/@emotion/is-prop-valid": {
"version": "0.8.8", "version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
@@ -1724,6 +1744,55 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2009,6 +2078,38 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2383,6 +2484,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2495,6 +2602,111 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",

View File

@@ -14,9 +14,11 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.8",
"@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.10.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",

View File

@@ -13,6 +13,8 @@ import ApiKeys from '@/pages/ApiKeys';
import Folders from '@/pages/Folders'; 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 DatabaseSchema from '@/pages/DatabaseSchema';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -75,6 +77,26 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/workbench"
element={
<PrivateRoute>
<Layout>
<SqlInterface />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/schema"
element={
<PrivateRoute>
<Layout>
<DatabaseSchema />
</Layout>
</PrivateRoute>
}
/>
<Route <Route
path="/endpoints" path="/endpoints"
element={ element={

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { endpointsApi, foldersApi } from '@/services/api'; import { endpointsApi, foldersApi } from '@/services/api';
import { Endpoint, EndpointParameter } from '@/types'; import { Endpoint, EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types';
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp } from 'lucide-react'; import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, X, CheckCircle, XCircle, Clock } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import SqlEditor from '@/components/SqlEditor'; import SqlEditor from '@/components/SqlEditor';
import CodeEditor from '@/components/CodeEditor'; import CodeEditor from '@/components/CodeEditor';
@@ -41,12 +41,16 @@ export default function EndpointModal({
aql_endpoint: endpoint?.aql_endpoint || '', aql_endpoint: endpoint?.aql_endpoint || '',
aql_body: endpoint?.aql_body || '', aql_body: endpoint?.aql_body || '',
aql_query_params: endpoint?.aql_query_params || {}, aql_query_params: endpoint?.aql_query_params || {},
// Response format
detailed_response: endpoint?.detailed_response || false,
}); });
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null); const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false); const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
const [parametersExpanded, setParametersExpanded] = useState(true); const [parametersExpanded, setParametersExpanded] = useState(true);
const [queriesExpanded, setQueriesExpanded] = useState(true); const [queriesExpanded, setQueriesExpanded] = useState(true);
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
// Определяем тип выбранной базы данных // Определяем тип выбранной базы данных
const selectedDatabase = databases.find(db => db.id === formData.database_id); const selectedDatabase = databases.find(db => db.id === formData.database_id);
@@ -63,7 +67,39 @@ export default function EndpointModal({
onError: () => toast.error('Не удалось сохранить эндпоинт'), onError: () => toast.error('Не удалось сохранить эндпоинт'),
}); });
const [testParams, setTestParams] = useState<any>({}); // Restore test params and result from localStorage
const storageKey = endpoint?.id ? `test_${endpoint.id}` : null;
const [testParams, setTestParams] = useState<any>(() => {
if (storageKey) {
try {
const saved = localStorage.getItem(storageKey);
if (saved) return JSON.parse(saved).testParams || {};
} catch {}
}
return {};
});
// Restore testResult from localStorage on mount
useEffect(() => {
if (storageKey) {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.testResult) setTestResult(parsed.testResult);
}
} catch {}
}
}, [storageKey]);
// Save testParams and testResult to localStorage
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
} catch {}
}
}, [storageKey, testParams, testResult]);
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -118,10 +154,20 @@ export default function EndpointModal({
} }
}, },
onSuccess: (response) => { onSuccess: (response) => {
toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`); setTestResult(response.data);
setActiveResultTab('data');
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error.response?.data?.error || 'Ошибка тестирования запроса'); const errorData = error.response?.data;
setTestResult({
success: false,
error: errorData?.error || error.message || 'Ошибка тестирования запроса',
detail: errorData?.detail,
hint: errorData?.hint,
logs: errorData?.logs || [],
queries: errorData?.queries || [],
});
setActiveResultTab('data');
}, },
}); });
@@ -592,7 +638,7 @@ export default function EndpointModal({
</> </>
)} )}
<div className="flex items-center gap-6"> <div className="flex flex-wrap items-center gap-6">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -612,6 +658,16 @@ export default function EndpointModal({
/> />
<span className="text-sm text-gray-700">Логгировать запросы</span> <span className="text-sm text-gray-700">Логгировать запросы</span>
</label> </label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.detailed_response}
onChange={(e) => setFormData({ ...formData, detailed_response: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Детализировать ответ (rowCount, executionTime)</span>
</label>
</div> </div>
{formData.parameters.length > 0 && ( {formData.parameters.length > 0 && (
@@ -677,6 +733,202 @@ export default function EndpointModal({
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'} {saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'}
</button> </button>
</div> </div>
{/* Test Results Panel */}
{testResult && (
<div className="border border-gray-200 rounded-lg mt-4">
{/* Status bar */}
<div className={`flex items-center justify-between px-4 py-2 rounded-t-lg ${testResult.success ? 'bg-green-50 border-b border-green-200' : 'bg-red-50 border-b border-red-200'}`}>
<div className="flex items-center gap-2">
{testResult.success ? (
<CheckCircle size={16} className="text-green-600" />
) : (
<XCircle size={16} className="text-red-600" />
)}
<span className={`text-sm font-medium ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
{testResult.success ? 'Успешно' : 'Ошибка'}
</span>
{testResult.executionTime !== undefined && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Clock size={12} /> {testResult.executionTime}мс
</span>
)}
{testResult.rowCount !== undefined && (
<span className="text-xs text-gray-500">
| {testResult.rowCount} строк
</span>
)}
</div>
<button
type="button"
onClick={() => setTestResult(null)}
className="p-1 hover:bg-gray-200 rounded"
>
<X size={14} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
<button
type="button"
onClick={() => setActiveResultTab('data')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'data' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
Результат
</button>
<button
type="button"
onClick={() => setActiveResultTab('logs')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'logs' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
Логи
{testResult.logs && testResult.logs.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-gray-200 text-gray-600 rounded-full text-xs">{testResult.logs.length}</span>
)}
</button>
{formData.execution_type === 'script' && (
<button
type="button"
onClick={() => setActiveResultTab('queries')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'queries' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
Запросы
{testResult.queries && testResult.queries.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-gray-200 text-gray-600 rounded-full text-xs">{testResult.queries.length}</span>
)}
</button>
)}
</div>
{/* Tab content */}
<div className="max-h-80 overflow-auto">
{activeResultTab === 'data' && (
<div className="p-3">
{!testResult.success ? (
<div className="space-y-2">
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
<div className="font-medium">{testResult.error}</div>
{testResult.detail && <div className="mt-1 text-xs">{testResult.detail}</div>}
{testResult.hint && <div className="mt-1 text-xs text-red-600">Hint: {testResult.hint}</div>}
</div>
</div>
) : testResult.data !== undefined ? (
(() => {
// Normalize data to array for table rendering
const dataArray = Array.isArray(testResult.data) ? testResult.data : [testResult.data];
if (dataArray.length === 0) {
return <p className="text-sm text-gray-500 text-center py-4">Нет данных</p>;
}
// Get columns from first row
const firstRow = dataArray[0];
if (typeof firstRow !== 'object' || firstRow === null) {
return (
<pre className="text-xs font-mono bg-gray-50 p-3 rounded whitespace-pre-wrap max-h-60 overflow-auto">
{JSON.stringify(testResult.data, null, 2)}
</pre>
);
}
const columns = Object.keys(firstRow);
return (
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="bg-gray-50">
<th className="px-2 py-1.5 text-left font-medium text-gray-500 border-b">#</th>
{columns.map(col => (
<th key={col} className="px-2 py-1.5 text-left font-medium text-gray-500 border-b">{col}</th>
))}
</tr>
</thead>
<tbody>
{dataArray.slice(0, 100).map((row: any, idx: number) => (
<tr key={idx} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-2 py-1 text-gray-400 border-b">{idx + 1}</td>
{columns.map(col => (
<td key={col} className="px-2 py-1 border-b font-mono max-w-xs truncate" title={String(row[col] ?? '')}>
{row[col] === null ? <span className="text-gray-400 italic">null</span> : typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
{dataArray.length > 100 && (
<p className="text-xs text-gray-500 text-center py-2">Показано 100 из {dataArray.length} строк</p>
)}
</div>
);
})()
) : (
<p className="text-sm text-gray-500 text-center py-4">Нет данных</p>
)}
</div>
)}
{activeResultTab === 'logs' && (
<div className="p-3 space-y-1">
{testResult.processedQuery && (
<div className="mb-3 p-2 bg-blue-50 border border-blue-200 rounded">
<div className="text-xs font-medium text-blue-700 mb-1">Обработанный запрос:</div>
<pre className="text-xs font-mono text-blue-800 whitespace-pre-wrap">{testResult.processedQuery}</pre>
</div>
)}
{(!testResult.logs || testResult.logs.length === 0) ? (
<p className="text-sm text-gray-500 text-center py-4">Нет логов</p>
) : (
testResult.logs.map((log: LogEntry, idx: number) => (
<div key={idx} className={`flex items-start gap-2 px-2 py-1 rounded text-xs font-mono ${
log.type === 'error' ? 'bg-red-50 text-red-700' :
log.type === 'warn' ? 'bg-yellow-50 text-yellow-700' :
log.type === 'info' ? 'bg-blue-50 text-blue-700' :
'bg-gray-50 text-gray-700'
}`}>
<span className="text-gray-400 shrink-0">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className={`shrink-0 uppercase font-semibold ${
log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' :
log.type === 'info' ? 'text-blue-500' :
'text-gray-400'
}`}>[{log.type}]</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>
)}
{activeResultTab === 'queries' && (
<div className="p-3 space-y-2">
{(!testResult.queries || testResult.queries.length === 0) ? (
<p className="text-sm text-gray-500 text-center py-4">Нет запросов</p>
) : (
testResult.queries.map((q: QueryExecution, idx: number) => (
<div key={idx} className={`border rounded p-3 ${q.success ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{q.success ? <CheckCircle size={14} className="text-green-600" /> : <XCircle size={14} className="text-red-600" />}
<span className="text-sm font-medium text-gray-900">{q.name}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"><Clock size={12} /> {q.executionTime}мс</span>
{q.rowCount !== undefined && <span>{q.rowCount} строк</span>}
</div>
</div>
{q.sql && (
<pre className="text-xs font-mono text-gray-600 bg-white p-2 rounded mt-1 whitespace-pre-wrap max-h-20 overflow-auto">{q.sql}</pre>
)}
{q.error && (
<div className="text-xs text-red-600 mt-1">{q.error}</div>
)}
</div>
))
)}
</div>
)}
</div>
</div>
)}
</form> </form>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,222 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { endpointsApi } from '@/services/api';
import { ImportPreviewResponse } from '@/types';
import { X, AlertTriangle, CheckCircle, Database, ArrowRight } from 'lucide-react';
import toast from 'react-hot-toast';
interface ImportEndpointModalProps {
preview: ImportPreviewResponse;
file: File;
onClose: () => void;
}
export default function ImportEndpointModal({ preview, file, onClose }: ImportEndpointModalProps) {
const queryClient = useQueryClient();
const [databaseMapping, setDatabaseMapping] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
preview.databases.forEach(db => {
if (db.found && db.local_id) {
initial[db.name] = db.local_id;
}
});
return initial;
});
const [overridePath, setOverridePath] = useState(preview.endpoint.path);
const [folderId] = useState<string | null>(
preview.folder?.found ? preview.folder.local_id : null
);
const importMutation = useMutation({
mutationFn: async () => {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return endpointsApi.importConfirm({
file_data: base64,
database_mapping: databaseMapping,
folder_id: folderId,
override_path: preview.path_exists ? overridePath : undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
toast.success('Эндпоинт успешно импортирован');
onClose();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Ошибка импорта эндпоинта');
},
});
const allMapped = preview.databases.every(
db => db.found || databaseMapping[db.name]
);
const handleMappingChange = (sourceName: string, localId: string) => {
setDatabaseMapping(prev => ({ ...prev, [sourceName]: localId }));
};
const methodColor = (method: string) => {
switch (method) {
case 'GET': return 'bg-green-100 text-green-700';
case 'POST': return 'bg-blue-100 text-blue-700';
case 'PUT': return 'bg-yellow-100 text-yellow-700';
case 'DELETE': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Импорт эндпоинта</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<X size={24} />
</button>
</div>
<div className="p-6 space-y-6">
{/* Endpoint Preview */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Информация об эндпоинте</h3>
<div className="space-y-2 text-sm">
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700 w-32">Название:</span>
<span>{preview.endpoint.name}</span>
</div>
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700 w-32">Метод:</span>
<span className={`px-2 py-0.5 text-xs font-semibold rounded ${methodColor(preview.endpoint.method)}`}>
{preview.endpoint.method}
</span>
</div>
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700 w-32">Путь:</span>
<code className="bg-gray-200 px-2 py-0.5 rounded">{preview.endpoint.path}</code>
</div>
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700 w-32">Тип:</span>
<span>{preview.endpoint.execution_type}</span>
</div>
{preview.endpoint.description && (
<div className="flex gap-2">
<span className="font-medium text-gray-700 w-32 flex-shrink-0">Описание:</span>
<span>{preview.endpoint.description}</span>
</div>
)}
</div>
</div>
{/* Path conflict */}
{preview.path_exists && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle size={18} className="text-yellow-600" />
<span className="font-medium text-yellow-800">Путь уже существует</span>
</div>
<p className="text-sm text-yellow-700 mb-2">
Эндпоинт с путем <code className="bg-yellow-100 px-1 rounded">{preview.endpoint.path}</code> уже существует. Укажите другой путь:
</p>
<input
type="text"
value={overridePath}
onChange={(e) => setOverridePath(e.target.value)}
className="input w-full"
/>
</div>
)}
{/* Database Mapping */}
{preview.databases.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Сопоставление баз данных</h3>
<div className="space-y-3">
{preview.databases.map((db) => (
<div key={db.name} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Database size={16} className="text-gray-500 flex-shrink-0" />
<div className="truncate">
<span className="font-medium text-gray-900">{db.name}</span>
<span className="text-xs text-gray-500 ml-1">({db.type})</span>
</div>
</div>
<ArrowRight size={16} className="text-gray-400 flex-shrink-0" />
<div className="flex-1">
{db.found ? (
<div className="flex items-center gap-1 text-green-700">
<CheckCircle size={16} />
<span className="text-sm">Найдена</span>
</div>
) : (
<select
value={databaseMapping[db.name] || ''}
onChange={(e) => handleMappingChange(db.name, e.target.value)}
className="input w-full text-sm"
>
<option value="">-- Выберите базу данных --</option>
{preview.local_databases
.filter(local => local.type === db.type)
.map(local => (
<option key={local.id} value={local.id}>
{local.name} ({local.type})
</option>
))
}
{preview.local_databases.filter(local => local.type !== db.type).length > 0 && (
<optgroup label="Другие типы">
{preview.local_databases
.filter(local => local.type !== db.type)
.map(local => (
<option key={local.id} value={local.id}>
{local.name} ({local.type})
</option>
))
}
</optgroup>
)}
</select>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Folder info */}
{preview.folder && !preview.folder.found && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-700">
Папка &quot;{preview.folder.name}&quot; не найдена. Эндпоинт будет импортирован в корневую папку.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-6 border-t border-gray-200 justify-end">
<button onClick={onClose} className="btn btn-secondary">
Отмена
</button>
<button
onClick={() => importMutation.mutate()}
disabled={!allMapped || importMutation.isPending}
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{importMutation.isPending ? 'Импорт...' : 'Импортировать'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink } 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: '/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 Ключи' },

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import Editor, { Monaco, loader } from '@monaco-editor/react'; import Editor, { Monaco, loader } from '@monaco-editor/react';
import { databasesApi } from '@/services/api'; import { schemaApi, TableInfo, SchemaData } from '@/services/api';
import * as monacoEditor from 'monaco-editor'; import * as monacoEditor from 'monaco-editor';
// Configure loader to use local Monaco // Configure loader to use local Monaco
@@ -11,66 +11,160 @@ interface SqlEditorProps {
onChange: (value: string) => void; onChange: (value: string) => void;
databaseId?: string; databaseId?: string;
height?: string; height?: string;
tabId?: string;
} }
// Cache for table names with 5-minute expiration // Cache for schema with 5-minute expiration
interface TableCache { interface SchemaCache {
tables: string[]; schema: SchemaData;
timestamp: number; timestamp: number;
} }
const tablesCache = new Map<string, TableCache>(); const schemaCache = new Map<string, SchemaCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const getCachedTables = async (databaseId: string): Promise<string[]> => { const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
const cached = tablesCache.get(databaseId); const cached = schemaCache.get(databaseId);
const now = Date.now(); const now = Date.now();
// Return cached data if it exists and is not expired
if (cached && (now - cached.timestamp) < CACHE_DURATION) { if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.tables; return cached.schema;
} }
// Fetch fresh data
try { try {
const { data } = await databasesApi.getTables(databaseId); const { data } = await schemaApi.getSchema(databaseId);
tablesCache.set(databaseId, { if (data.success && data.data) {
tables: data.tables, schemaCache.set(databaseId, {
schema: data.data,
timestamp: now, timestamp: now,
}); });
return data.tables; return data.data;
}
return null;
} catch (error) { } catch (error) {
console.error('Failed to fetch table names:', error); console.error('Failed to fetch schema:', error);
// Return cached data if available, even if expired, as fallback return cached?.schema || null;
return cached?.tables || [];
} }
}; };
// Global flag to ensure we only register the completion provider once // Global state for completion provider
let completionProviderRegistered = false; let completionProviderRegistered = false;
let currentDatabaseId: string | undefined; let currentSchema: SchemaData | null = null;
export default function SqlEditor({ value, onChange, databaseId, height = '400px' }: SqlEditorProps) { // Parse SQL to find table aliases (e.g., FROM users u, JOIN orders o)
function parseTableAliases(sql: string): Map<string, string> {
const aliases = new Map<string, string>();
// Match patterns like: FROM table_name alias, FROM table_name AS alias
// JOIN table_name alias, JOIN table_name AS alias
const patterns = [
/(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi,
/,\s*(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(sql)) !== null) {
const tableName = match[1].toLowerCase();
const alias = match[2]?.toLowerCase();
if (alias && alias !== 'on' && alias !== 'where' && alias !== 'join' && alias !== 'left' && alias !== 'inner' && alias !== 'right') {
aliases.set(alias, tableName);
}
// Also map table name to itself for direct references
aliases.set(tableName, tableName);
}
}
return aliases;
}
// Find tables mentioned in the query
function findTablesInQuery(sql: string, schema: SchemaData): TableInfo[] {
const tables: TableInfo[] = [];
const sqlLower = sql.toLowerCase();
for (const table of schema.tables) {
if (sqlLower.includes(table.name.toLowerCase())) {
tables.push(table);
}
}
return tables;
}
// Get FK suggestions for ON clause
function getFkSuggestions(
tablesInQuery: TableInfo[],
schema: SchemaData,
monaco: Monaco,
range: any,
sql: string
): any[] {
const suggestions: any[] = [];
const aliases = parseTableAliases(sql);
// Create reverse alias map: tableName -> alias (or tableName if no alias)
const tableToAlias = new Map<string, string>();
for (const [alias, tableName] of aliases) {
// Prefer shorter alias over table name
const existing = tableToAlias.get(tableName);
if (!existing || alias.length < existing.length) {
tableToAlias.set(tableName, alias);
}
}
for (const table of tablesInQuery) {
for (const fk of table.foreign_keys) {
// Find the referenced table
const refTable = schema.tables.find(t => t.name === fk.references_table);
if (refTable && tablesInQuery.some(t => t.name === refTable.name)) {
// Use alias if available, otherwise table name
const sourceAlias = tableToAlias.get(table.name) || table.name;
const targetAlias = tableToAlias.get(refTable.name) || refTable.name;
const joinCondition = `${sourceAlias}.${fk.column} = ${targetAlias}.${fk.references_column}`;
suggestions.push({
label: joinCondition,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: joinCondition,
detail: `FK: ${table.name}.${fk.column}${refTable.name}.${fk.references_column}`,
documentation: `Foreign key relationship between ${table.name} and ${refTable.name}`,
range,
sortText: '0' + joinCondition,
});
}
}
}
return suggestions;
}
export default function SqlEditor({ value, onChange, databaseId, height, tabId }: SqlEditorProps) {
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco | null>(null);
// Update current database ID when it changes // Fetch schema when database changes
useEffect(() => { useEffect(() => {
currentDatabaseId = databaseId; if (databaseId) {
getCachedSchema(databaseId).then(schema => {
currentSchema = schema;
});
} else {
currentSchema = null;
}
}, [databaseId]); }, [databaseId]);
const handleEditorDidMount = (editor: any, monaco: Monaco) => { const handleEditorDidMount = (editor: any, monaco: Monaco) => {
editorRef.current = editor; editorRef.current = editor;
monacoRef.current = monaco; monacoRef.current = monaco;
// Register completion provider only once
if (completionProviderRegistered) { if (completionProviderRegistered) {
return; return;
} }
completionProviderRegistered = true; completionProviderRegistered = true;
// Configure SQL language features
monaco.languages.registerCompletionItemProvider('sql', { monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' '],
provideCompletionItems: async (model, position) => { provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position); const word = model.getWordUntilPosition(position);
const range = { const range = {
@@ -80,42 +174,147 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
endColumn: word.endColumn, endColumn: word.endColumn,
}; };
let suggestions: any[] = [ const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const lineContent = model.getLineContent(position.lineNumber);
const textBeforeCursor = lineContent.substring(0, position.column - 1);
let suggestions: any[] = [];
// Check if we're after a dot (table.column completion)
const dotMatch = textBeforeCursor.match(/(\w+)\.\s*$/);
if (dotMatch && currentSchema) {
const prefix = dotMatch[1].toLowerCase();
const aliases = parseTableAliases(textUntilPosition);
const tableName = aliases.get(prefix) || prefix;
const table = currentSchema.tables.find(t => t.name.toLowerCase() === tableName);
if (table) {
// Return only columns for this table
return {
suggestions: table.columns.map(col => ({
label: col.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: col.name,
detail: `${col.type}${col.is_primary ? ' (PK)' : ''}${!col.nullable ? ' NOT NULL' : ''}`,
documentation: col.comment || undefined,
range,
sortText: col.is_primary ? '0' + col.name : '1' + col.name,
})),
};
}
}
// Check if we're after ON keyword (FK suggestions)
// Match: "ON ", "ON ", "on ", after JOIN ... ON
const fullText = model.getValue();
const onMatch = textBeforeCursor.match(/\bON\s*$/i) || textUntilPosition.match(/\bJOIN\s+\w+\s+\w*\s+ON\s*$/i);
if (onMatch && currentSchema) {
const tablesInQuery = findTablesInQuery(fullText, currentSchema);
const fkSuggestions = getFkSuggestions(tablesInQuery, currentSchema, monaco, range, fullText);
// Add FK suggestions to the top, but also include other suggestions
if (fkSuggestions.length > 0) {
suggestions = [...fkSuggestions, ...suggestions];
return { suggestions };
}
}
// SQL Keywords // SQL Keywords
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range }, const keywords = [
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range }, 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range }, 'FULL OUTER JOIN', 'CROSS JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE',
{ label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range }, 'BETWEEN', 'IS NULL', 'IS NOT NULL', 'GROUP BY', 'ORDER BY', 'HAVING',
{ label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range }, 'LIMIT', 'OFFSET', 'UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT',
{ label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range }, 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range }, 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'TRUNCATE',
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range }, 'DISTINCT', 'AS', 'ASC', 'DESC', 'NULLS FIRST', 'NULLS LAST',
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range }, 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'COALESCE', 'NULLIF',
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range }, 'EXISTS', 'NOT EXISTS', 'ANY', 'ALL',
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
]; ];
// Fetch table names from database if databaseId is provided (with caching) suggestions = keywords.map(kw => ({
if (currentDatabaseId) { label: kw,
const tables = await getCachedTables(currentDatabaseId); kind: monaco.languages.CompletionItemKind.Keyword,
const tableSuggestions = tables.map(table => ({ insertText: kw + ' ',
label: table,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table,
detail: 'Table',
range, range,
sortText: '2' + kw,
})); }));
suggestions = [...suggestions, ...tableSuggestions];
// SQL Functions
const functions = [
{ name: 'COUNT', snippet: 'COUNT($0)' },
{ name: 'SUM', snippet: 'SUM($0)' },
{ name: 'AVG', snippet: 'AVG($0)' },
{ name: 'MAX', snippet: 'MAX($0)' },
{ name: 'MIN', snippet: 'MIN($0)' },
{ name: 'COALESCE', snippet: 'COALESCE($1, $0)' },
{ name: 'NULLIF', snippet: 'NULLIF($1, $0)' },
{ name: 'CAST', snippet: 'CAST($1 AS $0)' },
{ name: 'CONCAT', snippet: 'CONCAT($0)' },
{ name: 'SUBSTRING', snippet: 'SUBSTRING($1 FROM $2 FOR $0)' },
{ name: 'TRIM', snippet: 'TRIM($0)' },
{ name: 'UPPER', snippet: 'UPPER($0)' },
{ name: 'LOWER', snippet: 'LOWER($0)' },
{ name: 'LENGTH', snippet: 'LENGTH($0)' },
{ name: 'NOW', snippet: 'NOW()' },
{ name: 'CURRENT_DATE', snippet: 'CURRENT_DATE' },
{ name: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP' },
{ name: 'DATE_TRUNC', snippet: "DATE_TRUNC('$1', $0)" },
{ name: 'EXTRACT', snippet: 'EXTRACT($1 FROM $0)' },
{ name: 'TO_CHAR', snippet: "TO_CHAR($1, '$0')" },
{ name: 'TO_DATE', snippet: "TO_DATE($1, '$0')" },
{ name: 'ARRAY_AGG', snippet: 'ARRAY_AGG($0)' },
{ name: 'STRING_AGG', snippet: "STRING_AGG($1, '$0')" },
{ name: 'JSON_AGG', snippet: 'JSON_AGG($0)' },
{ name: 'ROW_NUMBER', snippet: 'ROW_NUMBER() OVER ($0)' },
{ name: 'RANK', snippet: 'RANK() OVER ($0)' },
{ name: 'DENSE_RANK', snippet: 'DENSE_RANK() OVER ($0)' },
{ name: 'LAG', snippet: 'LAG($1) OVER ($0)' },
{ name: 'LEAD', snippet: 'LEAD($1) OVER ($0)' },
];
suggestions.push(...functions.map(fn => ({
label: fn.name,
kind: monaco.languages.CompletionItemKind.Function,
insertText: fn.snippet,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: 'Function',
range,
sortText: '3' + fn.name,
})));
// Add tables and columns from schema
if (currentSchema) {
// Tables
for (const table of currentSchema.tables) {
suggestions.push({
label: table.name,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table.name,
detail: `Table (${table.columns.length} columns)`,
documentation: table.comment || undefined,
range,
sortText: '1' + table.name,
});
// All columns (with table prefix for context)
for (const col of table.columns) {
suggestions.push({
label: `${table.name}.${col.name}`,
kind: monaco.languages.CompletionItemKind.Field,
insertText: `${table.name}.${col.name}`,
detail: col.type,
documentation: col.comment || undefined,
range,
sortText: '4' + table.name + col.name,
});
}
}
} }
return { suggestions }; return { suggestions };
@@ -124,10 +323,11 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
}; };
return ( return (
<div className="border border-gray-300 rounded-lg overflow-hidden"> <div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}>
<Editor <Editor
height={height} height="100%"
defaultLanguage="sql" language="sql"
path={tabId}
value={value} value={value}
onChange={(value) => onChange(value || '')} onChange={(value) => onChange(value || '')}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}

View File

@@ -0,0 +1,692 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ReactFlow,
Node,
Edge,
Background,
Controls,
MiniMap,
useNodesState,
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, Search, X, Table2, MessageSquare, Columns } from 'lucide-react';
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
import { Database } from '@/types';
import { useAuthStore } from '@/stores/authStore';
// Search result type
interface SearchResult {
type: 'table' | 'column' | 'comment';
tableName: string;
tableSchema: string;
columnName?: string;
displayText: string;
secondaryText?: string;
}
// Custom node for table with per-column handles
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
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));
return (
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
<div
className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm"
title={data.comment || undefined}
>
<div className="flex items-center gap-2">
<DatabaseIcon size={14} />
<span className="truncate">{data.name}</span>
</div>
</div>
<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 cursor-default relative"
title={col.comment || undefined}
>
{/* Handles on both sides for dynamic edge routing */}
{pkColumns.has(col.name) && (
<>
<Handle
type="target"
position={Position.Left}
id={`target-left-${col.name}`}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="target"
position={Position.Right}
id={`target-right-${col.name}`}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)}
{fkColumns.has(col.name) && (
<>
<Handle
type="source"
position={Position.Left}
id={`source-left-${col.name}`}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="source"
position={Position.Right}
id={`source-right-${col.name}`}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{col.is_primary && <Key size={11} className="text-yellow-500" />}
{fkColumns.has(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>
{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,
};
function getNodeHeight(table: TableInfo): number {
const headerHeight = 44;
const columnHeight = 26;
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
}
function calculateTableWeights(schema: SchemaData): Map<string, number> {
const weights = new Map<string, number>();
schema.tables.forEach(table => {
const key = `${table.schema}.${table.name}`;
let weight = table.foreign_keys.length;
schema.tables.forEach(other => {
other.foreign_keys.forEach(fk => {
if (fk.references_table === table.name) {
weight++;
}
});
});
weights.set(key, weight);
});
return weights;
}
// Build edges with optimal handle selection based on node positions
function buildEdgesWithPositions(
schema: SchemaData,
nodePositions: Map<string, { x: number; y: number; width: number }>
): Edge[] {
const edges: Edge[] = [];
schema.tables.forEach((table) => {
const sourceId = `${table.schema}.${table.name}`;
const sourcePos = nodePositions.get(sourceId);
if (!sourcePos) return;
table.foreign_keys.forEach((fk) => {
let targetTable = schema.tables.find(t =>
t.name === fk.references_table && t.schema === table.schema
);
if (!targetTable) {
targetTable = schema.tables.find(t => t.name === fk.references_table);
}
if (targetTable) {
const targetId = `${targetTable.schema}.${targetTable.name}`;
const targetPos = nodePositions.get(targetId);
if (!targetPos) return;
// Calculate center positions
const sourceCenterX = sourcePos.x + sourcePos.width / 2;
const targetCenterX = targetPos.x + targetPos.width / 2;
// Choose handles based on relative positions
const sourceOnRight = sourceCenterX < targetCenterX;
const sourceHandle = sourceOnRight
? `source-right-${fk.column}`
: `source-left-${fk.column}`;
const targetHandle = sourceOnRight
? `target-left-${fk.references_column}`
: `target-right-${fk.references_column}`;
edges.push({
id: `${fk.constraint_name}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
width: 15,
height: 15,
},
});
}
});
});
return edges;
}
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]; nodePositions: Map<string, { x: number; y: number; width: number }> } {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
const weights = calculateTableWeights(schema);
const sortedTables = [...schema.tables].sort((a, b) => {
const weightA = weights.get(`${a.schema}.${a.name}`) || 0;
const weightB = weights.get(`${b.schema}.${b.name}`) || 0;
return weightB - weightA;
});
g.setGraph({
rankdir: 'TB',
nodesep: 40,
ranksep: 80,
marginx: 30,
marginy: 30,
ranker: 'network-simplex',
});
const nodeWidth = 280;
sortedTables.forEach((table) => {
const nodeId = `${table.schema}.${table.name}`;
const height = getNodeHeight(table);
g.setNode(nodeId, { width: nodeWidth, height });
});
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, { weight: 2 });
}
});
});
Dagre.layout(g);
const nodePositions = new Map<string, { x: number; y: number; width: number }>();
const nodes: Node[] = schema.tables.map((table) => {
const nodeId = `${table.schema}.${table.name}`;
const nodeWithPosition = g.node(nodeId);
const x = nodeWithPosition.x - nodeWithPosition.width / 2;
const y = nodeWithPosition.y - nodeWithPosition.height / 2;
nodePositions.set(nodeId, { x, y, width: nodeWidth });
return {
id: nodeId,
type: 'table',
position: { x, y },
data: { ...table } as TableInfo & Record<string, unknown>,
};
});
const edges = buildEdgesWithPositions(schema, nodePositions);
return { nodes, edges, nodePositions };
}
export default function DatabaseSchema() {
const queryClient = useQueryClient();
const user = useAuthStore((state) => state.user);
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'],
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;
// 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 => {
const tableNameLower = table.name.toLowerCase();
// Search by table name
if (tableNameLower.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,
});
}
});
});
// Calculate relevance score (lower = better match)
const getRelevanceScore = (result: SearchResult): number => {
const searchTarget = result.columnName?.toLowerCase() || result.tableName.toLowerCase();
// Exact match
if (searchTarget === query) return 0;
// Starts with query
if (searchTarget.startsWith(query)) return 1;
// Query position in string (earlier = better)
const position = searchTarget.indexOf(query);
return 2 + position;
};
// Sort: by type (table -> column -> comment), then by relevance
const typeOrder: Record<SearchResult['type'], number> = {
'table': 0,
'column': 1,
'comment': 2,
};
results.sort((a, b) => {
// First by type
const typeDiff = typeOrder[a.type] - typeOrder[b.type];
if (typeDiff !== 0) return typeDiff;
// Then by relevance within same type
return getRelevanceScore(a) - getRelevanceScore(b);
});
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, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [nodePositions, setNodePositions] = useState<Map<string, { x: number; y: number; width: number }>>(new Map());
const { nodes: layoutedNodes, edges: layoutedEdges, nodePositions: initialPositions } = useMemo(() => {
if (!filteredSchema || filteredSchema.tables.length === 0) {
return { nodes: [], edges: [], nodePositions: new Map() };
}
return getLayoutedElements(filteredSchema);
}, [filteredSchema]);
// Update positions when layout changes
useEffect(() => {
setNodePositions(initialPositions);
}, [initialPositions]);
// Reset and rebuild when filtered schema changes
useEffect(() => {
// Clear first to avoid stale edges
setNodes([]);
setEdges([]);
// Then set new layout after a tick
if (layoutedNodes.length > 0) {
requestAnimationFrame(() => {
setNodes(layoutedNodes);
// Edges will be set by nodePositions effect
});
}
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
// Recalculate edges when positions change
useEffect(() => {
if (!filteredSchema || nodePositions.size === 0) return;
const newEdges = buildEdgesWithPositions(filteredSchema, nodePositions);
setEdges(newEdges);
}, [nodePositions, filteredSchema, setEdges]);
// Recalculate edges when nodes are dragged
const handleNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
if (!filteredSchema) return;
setNodePositions(prev => {
const updated = new Map(prev);
updated.set(node.id, { x: node.position.x, y: node.position.y, width: 280 });
return updated;
});
}, [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 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) {
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);
setSelectedTables(new Set());
setSearchQuery('');
// Clear canvas immediately on database change
setNodes([]);
setEdges([]);
}}
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 && 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 && user?.is_superadmin && (
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-md disabled:opacity-50 text-sm"
title="Обновить схему из базы данных"
>
{refreshMutation.isPending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 21h5v-5"/>
</svg>
)}
</button>
)}
{schema && (
<div className="text-sm text-gray-500 ml-auto">
{filteredSchema ? filteredSchema.tables.length : 0} / {schema.tables.length} таблиц
</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 */}
<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}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.01}
maxZoom={2}
onlyRenderVisibleElements={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
>
<Background color="#e5e7eb" gap={16} />
<Controls />
<MiniMap
nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.1)"
style={{ background: '#f3f4f6' }}
pannable
zoomable
/>
</ReactFlow>
)}
{selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && (
<div className="flex items-center justify-center h-full text-gray-400">
В базе данных нет таблиц
</div>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { endpointsApi, databasesApi } from '@/services/api'; import { endpointsApi, databasesApi } from '@/services/api';
import { Endpoint } from '@/types'; import { Endpoint, ImportPreviewResponse } from '@/types';
import { Plus, Search, Edit2, Trash2 } from 'lucide-react'; import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EndpointModal from '@/components/EndpointModal'; import EndpointModal from '@/components/EndpointModal';
import ImportEndpointModal from '@/components/ImportEndpointModal';
import Dialog from '@/components/Dialog'; import Dialog from '@/components/Dialog';
export default function Endpoints() { export default function Endpoints() {
@@ -12,6 +13,10 @@ export default function Endpoints() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null); const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
isOpen: boolean; isOpen: boolean;
title: string; title: string;
@@ -66,6 +71,42 @@ export default function Endpoints() {
setShowModal(true); setShowModal(true);
}; };
const handleExport = async (endpointId: string, endpointName: string) => {
try {
const response = await endpointsApi.exportEndpoint(endpointId);
const blob = new Blob([response.data]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${endpointName.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_')}.kabe`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Эндпоинт экспортирован');
} catch {
toast.error('Ошибка экспорта эндпоинта');
}
};
const handleImportFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.kabe')) {
toast.error('Выберите файл с расширением .kabe');
return;
}
try {
const response = await endpointsApi.importPreview(file);
setImportFile(file);
setImportPreview(response.data);
setShowImportModal(true);
} catch (error: any) {
toast.error(error.response?.data?.error || 'Ошибка чтения файла');
}
e.target.value = '';
};
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -73,11 +114,27 @@ export default function Endpoints() {
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1>
<p className="text-gray-600">Управление динамическими API эндпоинтами</p> <p className="text-gray-600">Управление динамическими API эндпоинтами</p>
</div> </div>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept=".kabe"
className="hidden"
onChange={handleImportFileSelect}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="btn btn-secondary flex items-center gap-2"
>
<Upload size={20} />
Импорт
</button>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2"> <button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
<Plus size={20} /> <Plus size={20} />
Новый эндпоинт Новый эндпоинт
</button> </button>
</div> </div>
</div>
<div className="card p-4 mb-6"> <div className="card p-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -138,6 +195,13 @@ export default function Endpoints() {
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button
onClick={() => handleExport(endpoint.id, endpoint.name)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Экспорт"
>
<Download size={18} className="text-gray-600" />
</button>
<button <button
onClick={() => handleEdit(endpoint)} onClick={() => handleEdit(endpoint)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -173,6 +237,18 @@ export default function Endpoints() {
/> />
)} )}
{showImportModal && importPreview && importFile && (
<ImportEndpointModal
preview={importPreview}
file={importFile}
onClose={() => {
setShowImportModal(false);
setImportFile(null);
setImportPreview(null);
}}
/>
)}
<Dialog <Dialog
isOpen={dialog.isOpen} isOpen={dialog.isOpen}
onClose={() => setDialog({ ...dialog, isOpen: false })} onClose={() => setDialog({ ...dialog, isOpen: false })}

View File

@@ -0,0 +1,482 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2, GripHorizontal } from 'lucide-react';
import { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api';
import { Database } from '@/types';
import SqlEditor from '@/components/SqlEditor';
interface SqlTab {
id: string;
name: string;
query: string;
databaseId: string;
result: SqlQueryResult | null;
isExecuting: boolean;
}
interface SqlInterfaceState {
tabs: SqlTab[];
activeTabId: string;
splitPosition: number; // percentage
}
const STORAGE_KEY = 'sql_interface_state';
const generateTabId = () => `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const createNewTab = (databaseId: string = '', name?: string): SqlTab => ({
id: generateTabId(),
name: name || 'Новый запрос',
query: '',
databaseId,
result: null,
isExecuting: false,
});
const loadState = (): SqlInterfaceState | null => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
console.error('Failed to load SQL interface state:', e);
}
return null;
};
const saveState = (state: SqlInterfaceState) => {
try {
const stateToSave = {
...state,
tabs: state.tabs.map(tab => ({
...tab,
isExecuting: false,
result: tab.result ? {
...tab.result,
data: tab.result.data?.slice(0, 100),
} : null,
})),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (e) {
console.error('Failed to save SQL interface state:', e);
}
};
export default function SqlInterface() {
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
queryKey: ['databases'],
queryFn: async () => {
const { data } = await databasesApi.getAll();
return data.filter((db: Database) => db.type !== 'aql');
},
});
const [state, setState] = useState<SqlInterfaceState>(() => {
const saved = loadState();
if (saved && saved.tabs.length > 0) {
return {
...saved,
splitPosition: saved.splitPosition || 50,
};
}
const initialTab = createNewTab();
return {
tabs: [initialTab],
activeTabId: initialTab.id,
splitPosition: 50,
};
});
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
useEffect(() => {
saveState(state);
}, [state]);
useEffect(() => {
if (databases.length > 0) {
setState(prev => ({
...prev,
tabs: prev.tabs.map(tab =>
tab.databaseId === '' ? { ...tab, databaseId: databases[0].id } : tab
),
}));
}
}, [databases]);
const activeTab = state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
const updateTab = useCallback((tabId: string, updates: Partial<SqlTab>) => {
setState(prev => ({
...prev,
tabs: prev.tabs.map(tab =>
tab.id === tabId ? { ...tab, ...updates } : tab
),
}));
}, []);
const addTab = useCallback(() => {
const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`);
setState(prev => ({
...prev,
tabs: [...prev.tabs, newTab],
activeTabId: newTab.id,
}));
}, [databases, state.tabs.length]);
const closeTab = useCallback((tabId: string, e: React.MouseEvent) => {
e.stopPropagation();
setState(prev => {
if (prev.tabs.length === 1) {
const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId);
return {
...prev,
tabs: [newTab],
activeTabId: newTab.id,
};
}
const newTabs = prev.tabs.filter(t => t.id !== tabId);
const newActiveId = prev.activeTabId === tabId
? newTabs[Math.max(0, prev.tabs.findIndex(t => t.id === tabId) - 1)].id
: prev.activeTabId;
return {
...prev,
tabs: newTabs,
activeTabId: newActiveId,
};
});
}, [databases]);
const setActiveTab = useCallback((tabId: string) => {
setState(prev => ({ ...prev, activeTabId: tabId }));
}, []);
const executeQuery = useCallback(async () => {
if (!activeTab || !activeTab.databaseId || !activeTab.query.trim()) {
return;
}
updateTab(activeTab.id, { isExecuting: true, result: null });
try {
const { data } = await sqlInterfaceApi.execute(activeTab.databaseId, activeTab.query);
updateTab(activeTab.id, { isExecuting: false, result: data });
} catch (error: any) {
updateTab(activeTab.id, {
isExecuting: false,
result: {
success: false,
error: error.response?.data?.error || error.message || 'Query execution failed',
detail: error.response?.data?.detail,
hint: error.response?.data?.hint,
},
});
}
}, [activeTab, updateTab]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
executeQuery();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [executeQuery]);
// Drag to resize
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
const clamped = Math.min(Math.max(percentage, 20), 80);
setState(prev => ({ ...prev, splitPosition: clamped }));
};
const handleMouseUp = () => {
if (isDragging.current) {
isDragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
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)' }}>
{/* Tabs bar */}
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2 flex-shrink-0 overflow-x-auto">
{state.tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200 flex-shrink-0
${tab.id === state.activeTabId
? 'bg-white text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}
`}
>
<span className="max-w-32 truncate">{tab.name}</span>
{tab.isExecuting && <Loader2 className="animate-spin" size={14} />}
<button
onClick={(e) => closeTab(tab.id, e)}
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
</button>
))}
<button
onClick={addTab}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded flex-shrink-0"
title="Новая вкладка"
>
<Plus size={18} />
</button>
</div>
{/* 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={activeTab?.databaseId || ''}
onChange={(e) => updateTab(activeTab.id, { databaseId: 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>
<button
onClick={executeQuery}
disabled={!activeTab?.databaseId || !activeTab?.query.trim() || activeTab?.isExecuting}
className="flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{activeTab?.isExecuting ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Play size={16} />
)}
Выполнить
<span className="text-xs opacity-75 ml-1">(Ctrl+Enter)</span>
</button>
<input
type="text"
value={activeTab?.name || ''}
onChange={(e) => updateTab(activeTab.id, { name: 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 w-48"
placeholder="Название вкладки"
/>
</div>
{/* Main content area with resizable split */}
<div ref={containerRef} className="flex-1 flex flex-col min-h-0 relative">
{/* SQL Editor */}
<div
className="min-h-0"
style={{ height: `${state.splitPosition}%` }}
>
<SqlEditor
value={activeTab?.query || ''}
onChange={(value) => updateTab(activeTab.id, { query: value })}
databaseId={activeTab?.databaseId}
tabId={activeTab?.id}
/>
</div>
{/* Resize handle */}
<div
onMouseDown={handleMouseDown}
className="h-2 bg-gray-200 hover:bg-primary-300 cursor-row-resize flex items-center justify-center border-y border-gray-300 flex-shrink-0 transition-colors"
>
<GripHorizontal size={16} className="text-gray-400" />
</div>
{/* Results panel */}
<div
className="flex flex-col bg-white overflow-hidden"
style={{ height: `calc(${100 - state.splitPosition}% - 8px)` }}
>
{/* Result header */}
{activeTab?.result && (
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm flex-shrink-0">
{activeTab.result.success ? (
<>
<div className="flex items-center gap-1 text-green-600">
<CheckCircle size={16} />
<span>Успешно</span>
</div>
<div className="flex items-center gap-1 text-gray-600">
<span>{activeTab.result.rowCount ?? 0} строк</span>
</div>
{activeTab.result.executionTime !== undefined && (
<div className="flex items-center gap-1 text-gray-500">
<Clock size={14} />
<span>{activeTab.result.executionTime} мс</span>
</div>
)}
{activeTab.result.command && (
<span className="text-gray-400">{activeTab.result.command}</span>
)}
</>
) : (
<div className="flex items-center gap-1 text-red-600">
<XCircle size={16} />
<span>Ошибка</span>
</div>
)}
</div>
)}
{/* Result content */}
<div className="flex-1 overflow-auto">
{!activeTab?.result && !activeTab?.isExecuting && (
<div className="flex items-center justify-center h-full text-gray-400">
Выполните запрос, чтобы увидеть результаты
</div>
)}
{activeTab?.isExecuting && (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-primary-600" size={32} />
</div>
)}
{activeTab?.result && !activeTab.result.success && (
<div className="p-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700 font-medium">{activeTab.result.error}</p>
{activeTab.result.detail && (
<p className="text-red-600 mt-2 text-sm">{activeTab.result.detail}</p>
)}
{activeTab.result.hint && (
<p className="text-red-500 mt-2 text-sm italic">Подсказка: {activeTab.result.hint}</p>
)}
</div>
</div>
)}
{activeTab?.result?.success && activeTab.result.data && (
<ResultTable
data={activeTab.result.data}
fields={activeTab.result.fields || []}
/>
)}
</div>
</div>
</div>
</div>
);
}
interface ResultTableProps {
data: any[];
fields: { name: string; dataTypeID: number }[];
}
function ResultTable({ data, fields }: ResultTableProps) {
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Запрос выполнен успешно, но не вернул данных
</div>
);
}
const columns = fields.length > 0
? fields.map(f => f.name)
: Object.keys(data[0] || {});
return (
<div className="overflow-auto h-full">
<table className="w-full text-sm">
<thead className="bg-gray-100 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 border-r border-b border-gray-200 w-12">
#
</th>
{columns.map(col => (
<th
key={col}
className="px-3 py-2 text-left text-xs font-medium text-gray-600 border-r border-b border-gray-200 whitespace-nowrap"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 border-b border-gray-100">
<td className="px-3 py-1.5 text-gray-400 border-r border-gray-100 text-xs">
{rowIndex + 1}
</td>
{columns.map(col => (
<td
key={col}
className="px-3 py-1.5 border-r border-gray-100 max-w-md truncate"
title={formatCellValue(row[col])}
>
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function formatCellValue(value: any): string {
if (value === null) return 'NULL';
if (value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult } from '@/types'; import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse } from '@/types';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -109,15 +109,37 @@ export const endpointsApi = {
test: (data: { test: (data: {
database_id: string; database_id: string;
execution_type?: 'sql' | 'script'; execution_type?: 'sql' | 'script' | 'aql';
sql_query?: string; sql_query?: string;
parameters?: any[]; parameters?: any[];
endpoint_parameters?: any[]; endpoint_parameters?: any[];
script_language?: 'javascript' | 'python'; script_language?: 'javascript' | 'python';
script_code?: string; script_code?: string;
script_queries?: any[]; script_queries?: any[];
aql_method?: string;
aql_endpoint?: string;
aql_body?: string;
aql_query_params?: Record<string, string>;
}) => }) =>
api.post<QueryTestResult>('/endpoints/test', data), api.post<QueryTestResult>('/endpoints/test', data),
exportEndpoint: (id: string) =>
api.get(`/endpoints/${id}/export`, { responseType: 'blob' }),
importPreview: (file: File) =>
file.arrayBuffer().then(buffer =>
api.post<ImportPreviewResponse>('/endpoints/import/preview', buffer, {
headers: { 'Content-Type': 'application/octet-stream' },
})
),
importConfirm: (data: {
file_data: string;
database_mapping: Record<string, string>;
folder_id?: string | null;
override_path?: string;
}) =>
api.post<Endpoint>('/endpoints/import', data),
}; };
// Folders API // Folders API
@@ -168,4 +190,61 @@ export const databasesApi = {
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`), api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
}; };
// SQL Interface API
export interface SqlQueryResult {
success: boolean;
data?: any[];
rowCount?: number;
fields?: { name: string; dataTypeID: number }[];
executionTime?: number;
command?: string;
error?: string;
position?: number;
detail?: string;
hint?: string;
}
export const sqlInterfaceApi = {
execute: (databaseId: string, query: string) =>
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;

View File

@@ -78,6 +78,8 @@ export interface Endpoint {
aql_endpoint?: string; aql_endpoint?: string;
aql_body?: string; aql_body?: string;
aql_query_params?: Record<string, string>; aql_query_params?: Record<string, string>;
// Response format
detailed_response?: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -93,10 +95,62 @@ export interface ApiKey {
expires_at: string | null; expires_at: string | null;
} }
export interface LogEntry {
type: 'log' | 'error' | 'warn' | 'info';
message: string;
timestamp: number;
}
export interface QueryExecution {
name: string;
sql?: string;
executionTime: number;
rowCount?: number;
success: boolean;
error?: string;
}
export interface QueryTestResult { export interface QueryTestResult {
success: boolean; success: boolean;
data?: any[]; data?: any;
rowCount?: number; rowCount?: number;
executionTime?: number; executionTime?: number;
error?: string; error?: string;
detail?: string;
hint?: string;
logs: LogEntry[];
queries: QueryExecution[];
processedQuery?: string;
}
export interface ImportPreviewDatabase {
name: string;
type: string;
found: boolean;
local_id: string | null;
}
export interface ImportPreviewFolder {
name: string;
found: boolean;
local_id: string | null;
}
export interface ImportPreviewResponse {
endpoint: {
name: string;
description: string;
method: string;
path: string;
execution_type: string;
is_public: boolean;
enable_logging: boolean;
detailed_response: boolean;
folder_name: string | null;
};
databases: ImportPreviewDatabase[];
all_databases_found: boolean;
local_databases: { id: string; name: string; type: string }[];
folder: ImportPreviewFolder | null;
path_exists: boolean;
} }

2
todo Normal file
View File

@@ -0,0 +1,2 @@
Протестировать изолированную среду для выполнения скриптов
Добавить расширенное тесирование эндпоинта прямо в окно с эндпоинтом (отображение логов, результатов, возможность дебага)