Compare commits

14 Commits

Author SHA1 Message Date
c918f34595 modified: cli 2026-03-14 17:44:44 +03:00
c047319afe modified: cli 2026-03-14 17:42:54 +03:00
46396c6fc4 Fix TypeScript errors in syncController — cast Date arguments to String
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:11:36 +03:00
dcdd6fee6d modified: cli 2026-03-14 17:10:11 +03:00
4ae281209f modified: backend/src/server.ts 2026-03-14 16:33:30 +03:00
f0cbe99cb0 modified: frontend/src/pages/EndpointEditor.tsx
modified:   frontend/src/pages/Folders.tsx
2026-03-13 18:23:21 +03:00
727c6765f8 modified: backend/src/config/dynamicSwagger.ts
modified:   backend/src/controllers/endpointController.ts
	new file:   backend/src/migrations/009_add_response_schema.sql
	modified:   backend/src/types/index.ts
	modified:   frontend/src/pages/EndpointEditor.tsx
	modified:   frontend/src/types/index.ts
2026-03-13 15:22:32 +03:00
b6b7064a41 modified: backend/src/server.ts 2026-03-11 11:11:09 +03:00
49f262d8ae Fix script execution logs being lost
- Add ScriptExecutionError class that preserves captured logs/queries
- IsolatedScriptExecutor: throw ScriptExecutionError with accumulated
  logs instead of plain Error on script failure
- ScriptExecutor (Python): same fix for Python execution errors
- testEndpoint: return captured logs/queries on script errors
- dynamicApiController: correctly extract scriptResult.result instead
  of stuffing entire IsolatedExecutionResult into rows; include logs
  in detailed_response output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:52:51 +03:00
3a3c87164d Fix export endpoint error with Cyrillic filenames
Content-Disposition header cannot contain non-ASCII characters.
Use RFC 5987 encoding (filename*=UTF-8'') for proper Cyrillic
filename support, with ASCII-only fallback in the base filename.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:25:11 +03:00
e8aab43a13 Merge branch 'main' of ssh://gitea.esh-service.ru:2222/public/api_builder 2026-03-01 19:32:37 +03:00
659ebf71ea modified: backend/src/types/index.ts
modified:   frontend/src/App.tsx
	deleted:    frontend/src/components/EndpointModal.tsx
	new file:   frontend/src/pages/EndpointEditor.tsx
	modified:   frontend/src/pages/Endpoints.tsx
	modified:   frontend/src/pages/Folders.tsx
	modified:   frontend/src/types/index.ts
2026-03-01 19:32:23 +03:00
75d4c0b45d Merge pull request 'Переработано окно эндпоинта, добавлены элементы дебага, добавлена возможность сохранять и загружать конфигурацию эндпоинта, добавлено …' (#1) from addDebuggingInEndpointsWindow into main
Reviewed-on: #1
2026-03-01 16:01:57 +03:00
6766cd81a1 Переработано окно эндпоинта, добавлены элементы дебага, добавлена возможность сохранять и загружать конфигурацию эндпоинта, добавлено отображение ошибок при загрузке конфигурации. Исправлены мелкие баги. 2026-03-01 16:00:26 +03:00
25 changed files with 3396 additions and 1233 deletions

View File

@@ -44,6 +44,7 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
e.path, e.path,
e.parameters, e.parameters,
e.is_public, e.is_public,
e.response_schema,
fp.full_path as folder_name fp.full_path as folder_name
FROM endpoints e FROM endpoints e
LEFT JOIN folder_path fp ON e.folder_id = fp.id LEFT JOIN folder_path fp ON e.folder_id = fp.id
@@ -136,7 +137,9 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
description: 'Успешный ответ', description: 'Успешный ответ',
content: { content: {
'application/json': { 'application/json': {
schema: { schema: endpoint.response_schema
? endpoint.response_schema
: {
type: 'object', type: 'object',
properties: { properties: {
success: { type: 'boolean' }, success: { type: 'boolean' },

View File

@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
import { mainPool } from '../config/database'; import { mainPool } from '../config/database';
import { sqlExecutor } from '../services/SqlExecutor'; import { sqlExecutor } from '../services/SqlExecutor';
import { scriptExecutor } from '../services/ScriptExecutor'; import { scriptExecutor } from '../services/ScriptExecutor';
import { EndpointParameter, ScriptQuery } from '../types'; import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types';
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => { export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
const startTime = Date.now(); const startTime = Date.now();
@@ -202,9 +202,11 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
}); });
result = { result = {
rows: scriptResult, rows: scriptResult.result,
rowCount: 0, rowCount: 0,
executionTime: 0, executionTime: 0,
scriptLogs: scriptResult.logs,
scriptQueries: scriptResult.queries,
}; };
} else { } else {
// Execute SQL query // Execute SQL query
@@ -241,6 +243,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
data: result.rows, data: result.rows,
rowCount: result.rowCount, rowCount: result.rowCount,
executionTime: result.executionTime, executionTime: result.executionTime,
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
} }
: result.rows; : result.rows;
@@ -267,9 +271,12 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
} catch (error: any) { } catch (error: any) {
console.error('Dynamic API execution error:', error); console.error('Dynamic API execution error:', error);
const errorResponse = { const isScriptError = error instanceof ScriptExecutionError;
const errorResponse: any = {
success: false, success: false,
error: error.message, error: error.message,
...(isScriptError && error.logs.length > 0 ? { logs: error.logs } : {}),
...(isScriptError && error.queries.length > 0 ? { queries: error.queries } : {}),
}; };
// Log error if needed // Log error if needed

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, ScriptExecutionError } 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 {
@@ -86,6 +88,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response, detailed_response,
response_schema,
} = req.body; } = req.body;
if (!name || !method || !path) { if (!name || !method || !path) {
@@ -120,9 +123,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, detailed_response aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -145,6 +148,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
aql_body || null, aql_body || null,
JSON.stringify(aql_query_params || {}), JSON.stringify(aql_query_params || {}),
detailed_response || false, detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
] ]
); );
@@ -181,6 +185,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_body, aql_body,
aql_query_params, aql_query_params,
detailed_response, detailed_response,
response_schema,
} = req.body; } = req.body;
const result = await mainPool.query( const result = await mainPool.query(
@@ -204,8 +209,9 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
aql_body = $17, aql_body = $17,
aql_query_params = $18, aql_query_params = $18,
detailed_response = $19, detailed_response = $19,
response_schema = $20,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $20 WHERE id = $21
RETURNING *`, RETURNING *`,
[ [
name, name,
@@ -227,6 +233,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
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, detailed_response || false,
response_schema ? JSON.stringify(response_schema) : null,
id, id,
] ]
); );
@@ -314,6 +321,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) {
@@ -338,7 +350,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) {
@@ -370,14 +384,363 @@ 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' });
} }
} catch (error: any) { } catch (error: any) {
const isScriptError = error instanceof ScriptExecutionError;
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: isScriptError ? error.logs : [],
queries: isScriptError ? error.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,
response_schema: endpoint.response_schema || null,
folder_name: folderName,
};
const encrypted = encryptEndpointData(exportData);
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
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, response_schema
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
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,
exportData.response_schema ? JSON.stringify(exportData.response_schema) : null,
]
);
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,296 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
/**
* GET /api/sync/pull
* Returns all endpoints + folders + database name mappings for CLI sync
*/
export const syncPull = async (req: AuthRequest, res: Response) => {
try {
// Get all endpoints with folder and database names
const endpointsResult = await mainPool.query(`
SELECT
e.*,
f.name as folder_name,
d.name as database_name,
d.type as database_type
FROM endpoints e
LEFT JOIN folders f ON e.folder_id = f.id
LEFT JOIN databases d ON e.database_id = d.id
ORDER BY e.created_at ASC
`);
// Get all folders with parent info
const foldersResult = await mainPool.query(`
SELECT f.*, pf.name as parent_name
FROM folders f
LEFT JOIN folders pf ON f.parent_id = pf.id
ORDER BY f.created_at ASC
`);
// Get database name->id mapping (active only)
const databasesResult = await mainPool.query(
'SELECT id, name, type FROM databases WHERE is_active = true ORDER BY name'
);
// Resolve script_queries database names
const endpoints = await Promise.all(endpointsResult.rows.map(async (ep: any) => {
const scriptQueries = ep.script_queries || [];
const resolvedScriptQueries = [];
for (const sq of scriptQueries) {
let sqDbName: string | null = null;
let sqDbType: string | null = null;
if (sq.database_id) {
const sqDb = databasesResult.rows.find((d: any) => d.id === sq.database_id);
if (sqDb) {
sqDbName = sqDb.name;
sqDbType = sqDb.type;
}
}
resolvedScriptQueries.push({
...sq,
database_name: sqDbName,
database_type: sqDbType,
});
}
return {
id: ep.id,
name: ep.name,
description: ep.description,
method: ep.method,
path: ep.path,
execution_type: ep.execution_type || 'sql',
database_name: ep.database_name,
database_type: ep.database_type,
database_id: ep.database_id,
sql_query: ep.sql_query,
parameters: ep.parameters || [],
script_language: ep.script_language,
script_code: ep.script_code,
script_queries: resolvedScriptQueries,
aql_method: ep.aql_method,
aql_endpoint: ep.aql_endpoint,
aql_body: ep.aql_body,
aql_query_params: ep.aql_query_params,
is_public: ep.is_public,
enable_logging: ep.enable_logging,
detailed_response: ep.detailed_response,
response_schema: ep.response_schema,
folder_id: ep.folder_id,
folder_name: ep.folder_name,
created_at: ep.created_at,
updated_at: ep.updated_at,
};
}));
const folders = foldersResult.rows.map((f: any) => ({
id: f.id,
name: f.name,
parent_id: f.parent_id,
parent_name: f.parent_name,
created_at: f.created_at,
updated_at: f.updated_at,
}));
res.json({
endpoints,
folders,
databases: databasesResult.rows,
server_time: new Date().toISOString(),
});
} catch (error) {
console.error('Sync pull error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* POST /api/sync/status
* Accepts {endpoints: [{id, updated_at}], folders: [{id, updated_at}]}
* Returns which items changed on server since the given timestamps
*/
export const syncStatus = async (req: AuthRequest, res: Response) => {
try {
const { endpoints: clientEndpoints, folders: clientFolders } = req.body;
// Get current server state
const serverEndpoints = await mainPool.query(
'SELECT id, name, updated_at FROM endpoints'
);
const serverFolders = await mainPool.query(
'SELECT id, name, updated_at FROM folders'
);
const clientEndpointMap = new Map(
(clientEndpoints || []).map((e: any) => [e.id, e.updated_at])
);
const clientFolderMap = new Map(
(clientFolders || []).map((f: any) => [f.id, f.updated_at])
);
// Find changed/new/deleted endpoints
const changedEndpoints: any[] = [];
const newEndpoints: any[] = [];
for (const ep of serverEndpoints.rows) {
const clientUpdatedAt = clientEndpointMap.get(ep.id);
if (!clientUpdatedAt) {
newEndpoints.push({ id: ep.id, name: ep.name });
} else if (new Date(String(ep.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
changedEndpoints.push({ id: ep.id, name: ep.name, server_updated_at: ep.updated_at });
}
}
const serverEndpointIds = new Set(serverEndpoints.rows.map((e: any) => e.id));
const deletedEndpoints = (clientEndpoints || [])
.filter((e: any) => !serverEndpointIds.has(e.id))
.map((e: any) => ({ id: e.id }));
// Find changed/new/deleted folders
const changedFolders: any[] = [];
const newFolders: any[] = [];
for (const f of serverFolders.rows) {
const clientUpdatedAt = clientFolderMap.get(f.id);
if (!clientUpdatedAt) {
newFolders.push({ id: f.id, name: f.name });
} else if (new Date(String(f.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
changedFolders.push({ id: f.id, name: f.name, server_updated_at: f.updated_at });
}
}
const serverFolderIds = new Set(serverFolders.rows.map((f: any) => f.id));
const deletedFolders = (clientFolders || [])
.filter((f: any) => !serverFolderIds.has(f.id))
.map((f: any) => ({ id: f.id }));
res.json({
endpoints: {
changed: changedEndpoints,
new: newEndpoints,
deleted: deletedEndpoints,
},
folders: {
changed: changedFolders,
new: newFolders,
deleted: deletedFolders,
},
});
} catch (error) {
console.error('Sync status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* POST /api/sync/push
* Accepts array of endpoints to create/update with conflict detection
*/
export const syncPush = async (req: AuthRequest, res: Response) => {
try {
const { endpoints: pushEndpoints, force } = req.body;
if (!pushEndpoints || !Array.isArray(pushEndpoints)) {
return res.status(400).json({ error: 'endpoints array is required' });
}
const conflicts: any[] = [];
const results: any[] = [];
for (const ep of pushEndpoints) {
// Check if endpoint exists
if (ep.id) {
const existing = await mainPool.query(
'SELECT id, updated_at, name FROM endpoints WHERE id = $1',
[ep.id]
);
if (existing.rows.length > 0) {
const serverUpdatedAt = new Date(existing.rows[0].updated_at).getTime();
const clientBaseUpdatedAt = ep._base_updated_at
? new Date(ep._base_updated_at).getTime()
: 0;
// Conflict: server was modified after client's last sync
if (!force && clientBaseUpdatedAt && serverUpdatedAt > clientBaseUpdatedAt) {
conflicts.push({
id: ep.id,
name: existing.rows[0].name,
server_updated_at: existing.rows[0].updated_at,
client_base_updated_at: ep._base_updated_at,
});
continue;
}
// Update existing endpoint
const result = await mainPool.query(
`UPDATE endpoints
SET name = $1, description = $2, method = $3, path = $4,
database_id = $5, sql_query = $6, parameters = $7,
folder_id = $8, is_public = $9, enable_logging = $10,
execution_type = $11, script_language = $12, script_code = $13,
script_queries = $14, aql_method = $15, aql_endpoint = $16,
aql_body = $17, aql_query_params = $18, detailed_response = $19,
response_schema = $20, updated_at = CURRENT_TIMESTAMP
WHERE id = $21
RETURNING *`,
[
ep.name, ep.description || '', ep.method, ep.path,
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
ep.folder_id || null, ep.is_public || false, ep.enable_logging || false,
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
ep.aql_endpoint || null, ep.aql_body || null,
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
ep.id,
]
);
results.push({ action: 'updated', endpoint: result.rows[0] });
} else {
// Endpoint with this ID doesn't exist on server — create it
const result = await mainPool.query(
`INSERT INTO endpoints (
id, 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, response_schema
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING *`,
[
ep.id, ep.name, ep.description || '', ep.method, ep.path,
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
ep.folder_id || null, req.user!.id, ep.is_public || false, ep.enable_logging || false,
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
ep.aql_endpoint || null, ep.aql_body || null,
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
]
);
results.push({ action: 'created', endpoint: result.rows[0] });
}
}
}
if (conflicts.length > 0) {
return res.status(409).json({
error: 'Conflicts detected',
conflicts,
applied: results,
});
}
res.json({
success: true,
results,
});
} catch (error: any) {
console.error('Sync push 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,7 @@
-- Add response_schema to endpoints for Swagger documentation
-- Stores an OpenAPI-compatible JSON schema describing the 200 response
ALTER TABLE endpoints
ADD COLUMN IF NOT EXISTS response_schema JSONB DEFAULT NULL;
COMMENT ON COLUMN endpoints.response_schema IS 'Optional OpenAPI JSON schema for the 200 response, displayed in Swagger documentation.';

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,13 @@
import express from 'express';
import { authMiddleware } from '../middleware/auth';
import { syncPull, syncPush, syncStatus } from '../controllers/syncController';
const router = express.Router();
router.use(authMiddleware);
router.get('/pull', syncPull);
router.post('/status', syncStatus);
router.post('/push', syncPush);
export default router;

View File

@@ -20,6 +20,7 @@ import userRoutes from './routes/users';
import logsRoutes from './routes/logs'; import logsRoutes from './routes/logs';
import sqlInterfaceRoutes from './routes/sqlInterface'; import sqlInterfaceRoutes from './routes/sqlInterface';
import dynamicRoutes from './routes/dynamic'; import dynamicRoutes from './routes/dynamic';
import syncRoutes from './routes/sync';
const app: Express = express(); const app: Express = express();
@@ -34,8 +35,8 @@ app.set('trust proxy', true);
// crossOriginEmbedderPolicy: false, // crossOriginEmbedderPolicy: false,
// })); // }));
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Rate limiting - DISABLED // Rate limiting - DISABLED
// const limiter = rateLimit({ // const limiter = rateLimit({
@@ -93,6 +94,7 @@ 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); app.use('/api/workbench', sqlInterfaceRoutes);
app.use('/api/sync', syncRoutes);
// Dynamic API routes (user-created endpoints) // Dynamic API routes (user-created endpoints)
app.use('/api/v1', dynamicRoutes); app.use('/api/v1', dynamicRoutes);

View File

@@ -0,0 +1,249 @@
import * as vm from 'vm';
import { sqlExecutor } from './SqlExecutor';
import { aqlExecutor } from './AqlExecutor';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } 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();
// Preserve captured logs and queries in the error
logs.push({ type: 'error', message: error.message, timestamp: Date.now() });
throw new ScriptExecutionError(`JavaScript execution error: ${error.message}`, logs, queries);
}
}
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, ScriptExecutionError } 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,20 +243,41 @@ 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}`)); logs.push({ type: 'error', message: errorOutput, timestamp: Date.now() });
reject(new ScriptExecutionError(`Python execution error: ${errorOutput}`, logs, queries));
} 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 ScriptExecutionError(`Failed to parse Python output: ${output}`, logs, queries));
} }
} }
}); });
@@ -325,7 +285,7 @@ print(json.dumps(result))
// Таймаут 10 минут // Таймаут 10 минут
setTimeout(() => { setTimeout(() => {
python.kill(); python.kill();
reject(new Error('Python script execution timeout (10min)')); reject(new ScriptExecutionError('Python script execution timeout (10min)', logs, queries));
}, 600000); }, 600000);
}); });
} }
@@ -337,7 +297,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

@@ -71,6 +71,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 schema for Swagger docs
response_schema?: object | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
@@ -81,6 +83,7 @@ export interface EndpointParameter {
required: boolean; required: boolean;
default_value?: any; default_value?: any;
description?: string; description?: string;
example_value?: string;
in: 'query' | 'body' | 'path'; in: 'query' | 'body' | 'path';
} }
@@ -101,6 +104,39 @@ 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 class ScriptExecutionError extends Error {
logs: LogEntry[];
queries: QueryExecution[];
constructor(message: string, logs: LogEntry[], queries: QueryExecution[]) {
super(message);
this.name = 'ScriptExecutionError';
this.logs = logs;
this.queries = queries;
}
}
export interface SwaggerEndpoint { export interface SwaggerEndpoint {
tags: string[]; tags: string[];
summary: string; summary: string;
@@ -109,3 +145,40 @@ 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;
response_schema: object | null;
folder_name: string | null;
}

1
cli Submodule

Submodule cli added at 5d6e7bbe56

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,11 +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.0.0", "@xyflow/react": "^12.10.0",
"@dagrejs/dagre": "^1.1.4",
"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

@@ -9,6 +9,7 @@ import Sidebar from '@/components/Sidebar';
import Login from '@/pages/Login'; import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard'; import Dashboard from '@/pages/Dashboard';
import Endpoints from '@/pages/Endpoints'; import Endpoints from '@/pages/Endpoints';
import EndpointEditor from '@/pages/EndpointEditor';
import ApiKeys from '@/pages/ApiKeys'; 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';
@@ -97,6 +98,26 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/endpoints/new"
element={
<PrivateRoute>
<Layout>
<EndpointEditor />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/endpoints/:id"
element={
<PrivateRoute>
<Layout>
<EndpointEditor />
</Layout>
</PrivateRoute>
}
/>
<Route <Route
path="/endpoints" path="/endpoints"
element={ element={

View File

@@ -1,970 +0,0 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { endpointsApi, foldersApi } from '@/services/api';
import { Endpoint, EndpointParameter } from '@/types';
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp } from 'lucide-react';
import toast from 'react-hot-toast';
import SqlEditor from '@/components/SqlEditor';
import CodeEditor from '@/components/CodeEditor';
interface EndpointModalProps {
endpoint: Endpoint | null;
databases: any[];
folderId?: string | null;
onClose: () => void;
}
export default function EndpointModal({
endpoint,
databases,
folderId,
onClose,
}: EndpointModalProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: endpoint?.name || '',
description: endpoint?.description || '',
method: endpoint?.method || 'GET',
path: endpoint?.path || '',
database_id: endpoint?.database_id || '',
sql_query: endpoint?.sql_query || '',
parameters: endpoint?.parameters || [],
folder_id: endpoint?.folder_id || folderId || '',
is_public: endpoint?.is_public || false,
enable_logging: endpoint?.enable_logging || false,
execution_type: endpoint?.execution_type || 'sql',
script_language: endpoint?.script_language || 'javascript',
script_code: endpoint?.script_code || '',
script_queries: endpoint?.script_queries || [],
// AQL-specific fields
aql_method: endpoint?.aql_method || 'GET',
aql_endpoint: endpoint?.aql_endpoint || '',
aql_body: endpoint?.aql_body || '',
aql_query_params: endpoint?.aql_query_params || {},
// Response format
detailed_response: endpoint?.detailed_response || false,
});
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
const [parametersExpanded, setParametersExpanded] = useState(true);
const [queriesExpanded, setQueriesExpanded] = useState(true);
// Определяем тип выбранной базы данных
const selectedDatabase = databases.find(db => db.id === formData.database_id);
const isAqlDatabase = selectedDatabase?.type === 'aql';
const saveMutation = useMutation({
mutationFn: (data: any) =>
endpoint ? endpointsApi.update(endpoint.id, data) : endpointsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
toast.success(endpoint ? 'Эндпоинт обновлен' : 'Эндпоинт создан');
onClose();
},
onError: () => toast.error('Не удалось сохранить эндпоинт'),
});
const [testParams, setTestParams] = useState<any>({});
const testMutation = useMutation({
mutationFn: () => {
// Собираем тестовые значения параметров в массив в правильном порядке
const paramValues = formData.parameters.map((param: any) => {
const value = testParams[param.name];
if (value === undefined || value === '') return null;
// Преобразуем тип если нужно
switch (param.type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'true' || value === true;
default:
return value;
}
});
if (formData.execution_type === 'aql') {
return endpointsApi.test({
database_id: formData.database_id || '',
execution_type: 'aql',
aql_method: formData.aql_method || 'GET',
aql_endpoint: formData.aql_endpoint || '',
aql_body: formData.aql_body || '',
aql_query_params: typeof formData.aql_query_params === 'string' ? {} : formData.aql_query_params || {},
parameters: paramValues,
endpoint_parameters: formData.parameters,
} as any);
} else if (formData.execution_type === 'script') {
// Для скриптов используем database_id из первого запроса или пустую строку
const scriptQueries = formData.script_queries || [];
const firstDbId = scriptQueries.length > 0 ? scriptQueries[0].database_id : '';
return endpointsApi.test({
database_id: firstDbId || '',
execution_type: 'script',
script_language: formData.script_language || 'javascript',
script_code: formData.script_code || '',
script_queries: scriptQueries,
parameters: paramValues,
endpoint_parameters: formData.parameters,
});
} else {
return endpointsApi.test({
database_id: formData.database_id || '',
execution_type: 'sql',
sql_query: formData.sql_query || '',
parameters: paramValues,
endpoint_parameters: formData.parameters,
});
}
},
onSuccess: (response) => {
toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Ошибка тестирования запроса');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(formData);
};
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-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">
{endpoint ? 'Редактировать эндпоинт' : 'Создать новый эндпоинт'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Метод</label>
<select
value={formData.method}
onChange={(e) => setFormData({ ...formData, method: e.target.value as any })}
className="input w-full"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input w-full"
rows={2}
/>
</div>
{!isAqlDatabase && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Тип выполнения</label>
<select
value={formData.execution_type}
onChange={(e) => setFormData({ ...formData, execution_type: e.target.value as 'sql' | 'script' })}
className="input w-full"
>
<option value="sql">QL Запрос</option>
<option value="script">Скрипт (JavaScript/Python) + QL запросы</option>
</select>
</div>
)}
<div className={`grid ${(!isAqlDatabase && formData.execution_type === 'sql') || isAqlDatabase ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Путь</label>
<input
type="text"
required
value={formData.path}
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
className="input w-full"
placeholder="/api/v1/users"
/>
</div>
{(formData.execution_type === 'sql' || isAqlDatabase) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
<select
required
value={formData.database_id}
onChange={(e) => setFormData({ ...formData, database_id: e.target.value, execution_type: databases.find(db => db.id === e.target.value)?.type === 'aql' ? 'aql' : 'sql' })}
className="input w-full"
>
<option value="">Выберите базу данных</option>
{databases.map((db) => (
<option key={db.id} value={db.id}>{db.name} ({db.type})</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Папка</label>
<FolderSelector
value={formData.folder_id}
onChange={(value) => setFormData({ ...formData, folder_id: value })}
/>
</div>
</div>
<div className="border border-gray-200 rounded-lg">
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100 rounded-t-lg"
onClick={() => setParametersExpanded(!parametersExpanded)}
>
<div className="flex items-center gap-2">
{parametersExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
<label className="text-sm font-medium text-gray-700 cursor-pointer">
Параметры запроса
{formData.parameters.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-primary-100 text-primary-700 rounded-full text-xs">
{formData.parameters.length}
</span>
)}
</label>
<span className="text-xs text-gray-500">
(используйте $имяПараметра в QL запросе)
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newParam: EndpointParameter = {
name: '',
type: 'string' as const,
required: false,
in: 'query' as const,
description: '',
};
setFormData({ ...formData, parameters: [...formData.parameters, newParam] });
setParametersExpanded(true);
}}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus size={16} />
Добавить
</button>
</div>
{parametersExpanded && (
formData.parameters.length > 0 ? (
<div className="space-y-3 p-4">
{formData.parameters.map((param: any, index: number) => (
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
<div className="flex-1 grid grid-cols-5 gap-2">
<input
type="text"
placeholder="Имя параметра"
value={param.name}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].name = e.target.value;
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
/>
<select
value={param.type}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].type = e.target.value as 'string' | 'number' | 'boolean' | 'date';
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="date">date</option>
</select>
<select
value={param.in}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].in = e.target.value as 'query' | 'body' | 'path';
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
>
<option value="query">Query</option>
<option value="body">Body</option>
<option value="path">Path</option>
</select>
<input
type="text"
placeholder="Описание"
value={param.description || ''}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].description = e.target.value;
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
/>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={param.required}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].required = e.target.checked;
setFormData({ ...formData, parameters: newParams });
}}
className="rounded"
/>
<span className="text-xs">Обязательный</span>
</label>
</div>
<button
type="button"
onClick={() => {
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
setFormData({ ...formData, parameters: newParams });
}}
className="p-1 hover:bg-red-50 rounded text-red-600"
title="Удалить параметр"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
) : (
<div className="text-center py-4 bg-white rounded-b-lg">
<p className="text-sm text-gray-500">Нет параметров. Добавьте параметры для динамического запроса.</p>
</div>
)
)}
</div>
{formData.execution_type === 'aql' ? (
<>
{/* AQL Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AQL HTTP Метод</label>
<select
value={formData.aql_method}
onChange={(e) => setFormData({ ...formData, aql_method: e.target.value as 'GET' | 'POST' | 'PUT' | 'DELETE' })}
className="input w-full"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AQL Endpoint URL</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> для подстановки</div>
<div>Пример: <code className="bg-blue-100 px-1 rounded">/view/$viewId/GetFullCuidIsLink</code></div>
</div>
<input
type="text"
required
value={formData.aql_endpoint}
onChange={(e) => setFormData({ ...formData, aql_endpoint: e.target.value })}
className="input w-full"
placeholder="/view/15151180-f7f9-4ecc-a48c-25c083511907/GetFullCuidIsLink"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Body (JSON)</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> в JSON для подстановки</div>
</div>
<CodeEditor
value={formData.aql_body}
onChange={(value) => setFormData({ ...formData, aql_body: value })}
language="json"
height="150px"
/>
<p className="text-xs text-gray-500 mt-1">
Пример: {`{"aql": "select c from COMPOSITION c where c/uid/value='$compositionId'"}`}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Query Parameters (JSON)</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div>Формат: <code className="bg-blue-100 px-1 rounded">{`{"key": "value", "CompositionLink": "$linkValue"}`}</code></div>
</div>
<CodeEditor
value={typeof formData.aql_query_params === 'string' ? formData.aql_query_params : JSON.stringify(formData.aql_query_params, null, 2)}
onChange={(value) => {
try {
const parsed = JSON.parse(value);
setFormData({ ...formData, aql_query_params: parsed });
} catch {
// Сохраняем невалидный JSON как строку для последующего редактирования
setFormData({ ...formData, aql_query_params: value as any });
}
}}
language="json"
height="120px"
/>
<p className="text-xs text-gray-500 mt-1">
Пример: {`{"CompositionLink": "ehr:compositions/$compositionId"}`}
</p>
</div>
</>
) : formData.execution_type === 'sql' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 space-y-1">
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для подстановки значений параметров в SQL запрос.</div>
<div>Пример: <code className="bg-blue-100 px-1 rounded">SELECT * FROM users WHERE id = $userId AND status = $status</code></div>
<div className="text-xs text-blue-600 mt-1">
💡 <strong>Query</strong> параметры передаются в URL, <strong>Body</strong> - в теле запроса (для POST/PUT/DELETE можно использовать оба типа)
</div>
</div>
<SqlEditor
value={formData.sql_query}
onChange={(value) => setFormData({ ...formData, sql_query: value })}
databaseId={formData.database_id}
height="300px"
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Язык скрипта</label>
<select
value={formData.script_language}
onChange={(e) => setFormData({ ...formData, script_language: e.target.value as 'javascript' | 'python' })}
className="input w-full"
>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
</select>
</div>
<div className="border border-gray-200 rounded-lg">
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100 rounded-t-lg"
onClick={() => setQueriesExpanded(!queriesExpanded)}
>
<div className="flex items-center gap-2">
{queriesExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
<label className="text-sm font-medium text-gray-700 cursor-pointer">
SQL Запросы для скрипта
{formData.script_queries.length > 0 && (
<span className="ml-2 px-2 py-0.5 bg-primary-100 text-primary-700 rounded-full text-xs">
{formData.script_queries.length}
</span>
)}
</label>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newQueries = [...formData.script_queries, {
name: '',
sql: '',
database_id: '',
aql_method: 'GET' as 'GET' | 'POST' | 'PUT' | 'DELETE',
aql_endpoint: '',
aql_body: '',
aql_query_params: {}
}];
setFormData({ ...formData, script_queries: newQueries });
setEditingQueryIndex(newQueries.length - 1);
setQueriesExpanded(true);
}}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus size={16} />
Добавить
</button>
</div>
{queriesExpanded && (
formData.script_queries.length > 0 ? (
<div className="space-y-2 p-4">
{formData.script_queries.map((query: any, idx: number) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<code className="text-sm font-semibold text-gray-900">{query.name || 'Безымянный запрос'}</code>
{query.database_id && (
<>
<span className="text-xs text-gray-500">
{databases.find(db => db.id === query.database_id)?.name || 'БД не выбрана'}
</span>
{databases.find(db => db.id === query.database_id)?.type === 'aql' && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">AQL</span>
)}
</>
)}
</div>
{query.sql && (
<div className="text-xs text-gray-600 font-mono bg-gray-50 p-2 rounded mt-1 truncate">
{query.sql.substring(0, 100)}{query.sql.length > 100 ? '...' : ''}
</div>
)}
{query.aql_endpoint && (
<div className="text-xs text-gray-600 font-mono bg-purple-50 p-2 rounded mt-1">
<span className="text-purple-700 font-semibold">{query.aql_method}</span> {query.aql_endpoint}
</div>
)}
</div>
<div className="flex gap-2 ml-4">
<button
type="button"
onClick={() => setEditingQueryIndex(idx)}
className="p-2 hover:bg-blue-50 rounded text-blue-600"
title="Редактировать запрос"
>
<Edit2 size={16} />
</button>
<button
type="button"
onClick={() => {
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
setFormData({ ...formData, script_queries: newQueries });
}}
className="p-2 hover:bg-red-50 rounded text-red-600"
title="Удалить запрос"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 bg-white rounded-b-lg">
<p className="text-sm text-gray-500">Нет SQL запросов. Добавьте запросы для использования в скрипте.</p>
</div>
)
)}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Код скрипта</label>
<button
type="button"
onClick={() => setShowScriptCodeEditor(true)}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Edit2 size={16} />
Редактировать код
</button>
</div>
<div className="border border-gray-200 rounded-lg p-4 bg-white">
{formData.script_code ? (
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap max-h-32 overflow-y-auto">
{formData.script_code}
</pre>
) : (
<p className="text-sm text-gray-500 italic">Код скрипта не задан. Нажмите "Редактировать код"</p>
)}
</div>
</div>
</>
)}
<div className="flex flex-wrap items-center gap-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Публичный эндпоинт (не требует API ключ)</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.enable_logging}
onChange={(e) => setFormData({ ...formData, enable_logging: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Логгировать запросы</span>
</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>
{formData.parameters.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Тестовые значения параметров
</label>
<div className="grid grid-cols-2 gap-3 border border-gray-200 rounded-lg p-4 bg-gray-50">
{formData.parameters.map((param: any, index: number) => (
<div key={index}>
<label className="block text-xs font-medium text-gray-700 mb-1">
${param.name} ({param.type})
{param.required && <span className="text-red-500">*</span>}
</label>
{param.type === 'boolean' ? (
<select
value={testParams[param.name] || ''}
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
className="input w-full text-sm"
>
<option value="">Не задано</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'datetime-local' : 'text'}
placeholder={param.description || `Введите ${param.name}`}
value={testParams[param.name] || ''}
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
className="input w-full text-sm"
/>
)}
</div>
))}
</div>
</div>
)}
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={() => testMutation.mutate()}
disabled={
testMutation.isPending ||
(formData.execution_type === 'aql'
? (!formData.database_id || !formData.aql_endpoint)
: formData.execution_type === 'sql'
? (!formData.database_id || !formData.sql_query)
: !formData.script_code
)
}
className="btn btn-secondary flex items-center gap-2"
>
<Play size={18} />
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
</button>
<div className="flex-1"></div>
<button type="button" onClick={onClose} className="btn btn-secondary">
Отмена
</button>
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'}
</button>
</div>
</form>
</div>
</div>
{/* Query Editor Modal */}
{editingQueryIndex !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h3 className="text-xl font-bold text-gray-900">Редактировать SQL запрос</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Имя запроса</label>
<input
type="text"
placeholder="mainQuery"
value={formData.script_queries[editingQueryIndex]?.name || ''}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].name = e.target.value;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
<select
required
value={formData.script_queries[editingQueryIndex]?.database_id || ''}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].database_id = e.target.value;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
>
<option value="">Выберите базу данных</option>
{databases.map((db) => (
<option key={db.id} value={db.id}>{db.name}</option>
))}
</select>
</div>
{/* Определяем тип выбранной базы данных */}
{(() => {
const selectedDb = databases.find(db => db.id === formData.script_queries[editingQueryIndex]?.database_id);
const isAql = selectedDb?.type === 'aql';
return isAql ? (
<>
{/* AQL Fields */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AQL HTTP Метод</label>
<select
value={formData.script_queries[editingQueryIndex]?.aql_method || 'GET'}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].aql_method = e.target.value as any;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AQL Endpoint URL</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> для подстановки</div>
</div>
<input
type="text"
required
value={formData.script_queries[editingQueryIndex]?.aql_endpoint || ''}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].aql_endpoint = e.target.value;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
placeholder="/query"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Body (JSON)</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> в JSON для подстановки</div>
</div>
<CodeEditor
value={formData.script_queries[editingQueryIndex]?.aql_body || ''}
onChange={(value) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].aql_body = value;
setFormData({ ...formData, script_queries: newQueries });
}}
language="json"
height="200px"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Query Parameters (JSON)</label>
<CodeEditor
value={typeof formData.script_queries[editingQueryIndex]?.aql_query_params === 'string'
? formData.script_queries[editingQueryIndex]?.aql_query_params
: JSON.stringify(formData.script_queries[editingQueryIndex]?.aql_query_params || {}, null, 2)}
onChange={(value) => {
const newQueries = [...formData.script_queries];
try {
const parsed = JSON.parse(value);
newQueries[editingQueryIndex].aql_query_params = parsed;
} catch {
newQueries[editingQueryIndex].aql_query_params = value as any;
}
setFormData({ ...formData, script_queries: newQueries });
}}
language="json"
height="150px"
/>
</div>
</>
) : (
<>
{/* SQL Field */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для параметров из запроса или дополнительных параметров из скрипта.</div>
</div>
<SqlEditor
value={formData.script_queries[editingQueryIndex]?.sql || ''}
onChange={(value) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].sql = value;
setFormData({ ...formData, script_queries: newQueries });
}}
databaseId={formData.script_queries[editingQueryIndex]?.database_id || ''}
height="400px"
/>
</div>
</>
);
})()}
</div>
<div className="p-6 border-t border-gray-200 flex gap-3">
<button
type="button"
onClick={() => setEditingQueryIndex(null)}
className="btn btn-primary"
>
Сохранить
</button>
<button
type="button"
onClick={() => setEditingQueryIndex(null)}
className="btn btn-secondary"
>
Закрыть
</button>
</div>
</div>
</div>
)}
{/* Script Code Editor Modal */}
{showScriptCodeEditor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h3 className="text-xl font-bold text-gray-900">Редактировать код скрипта</h3>
</div>
<div className="p-6 space-y-4">
<div className="mb-3 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-700 space-y-2">
<div><strong>Доступные функции:</strong></div>
{formData.script_language === 'javascript' ? (
<>
<div> <code className="bg-blue-100 px-1 rounded">params</code> - объект с параметрами из запроса (query/body)</div>
<div> <code className="bg-blue-100 px-1 rounded">await execQuery('queryName', {'{'}additional: 'params'{'}'})</code> - выполнить SQL запрос</div>
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">const result = await execQuery('mainQuery', {'{'}diff: 123{'}'}); return result.data;</code></div>
</>
) : (
<>
<div> <code className="bg-blue-100 px-1 rounded">params</code> - словарь с параметрами из запроса (query/body)</div>
<div> <code className="bg-blue-100 px-1 rounded">exec_query('queryName', {'{'}' additional': 'params'{'}'})</code> - выполнить SQL запрос</div>
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">result = exec_query('mainQuery', {'{'}'diff': 123{'}'}); return result</code></div>
</>
)}
</div>
<CodeEditor
value={formData.script_code}
onChange={(value) => setFormData({ ...formData, script_code: value })}
language={formData.script_language}
height="500px"
/>
</div>
<div className="p-6 border-t border-gray-200 flex gap-3">
<button
type="button"
onClick={() => setShowScriptCodeEditor(false)}
className="btn btn-primary"
>
Сохранить
</button>
<button
type="button"
onClick={() => setShowScriptCodeEditor(false)}
className="btn btn-secondary"
>
Закрыть
</button>
</div>
</div>
</div>
)}
</>
);
}
// Компонент для выбора папки с иерархией
function FolderSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const { data: folders } = useQuery({
queryKey: ['folders'],
queryFn: () => foldersApi.getAll().then(res => res.data),
});
// Построение списка с иерархией для отображения
const buildFolderList = (): Array<{ id: string; name: string; level: number }> => {
if (!folders) return [];
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [] }]));
const result: Array<{ id: string; name: string; level: number }> = [];
// Группируем папки по parent_id
folders.forEach(folder => {
if (folder.parent_id && folderMap.has(folder.parent_id)) {
const parent: any = folderMap.get(folder.parent_id)!;
if (!parent.children) parent.children = [];
parent.children.push(folderMap.get(folder.id)!);
}
});
// Рекурсивно добавляем папки в список с учетом уровня вложенности
const addFolderRecursive = (folder: any, level: number) => {
result.push({ id: folder.id, name: folder.name, level });
if (folder.children && folder.children.length > 0) {
folder.children.forEach((child: any) => addFolderRecursive(child, level + 1));
}
};
// Добавляем корневые папки и их детей
folders.forEach(folder => {
if (!folder.parent_id) {
addFolderRecursive(folderMap.get(folder.id)!, 0);
}
});
return result;
};
const folderList = buildFolderList();
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className="input w-full">
<option value="">Без папки</option>
{folderList.map((folder) => (
<option key={folder.id} value={folder.id}>
{'\u00A0'.repeat(folder.level * 4)}{folder.name}
</option>
))}
</select>
);
}

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

@@ -11,6 +11,7 @@ interface SqlEditorProps {
onChange: (value: string) => void; onChange: (value: string) => void;
databaseId?: string; databaseId?: string;
height?: string; height?: string;
tabId?: string;
} }
// Cache for schema with 5-minute expiration // Cache for schema with 5-minute expiration
@@ -138,7 +139,7 @@ function getFkSuggestions(
return suggestions; return suggestions;
} }
export default function SqlEditor({ value, onChange, databaseId, height }: SqlEditorProps) { 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);
@@ -325,7 +326,8 @@ export default function SqlEditor({ value, onChange, databaseId, height }: SqlEd
<div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}> <div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}>
<Editor <Editor
height="100%" height="100%"
defaultLanguage="sql" language="sql"
path={tabId}
value={value} value={value}
onChange={(value) => onChange(value || '')} onChange={(value) => onChange(value || '')}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,21 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { endpointsApi, databasesApi } from '@/services/api'; import { endpointsApi } from '@/services/api';
import { Endpoint } from '@/types'; import { 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 ImportEndpointModal from '@/components/ImportEndpointModal';
import Dialog from '@/components/Dialog'; import Dialog from '@/components/Dialog';
export default function Endpoints() { export default function Endpoints() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null); 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;
@@ -30,11 +34,6 @@ export default function Endpoints() {
queryFn: () => endpointsApi.getAll(search).then(res => res.data), queryFn: () => endpointsApi.getAll(search).then(res => res.data),
}); });
const { data: databases } = useQuery({
queryKey: ['databases'],
queryFn: () => databasesApi.getAll().then(res => res.data),
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => endpointsApi.delete(id), mutationFn: (id: string) => endpointsApi.delete(id),
onSuccess: () => { onSuccess: () => {
@@ -56,14 +55,40 @@ export default function Endpoints() {
}); });
}; };
const handleEdit = (endpoint: Endpoint) => { const handleExport = async (endpointId: string, endpointName: string) => {
setEditingEndpoint(endpoint); try {
setShowModal(true); 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 handleCreate = () => { const handleImportFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingEndpoint(null); const file = e.target.files?.[0];
setShowModal(true); 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 (
@@ -73,11 +98,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>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2"> <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={() => navigate('/endpoints/new')} 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">
@@ -139,7 +180,14 @@ export default function Endpoints() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleEdit(endpoint)} 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
onClick={() => navigate(`/endpoints/${endpoint.id}`)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать" title="Редактировать"
> >
@@ -165,11 +213,15 @@ export default function Endpoints() {
</div> </div>
)} )}
{showModal && ( {showImportModal && importPreview && importFile && (
<EndpointModal <ImportEndpointModal
endpoint={editingEndpoint} preview={importPreview}
databases={databases || []} file={importFile}
onClose={() => setShowModal(false)} onClose={() => {
setShowImportModal(false);
setImportFile(null);
setImportPreview(null);
}}
/> />
)} )}

View File

@@ -1,20 +1,20 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { foldersApi, endpointsApi, databasesApi } from '@/services/api'; import { foldersApi, endpointsApi } from '@/services/api';
import { Folder, Endpoint } from '@/types'; import { Folder, Endpoint } from '@/types';
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown } from 'lucide-react'; import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import EndpointModal from '@/components/EndpointModal';
import Dialog from '@/components/Dialog'; import Dialog from '@/components/Dialog';
export default function Folders() { export default function Folders() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [showFolderModal, setShowFolderModal] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false);
const [showEndpointModal, setShowEndpointModal] = useState(false);
const [editingFolder, setEditingFolder] = useState<Folder | null>(null); const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null); const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
isOpen: boolean; isOpen: boolean;
title: string; title: string;
@@ -38,11 +38,6 @@ export default function Folders() {
queryFn: () => endpointsApi.getAll().then(res => res.data), queryFn: () => endpointsApi.getAll().then(res => res.data),
}); });
const { data: databases } = useQuery({
queryKey: ['databases'],
queryFn: () => databasesApi.getAll().then(res => res.data),
});
const deleteFolderMutation = useMutation({ const deleteFolderMutation = useMutation({
mutationFn: (id: string) => foldersApi.delete(id), mutationFn: (id: string) => foldersApi.delete(id),
onSuccess: () => { onSuccess: () => {
@@ -74,14 +69,11 @@ export default function Folders() {
}; };
const handleCreateEndpoint = (folderId?: string) => { const handleCreateEndpoint = (folderId?: string) => {
setSelectedFolderId(folderId || null); navigate(folderId ? `/endpoints/new?folder=${folderId}` : '/endpoints/new');
setEditingEndpoint(null);
setShowEndpointModal(true);
}; };
const handleEditEndpoint = (endpoint: Endpoint) => { const handleEditEndpoint = (endpoint: Endpoint) => {
setEditingEndpoint(endpoint); navigate(`/endpoints/${endpoint.id}`);
setShowEndpointModal(true);
}; };
const handleDeleteFolder = (id: string) => { const handleDeleteFolder = (id: string) => {
@@ -120,6 +112,16 @@ export default function Folders() {
}); });
}; };
const getAllFolderIds = (nodes: any[]): string[] => {
const ids: string[] = [];
const collect = (ns: any[]) => ns.forEach(n => { ids.push(n.id); if (n.children?.length) collect(n.children); });
collect(nodes);
return ids;
};
const expandAll = () => setExpandedFolders(new Set(getAllFolderIds(tree)));
const collapseAll = () => setExpandedFolders(new Set());
// Построение дерева папок // Построение дерева папок
const buildTree = () => { const buildTree = () => {
if (!folders || !endpoints) return []; if (!folders || !endpoints) return [];
@@ -152,6 +154,33 @@ export default function Folders() {
const tree = buildTree(); const tree = buildTree();
// Поиск
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return null;
const q = searchQuery.toLowerCase();
// Строим карту путей папок
const folderPathMap = new Map<string, string>();
const buildPaths = (nodes: any[], prefix = '') => {
nodes.forEach(n => {
const p = prefix ? `${prefix} / ${n.name}` : n.name;
folderPathMap.set(n.id, p);
if (n.children?.length) buildPaths(n.children, p);
});
};
buildPaths(tree);
const matchedEndpoints = (endpoints || []).filter(e =>
e.name.toLowerCase().includes(q) ||
e.path.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q)
).map(e => ({ ...e, folderPath: e.folder_id ? (folderPathMap.get(e.folder_id) || '') : '' }));
const matchedFolders = (folders || []).filter(f => f.name.toLowerCase().includes(q));
return { endpoints: matchedEndpoints, folders: matchedFolders, folderPathMap };
}, [searchQuery, endpoints, folders, tree]);
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -178,8 +207,93 @@ export default function Folders() {
</div> </div>
) : ( ) : (
<div className="card p-6"> <div className="card p-6">
{/* Toolbar: search + expand/collapse */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Поиск по имени, пути, методу..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input w-full pl-9 text-sm"
/>
</div>
<button
onClick={expandAll}
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
title="Раскрыть все"
>
<ChevronsUpDown size={16} />
Раскрыть все
</button>
<button
onClick={collapseAll}
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
title="Свернуть все"
>
<ChevronsDownUp size={16} />
Свернуть все
</button>
</div>
<div className="space-y-1"> <div className="space-y-1">
{/* Корневые папки */} {searchResults ? (
/* Результаты поиска */
<>
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p>Ничего не найдено по запросу «{searchQuery}»</p>
</div>
) : (
<>
{searchResults.folders.map((folder: any) => (
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
<span className="text-xs text-gray-400">{searchResults.folderPathMap.get(folder.id)}</span>
<div className="flex gap-1 ml-auto">
<button onClick={() => handleEditFolder(folder)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
<Edit2 size={14} className="text-gray-600" />
</button>
<button onClick={() => handleDeleteFolder(folder.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
<Trash2 size={14} className="text-red-600" />
</button>
</div>
</div>
))}
{searchResults.endpoints.map((endpoint: any) => (
<div key={endpoint.id} className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 border border-transparent">
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-900">{endpoint.name}</span>
{endpoint.folderPath && (
<span className="ml-2 text-xs text-gray-400">{endpoint.folderPath}</span>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>{endpoint.method}</span>
<code className="text-xs text-gray-600 hidden sm:block">{endpoint.path}</code>
<div className="flex gap-1">
<button onClick={() => handleEditEndpoint(endpoint)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
<Edit2 size={14} className="text-gray-600" />
</button>
<button onClick={() => handleDeleteEndpoint(endpoint.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
<Trash2 size={14} className="text-red-600" />
</button>
</div>
</div>
))}
</>
)}
</>
) : (
/* Обычное дерево */
<>
{tree.map(folder => ( {tree.map(folder => (
<TreeNode <TreeNode
key={folder.id} key={folder.id}
@@ -196,7 +310,6 @@ export default function Folders() {
/> />
))} ))}
{/* Корневые эндпоинты (без папки) */}
{rootEndpoints.map(endpoint => ( {rootEndpoints.map(endpoint => (
<EndpointNode <EndpointNode
key={endpoint.id} key={endpoint.id}
@@ -213,6 +326,8 @@ export default function Folders() {
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p> <p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
</div> </div>
)} )}
</>
)}
</div> </div>
</div> </div>
)} )}
@@ -226,15 +341,6 @@ export default function Folders() {
/> />
)} )}
{showEndpointModal && (
<EndpointModal
endpoint={editingEndpoint}
folderId={selectedFolderId}
databases={databases || []}
onClose={() => setShowEndpointModal(false)}
/>
)}
<Dialog <Dialog
isOpen={dialog.isOpen} isOpen={dialog.isOpen}
onClose={() => setDialog({ ...dialog, isOpen: false })} onClose={() => setDialog({ ...dialog, isOpen: false })}

View File

@@ -326,6 +326,7 @@ export default function SqlInterface() {
value={activeTab?.query || ''} value={activeTab?.query || ''}
onChange={(value) => updateTab(activeTab.id, { query: value })} onChange={(value) => updateTab(activeTab.id, { query: value })}
databaseId={activeTab?.databaseId} databaseId={activeTab?.databaseId}
tabId={activeTab?.id}
/> />
</div> </div>

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

View File

@@ -41,6 +41,7 @@ export interface EndpointParameter {
required: boolean; required: boolean;
default_value?: any; default_value?: any;
description?: string; description?: string;
example_value?: string;
in: 'query' | 'body' | 'path'; in: 'query' | 'body' | 'path';
} }
@@ -80,6 +81,7 @@ export interface Endpoint {
aql_query_params?: Record<string, string>; aql_query_params?: Record<string, string>;
// Response format // Response format
detailed_response?: boolean; detailed_response?: boolean;
response_schema?: object | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -95,10 +97,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;
} }