Merge pull request 'Переработано окно эндпоинта, добавлены элементы дебага, добавлена возможность сохранять и загружать конфигурацию эндпоинта, добавлено …' (#1) from addDebuggingInEndpointsWindow into main
Reviewed-on: #1
This commit is contained in:
@@ -2,6 +2,8 @@ import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ExportedEndpoint, ExportedScriptQuery } from '../types';
|
||||
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||
|
||||
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
@@ -314,6 +316,11 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
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') {
|
||||
if (!script_language || !script_code) {
|
||||
@@ -338,7 +345,9 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: scriptResult,
|
||||
data: scriptResult.result,
|
||||
logs: scriptResult.logs,
|
||||
queries: scriptResult.queries,
|
||||
});
|
||||
} else if (execType === 'aql') {
|
||||
if (!database_id) {
|
||||
@@ -370,6 +379,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
logs: [
|
||||
{ type: 'info', message: `AQL ${aql_method} ${aql_endpoint} executed in ${result.executionTime}ms`, timestamp: Date.now() },
|
||||
],
|
||||
queries: [],
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Invalid execution_type' });
|
||||
@@ -378,6 +391,347 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
detail: error.detail || undefined,
|
||||
hint: error.hint || undefined,
|
||||
logs: [],
|
||||
queries: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const exportEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const endpointResult = await mainPool.query(
|
||||
'SELECT * FROM endpoints WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
if (endpointResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Endpoint not found' });
|
||||
}
|
||||
const endpoint = endpointResult.rows[0];
|
||||
|
||||
// Resolve database_id -> name & type
|
||||
let databaseName: string | null = null;
|
||||
let databaseType: string | null = null;
|
||||
if (endpoint.database_id) {
|
||||
const dbResult = await mainPool.query(
|
||||
'SELECT name, type FROM databases WHERE id = $1',
|
||||
[endpoint.database_id]
|
||||
);
|
||||
if (dbResult.rows.length > 0) {
|
||||
databaseName = dbResult.rows[0].name;
|
||||
databaseType = dbResult.rows[0].type;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve folder_id -> name
|
||||
let folderName: string | null = null;
|
||||
if (endpoint.folder_id) {
|
||||
const folderResult = await mainPool.query(
|
||||
'SELECT name FROM folders WHERE id = $1',
|
||||
[endpoint.folder_id]
|
||||
);
|
||||
if (folderResult.rows.length > 0) {
|
||||
folderName = folderResult.rows[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve database_ids in script_queries
|
||||
const scriptQueries = endpoint.script_queries || [];
|
||||
const exportedScriptQueries: ExportedScriptQuery[] = [];
|
||||
for (const sq of scriptQueries) {
|
||||
let sqDbName: string | undefined;
|
||||
let sqDbType: string | undefined;
|
||||
if (sq.database_id) {
|
||||
const sqDbResult = await mainPool.query(
|
||||
'SELECT name, type FROM databases WHERE id = $1',
|
||||
[sq.database_id]
|
||||
);
|
||||
if (sqDbResult.rows.length > 0) {
|
||||
sqDbName = sqDbResult.rows[0].name;
|
||||
sqDbType = sqDbResult.rows[0].type;
|
||||
}
|
||||
}
|
||||
exportedScriptQueries.push({
|
||||
name: sq.name,
|
||||
sql: sq.sql,
|
||||
database_name: sqDbName,
|
||||
database_type: sqDbType,
|
||||
aql_method: sq.aql_method,
|
||||
aql_endpoint: sq.aql_endpoint,
|
||||
aql_body: sq.aql_body,
|
||||
aql_query_params: sq.aql_query_params,
|
||||
});
|
||||
}
|
||||
|
||||
const exportData: ExportedEndpoint = {
|
||||
_format: 'kabe_v1',
|
||||
name: endpoint.name,
|
||||
description: endpoint.description || '',
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
execution_type: endpoint.execution_type || 'sql',
|
||||
database_name: databaseName,
|
||||
database_type: databaseType,
|
||||
sql_query: endpoint.sql_query || '',
|
||||
parameters: endpoint.parameters || [],
|
||||
script_language: endpoint.script_language || null,
|
||||
script_code: endpoint.script_code || null,
|
||||
script_queries: exportedScriptQueries,
|
||||
aql_method: endpoint.aql_method || null,
|
||||
aql_endpoint: endpoint.aql_endpoint || null,
|
||||
aql_body: endpoint.aql_body || null,
|
||||
aql_query_params: endpoint.aql_query_params || null,
|
||||
is_public: endpoint.is_public || false,
|
||||
enable_logging: endpoint.enable_logging || false,
|
||||
detailed_response: endpoint.detailed_response || false,
|
||||
folder_name: folderName,
|
||||
};
|
||||
|
||||
const encrypted = encryptEndpointData(exportData);
|
||||
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`);
|
||||
res.send(encrypted);
|
||||
} catch (error) {
|
||||
console.error('Export endpoint error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const importPreview = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const buffer = req.body as Buffer;
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
let exportData: ExportedEndpoint;
|
||||
try {
|
||||
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
|
||||
}
|
||||
|
||||
if (exportData._format !== 'kabe_v1') {
|
||||
return res.status(400).json({ error: 'Unsupported file format version' });
|
||||
}
|
||||
|
||||
// Collect all referenced database names
|
||||
const referencedDatabases: { name: string; type: string }[] = [];
|
||||
|
||||
if (exportData.database_name) {
|
||||
referencedDatabases.push({
|
||||
name: exportData.database_name,
|
||||
type: exportData.database_type || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
for (const sq of exportData.script_queries || []) {
|
||||
if (sq.database_name && !referencedDatabases.find(d => d.name === sq.database_name)) {
|
||||
referencedDatabases.push({
|
||||
name: sq.database_name,
|
||||
type: sq.database_type || 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check which databases exist locally
|
||||
const localDatabases = await mainPool.query(
|
||||
'SELECT id, name, type FROM databases WHERE is_active = true'
|
||||
);
|
||||
|
||||
const databaseMapping = referencedDatabases.map(ref => {
|
||||
const found = localDatabases.rows.find(
|
||||
(db: any) => db.name === ref.name && db.type === ref.type
|
||||
);
|
||||
return {
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
found: !!found,
|
||||
local_id: found?.id || null,
|
||||
};
|
||||
});
|
||||
|
||||
// Check folder
|
||||
let folder: { name: string; found: boolean; local_id: string | null } | null = null;
|
||||
if (exportData.folder_name) {
|
||||
const folderResult = await mainPool.query(
|
||||
'SELECT id FROM folders WHERE name = $1',
|
||||
[exportData.folder_name]
|
||||
);
|
||||
folder = {
|
||||
name: exportData.folder_name,
|
||||
found: folderResult.rows.length > 0,
|
||||
local_id: folderResult.rows.length > 0 ? folderResult.rows[0].id : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if path already exists
|
||||
const pathCheck = await mainPool.query(
|
||||
'SELECT id FROM endpoints WHERE path = $1',
|
||||
[exportData.path]
|
||||
);
|
||||
|
||||
res.json({
|
||||
endpoint: {
|
||||
name: exportData.name,
|
||||
description: exportData.description,
|
||||
method: exportData.method,
|
||||
path: exportData.path,
|
||||
execution_type: exportData.execution_type,
|
||||
is_public: exportData.is_public,
|
||||
enable_logging: exportData.enable_logging,
|
||||
detailed_response: exportData.detailed_response,
|
||||
folder_name: exportData.folder_name,
|
||||
},
|
||||
databases: databaseMapping,
|
||||
all_databases_found: databaseMapping.every(d => d.found),
|
||||
local_databases: localDatabases.rows.map((db: any) => ({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
type: db.type,
|
||||
})),
|
||||
folder,
|
||||
path_exists: pathCheck.rows.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Import preview error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const importEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
file_data,
|
||||
database_mapping,
|
||||
folder_id,
|
||||
override_path,
|
||||
} = req.body;
|
||||
|
||||
if (!file_data) {
|
||||
return res.status(400).json({ error: 'No file data provided' });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(file_data, 'base64');
|
||||
let exportData: ExportedEndpoint;
|
||||
try {
|
||||
exportData = decryptEndpointData(buffer) as ExportedEndpoint;
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid or corrupted .kabe file' });
|
||||
}
|
||||
|
||||
// Resolve main database_id
|
||||
let databaseId: string | null = null;
|
||||
if (exportData.database_name) {
|
||||
const mappedId = database_mapping?.[exportData.database_name];
|
||||
if (mappedId) {
|
||||
databaseId = mappedId;
|
||||
} else {
|
||||
const dbResult = await mainPool.query(
|
||||
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
|
||||
[exportData.database_name]
|
||||
);
|
||||
if (dbResult.rows.length > 0) {
|
||||
databaseId = dbResult.rows[0].id;
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: `Database "${exportData.database_name}" not found and no mapping provided`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve script_queries database_ids
|
||||
const resolvedScriptQueries = [];
|
||||
for (const sq of exportData.script_queries || []) {
|
||||
let sqDatabaseId: string | undefined;
|
||||
if (sq.database_name) {
|
||||
const mappedId = database_mapping?.[sq.database_name];
|
||||
if (mappedId) {
|
||||
sqDatabaseId = mappedId;
|
||||
} else {
|
||||
const sqDbResult = await mainPool.query(
|
||||
'SELECT id FROM databases WHERE name = $1 AND is_active = true',
|
||||
[sq.database_name]
|
||||
);
|
||||
if (sqDbResult.rows.length > 0) {
|
||||
sqDatabaseId = sqDbResult.rows[0].id;
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: `Database "${sq.database_name}" (script query "${sq.name}") not found and no mapping provided`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedScriptQueries.push({
|
||||
name: sq.name,
|
||||
sql: sq.sql,
|
||||
database_id: sqDatabaseId,
|
||||
aql_method: sq.aql_method,
|
||||
aql_endpoint: sq.aql_endpoint,
|
||||
aql_body: sq.aql_body,
|
||||
aql_query_params: sq.aql_query_params,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve folder
|
||||
let resolvedFolderId: string | null = folder_id || null;
|
||||
if (!resolvedFolderId && exportData.folder_name) {
|
||||
const folderResult = await mainPool.query(
|
||||
'SELECT id FROM folders WHERE name = $1',
|
||||
[exportData.folder_name]
|
||||
);
|
||||
if (folderResult.rows.length > 0) {
|
||||
resolvedFolderId = folderResult.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const finalPath = override_path || exportData.path;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoints (
|
||||
name, description, method, path, database_id, sql_query, parameters,
|
||||
folder_id, user_id, is_public, enable_logging,
|
||||
execution_type, script_language, script_code, script_queries,
|
||||
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
RETURNING *`,
|
||||
[
|
||||
exportData.name,
|
||||
exportData.description || '',
|
||||
exportData.method,
|
||||
finalPath,
|
||||
databaseId,
|
||||
exportData.sql_query || '',
|
||||
JSON.stringify(exportData.parameters || []),
|
||||
resolvedFolderId,
|
||||
req.user!.id,
|
||||
exportData.is_public || false,
|
||||
exportData.enable_logging || false,
|
||||
exportData.execution_type || 'sql',
|
||||
exportData.script_language || null,
|
||||
exportData.script_code || null,
|
||||
JSON.stringify(resolvedScriptQueries),
|
||||
exportData.aql_method || null,
|
||||
exportData.aql_endpoint || null,
|
||||
exportData.aql_body || null,
|
||||
JSON.stringify(exportData.aql_query_params || {}),
|
||||
exportData.detailed_response || false,
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Import endpoint error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Endpoint path already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
updateEndpoint,
|
||||
deleteEndpoint,
|
||||
testEndpoint,
|
||||
exportEndpoint,
|
||||
importPreview,
|
||||
importEndpoint,
|
||||
} from '../controllers/endpointController';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -36,6 +39,44 @@ router.use(authMiddleware);
|
||||
*/
|
||||
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
|
||||
* /api/endpoints/{id}:
|
||||
@@ -58,23 +99,23 @@ router.get('/:id', getEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints:
|
||||
* post:
|
||||
* /api/endpoints/{id}/export:
|
||||
* get:
|
||||
* tags: [Endpoints]
|
||||
* summary: Create new endpoint
|
||||
* summary: Export endpoint as .kabe file
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Endpoint created
|
||||
* 200:
|
||||
* description: Encrypted .kabe file
|
||||
*/
|
||||
router.post('/', createEndpoint);
|
||||
router.get('/:id/export', exportEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -116,18 +157,4 @@ router.put('/:id', updateEndpoint);
|
||||
*/
|
||||
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;
|
||||
|
||||
247
backend/src/services/IsolatedScriptExecutor.ts
Normal file
247
backend/src/services/IsolatedScriptExecutor.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as vm from 'vm';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
interface IsolatedScriptContext {
|
||||
databaseId: string;
|
||||
scriptQueries: ScriptQuery[];
|
||||
requestParams: Record<string, any>;
|
||||
endpointParameters: EndpointParameter[];
|
||||
}
|
||||
|
||||
export class IsolatedScriptExecutor {
|
||||
private readonly TIMEOUT_MS = 600000; // 10 minutes
|
||||
|
||||
async execute(code: string, context: IsolatedScriptContext): Promise<IsolatedExecutionResult> {
|
||||
const logs: LogEntry[] = [];
|
||||
const queries: QueryExecution[] = [];
|
||||
|
||||
// Build captured console proxy
|
||||
const capturedConsole = {
|
||||
log: (...args: any[]) => {
|
||||
logs.push({ type: 'log', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
logs.push({ type: 'error', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
|
||||
},
|
||||
warn: (...args: any[]) => {
|
||||
logs.push({ type: 'warn', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
|
||||
},
|
||||
info: (...args: any[]) => {
|
||||
logs.push({ type: 'info', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() });
|
||||
},
|
||||
};
|
||||
|
||||
// Build execQuery function with tracking
|
||||
const execQuery = async (queryName: string, additionalParams: Record<string, any> = {}) => {
|
||||
const startTime = Date.now();
|
||||
const query = context.scriptQueries.find(q => q.name === queryName);
|
||||
|
||||
if (!query) {
|
||||
const entry: QueryExecution = {
|
||||
name: queryName,
|
||||
executionTime: Date.now() - startTime,
|
||||
success: false,
|
||||
error: `Query '${queryName}' not found`,
|
||||
};
|
||||
queries.push(entry);
|
||||
throw new Error(`Query '${queryName}' not found`);
|
||||
}
|
||||
|
||||
const allParams = { ...context.requestParams, ...additionalParams };
|
||||
const dbId = (query as any).database_id || context.databaseId;
|
||||
|
||||
if (!dbId) {
|
||||
const errMsg = `Database ID not found for query '${queryName}'. Please specify database_id in the Script Queries configuration.`;
|
||||
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const dbConfig = await databasePoolManager.getDatabaseConfig(dbId);
|
||||
if (!dbConfig) {
|
||||
const errMsg = `Database configuration not found for ID: ${dbId}`;
|
||||
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
if (dbConfig.type === 'aql') {
|
||||
try {
|
||||
const result = await aqlExecutor.executeAqlQuery(dbId, {
|
||||
method: query.aql_method || 'GET',
|
||||
endpoint: query.aql_endpoint || '',
|
||||
body: query.aql_body || '',
|
||||
queryParams: query.aql_query_params || {},
|
||||
parameters: allParams,
|
||||
});
|
||||
|
||||
queries.push({
|
||||
name: queryName,
|
||||
executionTime: Date.now() - startTime,
|
||||
rowCount: result.rowCount,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
queries.push({
|
||||
name: queryName,
|
||||
executionTime: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return { success: false, error: error.message, data: [], rowCount: 0 };
|
||||
}
|
||||
} else {
|
||||
if (!query.sql) {
|
||||
const errMsg = `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`;
|
||||
queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg });
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
try {
|
||||
let processedQuery = query.sql;
|
||||
const paramValues: any[] = [];
|
||||
const paramMatches = query.sql.match(/\$\w+/g) || [];
|
||||
const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))];
|
||||
|
||||
uniqueParams.forEach((paramName, index) => {
|
||||
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
||||
processedQuery = processedQuery.replace(regex, `$${index + 1}`);
|
||||
const value = allParams[paramName];
|
||||
paramValues.push(value !== undefined ? value : null);
|
||||
});
|
||||
|
||||
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
|
||||
|
||||
queries.push({
|
||||
name: queryName,
|
||||
sql: query.sql,
|
||||
executionTime: Date.now() - startTime,
|
||||
rowCount: result.rowCount,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
queries.push({
|
||||
name: queryName,
|
||||
sql: query.sql,
|
||||
executionTime: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return { success: false, error: error.message, data: [], rowCount: 0 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create sandbox with null-prototype base
|
||||
const sandbox = Object.create(null);
|
||||
sandbox.params = context.requestParams;
|
||||
sandbox.console = capturedConsole;
|
||||
sandbox.execQuery = execQuery;
|
||||
|
||||
// Safe globals
|
||||
sandbox.JSON = JSON;
|
||||
sandbox.Date = Date;
|
||||
sandbox.Math = Math;
|
||||
sandbox.parseInt = parseInt;
|
||||
sandbox.parseFloat = parseFloat;
|
||||
sandbox.Array = Array;
|
||||
sandbox.Object = Object;
|
||||
sandbox.String = String;
|
||||
sandbox.Number = Number;
|
||||
sandbox.Boolean = Boolean;
|
||||
sandbox.RegExp = RegExp;
|
||||
sandbox.Map = Map;
|
||||
sandbox.Set = Set;
|
||||
sandbox.Promise = Promise;
|
||||
sandbox.Error = Error;
|
||||
sandbox.TypeError = TypeError;
|
||||
sandbox.RangeError = RangeError;
|
||||
sandbox.SyntaxError = SyntaxError;
|
||||
sandbox.isNaN = isNaN;
|
||||
sandbox.isFinite = isFinite;
|
||||
sandbox.undefined = undefined;
|
||||
sandbox.NaN = NaN;
|
||||
sandbox.Infinity = Infinity;
|
||||
sandbox.encodeURIComponent = encodeURIComponent;
|
||||
sandbox.decodeURIComponent = decodeURIComponent;
|
||||
sandbox.encodeURI = encodeURI;
|
||||
sandbox.decodeURI = decodeURI;
|
||||
|
||||
// Capped setTimeout/clearTimeout
|
||||
const timerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
sandbox.setTimeout = (fn: Function, ms: number, ...args: any[]) => {
|
||||
const cappedMs = Math.min(ms || 0, 30000);
|
||||
const id = setTimeout(() => {
|
||||
timerIds.delete(id);
|
||||
fn(...args);
|
||||
}, cappedMs);
|
||||
timerIds.add(id);
|
||||
return id;
|
||||
};
|
||||
sandbox.clearTimeout = (id: ReturnType<typeof setTimeout>) => {
|
||||
timerIds.delete(id);
|
||||
clearTimeout(id);
|
||||
};
|
||||
|
||||
const vmContext = vm.createContext(sandbox);
|
||||
|
||||
// Wrap user code in async IIFE
|
||||
const wrappedCode = `(async function() { ${code} })()`;
|
||||
|
||||
try {
|
||||
const script = new vm.Script(wrappedCode, { filename: 'user-script.js' });
|
||||
const resultPromise = script.runInContext(vmContext);
|
||||
|
||||
// Race against timeout
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Script execution timeout (10min)')), this.TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const result = await Promise.race([resultPromise, timeoutPromise]);
|
||||
|
||||
// Clean up timers
|
||||
for (const id of timerIds) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
timerIds.clear();
|
||||
|
||||
return { result, logs, queries };
|
||||
} catch (error: any) {
|
||||
// Clean up timers
|
||||
for (const id of timerIds) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
timerIds.clear();
|
||||
|
||||
throw new Error(`JavaScript execution error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private stringify(value: any): string {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isolatedScriptExecutor = new IsolatedScriptExecutor();
|
||||
@@ -1,8 +1,9 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter } from '../types';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
||||
|
||||
interface ScriptContext {
|
||||
databaseId: string;
|
||||
@@ -13,122 +14,19 @@ interface ScriptContext {
|
||||
|
||||
export class ScriptExecutor {
|
||||
/**
|
||||
* Выполняет JavaScript скрипт
|
||||
* Выполняет JavaScript скрипт через изолированный VM контекст
|
||||
*/
|
||||
async executeJavaScript(code: string, context: ScriptContext): Promise<any> {
|
||||
try {
|
||||
// Создаем функцию 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}`);
|
||||
}
|
||||
async executeJavaScript(code: string, context: ScriptContext): Promise<IsolatedExecutionResult> {
|
||||
return isolatedScriptExecutor.execute(code, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет 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) => {
|
||||
// Сериализуем параметры в JSON строку
|
||||
const paramsJson = JSON.stringify(context.requestParams);
|
||||
@@ -179,7 +77,6 @@ print(json.dumps(result))
|
||||
const python = spawn(pythonCommand, ['-c', wrapperCode]);
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
let queryRequests: any[] = [];
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
@@ -192,12 +89,19 @@ print(json.dumps(result))
|
||||
// Проверяем на запросы к БД
|
||||
const requestMatches = text.matchAll(/__QUERY_REQUEST__(.*?)__END_REQUEST__/g);
|
||||
for (const match of requestMatches) {
|
||||
const queryStartTime = Date.now();
|
||||
try {
|
||||
const request = JSON.parse(match[1]);
|
||||
|
||||
// Выполняем запрос
|
||||
const query = context.scriptQueries.find(q => q.name === request.query_name);
|
||||
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');
|
||||
continue;
|
||||
}
|
||||
@@ -206,18 +110,18 @@ print(json.dumps(result))
|
||||
const dbId = (query as any).database_id || context.databaseId;
|
||||
|
||||
if (!dbId) {
|
||||
python.stdin.write(JSON.stringify({
|
||||
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}'.`
|
||||
}) + '\n');
|
||||
const errMsg = `Database ID not found for query '${request.query_name}'.`;
|
||||
queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg });
|
||||
python.stdin.write(JSON.stringify({ error: errMsg }) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Получаем конфигурацию базы данных для определения типа
|
||||
const dbConfig = await databasePoolManager.getDatabaseConfig(dbId);
|
||||
if (!dbConfig) {
|
||||
python.stdin.write(JSON.stringify({
|
||||
error: `Database configuration not found for ID: ${dbId}`
|
||||
}) + '\n');
|
||||
const errMsg = `Database configuration not found for ID: ${dbId}`;
|
||||
queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg });
|
||||
python.stdin.write(JSON.stringify({ error: errMsg }) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -233,6 +137,13 @@ print(json.dumps(result))
|
||||
parameters: allParams,
|
||||
});
|
||||
|
||||
queries.push({
|
||||
name: request.query_name,
|
||||
executionTime: Date.now() - queryStartTime,
|
||||
rowCount: result.rowCount,
|
||||
success: true,
|
||||
});
|
||||
|
||||
python.stdin.write(JSON.stringify({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
@@ -240,7 +151,12 @@ print(json.dumps(result))
|
||||
executionTime: result.executionTime,
|
||||
}) + '\n');
|
||||
} catch (error: any) {
|
||||
// Отправляем ошибку как объект, а не через поле error
|
||||
queries.push({
|
||||
name: request.query_name,
|
||||
executionTime: Date.now() - queryStartTime,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
python.stdin.write(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
@@ -251,9 +167,11 @@ print(json.dumps(result))
|
||||
} else {
|
||||
// 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({
|
||||
success: false,
|
||||
error: `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`,
|
||||
error: errMsg,
|
||||
data: [],
|
||||
rowCount: 0,
|
||||
}) + '\n');
|
||||
@@ -280,6 +198,14 @@ print(json.dumps(result))
|
||||
paramValues
|
||||
);
|
||||
|
||||
queries.push({
|
||||
name: request.query_name,
|
||||
sql: query.sql,
|
||||
executionTime: Date.now() - queryStartTime,
|
||||
rowCount: result.rowCount,
|
||||
success: true,
|
||||
});
|
||||
|
||||
python.stdin.write(JSON.stringify({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
@@ -287,6 +213,13 @@ print(json.dumps(result))
|
||||
executionTime: result.executionTime,
|
||||
}) + '\n');
|
||||
} 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({
|
||||
success: false,
|
||||
error: error.message,
|
||||
@@ -296,6 +229,12 @@ print(json.dumps(result))
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
queries.push({
|
||||
name: 'unknown',
|
||||
executionTime: Date.now() - queryStartTime,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
python.stdin.write(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
@@ -304,18 +243,38 @@ print(json.dumps(result))
|
||||
}) + '\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) => {
|
||||
if (code !== 0) {
|
||||
python.on('close', (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
reject(new Error(`Python execution error: ${errorOutput}`));
|
||||
} else {
|
||||
try {
|
||||
// Последняя строка вывода - результат
|
||||
// Последняя строка вывода - результат, остальные - логи
|
||||
const lines = output.trim().split('\n');
|
||||
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);
|
||||
resolve(result);
|
||||
resolve({ result, logs, queries });
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse Python output: ${output}`));
|
||||
}
|
||||
@@ -337,7 +296,7 @@ print(json.dumps(result))
|
||||
language: 'javascript' | 'python',
|
||||
code: string,
|
||||
context: ScriptContext
|
||||
): Promise<any> {
|
||||
): Promise<IsolatedExecutionResult> {
|
||||
if (language === 'javascript') {
|
||||
return this.executeJavaScript(code, context);
|
||||
} else if (language === 'python') {
|
||||
|
||||
35
backend/src/services/endpointCrypto.ts
Normal file
35
backend/src/services/endpointCrypto.ts
Normal 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'));
|
||||
}
|
||||
@@ -101,6 +101,27 @@ export interface QueryResult {
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
type: 'log' | 'error' | 'warn' | 'info';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface QueryExecution {
|
||||
name: string;
|
||||
sql?: string;
|
||||
executionTime: number;
|
||||
rowCount?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IsolatedExecutionResult {
|
||||
result: any;
|
||||
logs: LogEntry[];
|
||||
queries: QueryExecution[];
|
||||
}
|
||||
|
||||
export interface SwaggerEndpoint {
|
||||
tags: string[];
|
||||
summary: string;
|
||||
@@ -109,3 +130,38 @@ export interface SwaggerEndpoint {
|
||||
responses: any;
|
||||
security?: any[];
|
||||
}
|
||||
|
||||
export interface ExportedScriptQuery {
|
||||
name: string;
|
||||
sql?: string;
|
||||
database_name?: string;
|
||||
database_type?: string;
|
||||
aql_method?: string;
|
||||
aql_endpoint?: string;
|
||||
aql_body?: string;
|
||||
aql_query_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExportedEndpoint {
|
||||
_format: 'kabe_v1';
|
||||
name: string;
|
||||
description: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path: string;
|
||||
execution_type: 'sql' | 'script' | 'aql';
|
||||
database_name: string | null;
|
||||
database_type: string | null;
|
||||
sql_query: string;
|
||||
parameters: EndpointParameter[];
|
||||
script_language: string | null;
|
||||
script_code: string | null;
|
||||
script_queries: ExportedScriptQuery[];
|
||||
aql_method: string | null;
|
||||
aql_endpoint: string | null;
|
||||
aql_body: string | null;
|
||||
aql_query_params: Record<string, string> | null;
|
||||
is_public: boolean;
|
||||
enable_logging: boolean;
|
||||
detailed_response: boolean;
|
||||
folder_name: string | null;
|
||||
}
|
||||
|
||||
212
frontend/package-lock.json
generated
212
frontend/package-lock.json
generated
@@ -8,9 +8,11 @@
|
||||
"name": "kis-api-builder-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.8",
|
||||
"@hookform/resolvers": "^3.3.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
@@ -369,6 +371,24 @@
|
||||
"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": {
|
||||
"version": "0.8.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -2383,6 +2484,12 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2495,6 +2602,111 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"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": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.8",
|
||||
"@hookform/resolvers": "^3.3.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@xyflow/react": "^12.0.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } 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 { Endpoint, EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types';
|
||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, X, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
@@ -49,6 +49,8 @@ export default function EndpointModal({
|
||||
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
|
||||
const [parametersExpanded, setParametersExpanded] = useState(true);
|
||||
const [queriesExpanded, setQueriesExpanded] = useState(true);
|
||||
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
|
||||
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
|
||||
|
||||
// Определяем тип выбранной базы данных
|
||||
const selectedDatabase = databases.find(db => db.id === formData.database_id);
|
||||
@@ -65,7 +67,39 @@ export default function EndpointModal({
|
||||
onError: () => toast.error('Не удалось сохранить эндпоинт'),
|
||||
});
|
||||
|
||||
const [testParams, setTestParams] = useState<any>({});
|
||||
// Restore test params and result from localStorage
|
||||
const storageKey = endpoint?.id ? `test_${endpoint.id}` : null;
|
||||
const [testParams, setTestParams] = useState<any>(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) return JSON.parse(saved).testParams || {};
|
||||
} catch {}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Restore testResult from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.testResult) setTestResult(parsed.testResult);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Save testParams and testResult to localStorage
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
|
||||
} catch {}
|
||||
}
|
||||
}, [storageKey, testParams, testResult]);
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -120,10 +154,20 @@ export default function EndpointModal({
|
||||
}
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`);
|
||||
setTestResult(response.data);
|
||||
setActiveResultTab('data');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Ошибка тестирования запроса');
|
||||
const errorData = error.response?.data;
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: errorData?.error || error.message || 'Ошибка тестирования запроса',
|
||||
detail: errorData?.detail,
|
||||
hint: errorData?.hint,
|
||||
logs: errorData?.logs || [],
|
||||
queries: errorData?.queries || [],
|
||||
});
|
||||
setActiveResultTab('data');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -689,6 +733,202 @@ export default function EndpointModal({
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Test Results Panel */}
|
||||
{testResult && (
|
||||
<div className="border border-gray-200 rounded-lg mt-4">
|
||||
{/* Status bar */}
|
||||
<div className={`flex items-center justify-between px-4 py-2 rounded-t-lg ${testResult.success ? 'bg-green-50 border-b border-green-200' : 'bg-red-50 border-b border-red-200'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<CheckCircle size={16} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-red-600" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{testResult.success ? 'Успешно' : 'Ошибка'}
|
||||
</span>
|
||||
{testResult.executionTime !== undefined && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock size={12} /> {testResult.executionTime}мс
|
||||
</span>
|
||||
)}
|
||||
{testResult.rowCount !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
| {testResult.rowCount} строк
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTestResult(null)}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveResultTab('data')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'data' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Результат
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveResultTab('logs')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'logs' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Логи
|
||||
{testResult.logs && testResult.logs.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-gray-200 text-gray-600 rounded-full text-xs">{testResult.logs.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{formData.execution_type === 'script' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveResultTab('queries')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${activeResultTab === 'queries' ? 'border-primary-500 text-primary-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
Запросы
|
||||
{testResult.queries && testResult.queries.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-gray-200 text-gray-600 rounded-full text-xs">{testResult.queries.length}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{activeResultTab === 'data' && (
|
||||
<div className="p-3">
|
||||
{!testResult.success ? (
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
<div className="font-medium">{testResult.error}</div>
|
||||
{testResult.detail && <div className="mt-1 text-xs">{testResult.detail}</div>}
|
||||
{testResult.hint && <div className="mt-1 text-xs text-red-600">Hint: {testResult.hint}</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : testResult.data !== undefined ? (
|
||||
(() => {
|
||||
// Normalize data to array for table rendering
|
||||
const dataArray = Array.isArray(testResult.data) ? testResult.data : [testResult.data];
|
||||
if (dataArray.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-4">Нет данных</p>;
|
||||
}
|
||||
// Get columns from first row
|
||||
const firstRow = dataArray[0];
|
||||
if (typeof firstRow !== 'object' || firstRow === null) {
|
||||
return (
|
||||
<pre className="text-xs font-mono bg-gray-50 p-3 rounded whitespace-pre-wrap max-h-60 overflow-auto">
|
||||
{JSON.stringify(testResult.data, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
const columns = Object.keys(firstRow);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-2 py-1.5 text-left font-medium text-gray-500 border-b">#</th>
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-2 py-1.5 text-left font-medium text-gray-500 border-b">{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataArray.slice(0, 100).map((row: any, idx: number) => (
|
||||
<tr key={idx} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-2 py-1 text-gray-400 border-b">{idx + 1}</td>
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-2 py-1 border-b font-mono max-w-xs truncate" title={String(row[col] ?? '')}>
|
||||
{row[col] === null ? <span className="text-gray-400 italic">null</span> : typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{dataArray.length > 100 && (
|
||||
<p className="text-xs text-gray-500 text-center py-2">Показано 100 из {dataArray.length} строк</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Нет данных</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeResultTab === 'logs' && (
|
||||
<div className="p-3 space-y-1">
|
||||
{testResult.processedQuery && (
|
||||
<div className="mb-3 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||
<div className="text-xs font-medium text-blue-700 mb-1">Обработанный запрос:</div>
|
||||
<pre className="text-xs font-mono text-blue-800 whitespace-pre-wrap">{testResult.processedQuery}</pre>
|
||||
</div>
|
||||
)}
|
||||
{(!testResult.logs || testResult.logs.length === 0) ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Нет логов</p>
|
||||
) : (
|
||||
testResult.logs.map((log: LogEntry, idx: number) => (
|
||||
<div key={idx} className={`flex items-start gap-2 px-2 py-1 rounded text-xs font-mono ${
|
||||
log.type === 'error' ? 'bg-red-50 text-red-700' :
|
||||
log.type === 'warn' ? 'bg-yellow-50 text-yellow-700' :
|
||||
log.type === 'info' ? 'bg-blue-50 text-blue-700' :
|
||||
'bg-gray-50 text-gray-700'
|
||||
}`}>
|
||||
<span className="text-gray-400 shrink-0">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span className={`shrink-0 uppercase font-semibold ${
|
||||
log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' :
|
||||
log.type === 'info' ? 'text-blue-500' :
|
||||
'text-gray-400'
|
||||
}`}>[{log.type}]</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeResultTab === 'queries' && (
|
||||
<div className="p-3 space-y-2">
|
||||
{(!testResult.queries || testResult.queries.length === 0) ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Нет запросов</p>
|
||||
) : (
|
||||
testResult.queries.map((q: QueryExecution, idx: number) => (
|
||||
<div key={idx} className={`border rounded p-3 ${q.success ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{q.success ? <CheckCircle size={14} className="text-green-600" /> : <XCircle size={14} className="text-red-600" />}
|
||||
<span className="text-sm font-medium text-gray-900">{q.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><Clock size={12} /> {q.executionTime}мс</span>
|
||||
{q.rowCount !== undefined && <span>{q.rowCount} строк</span>}
|
||||
</div>
|
||||
</div>
|
||||
{q.sql && (
|
||||
<pre className="text-xs font-mono text-gray-600 bg-white p-2 rounded mt-1 whitespace-pre-wrap max-h-20 overflow-auto">{q.sql}</pre>
|
||||
)}
|
||||
{q.error && (
|
||||
<div className="text-xs text-red-600 mt-1">{q.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
222
frontend/src/components/ImportEndpointModal.tsx
Normal file
222
frontend/src/components/ImportEndpointModal.tsx
Normal 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">
|
||||
Папка "{preview.folder.name}" не найдена. Эндпоинт будет импортирован в корневую папку.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface SqlEditorProps {
|
||||
onChange: (value: string) => void;
|
||||
databaseId?: string;
|
||||
height?: string;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
// Cache for schema with 5-minute expiration
|
||||
@@ -138,7 +139,7 @@ function getFkSuggestions(
|
||||
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 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%' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
language="sql"
|
||||
path={tabId}
|
||||
value={value}
|
||||
onChange={(value) => onChange(value || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Endpoint } from '@/types';
|
||||
import { Plus, Search, Edit2, Trash2 } from 'lucide-react';
|
||||
import { Endpoint, ImportPreviewResponse } from '@/types';
|
||||
import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import ImportEndpointModal from '@/components/ImportEndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Endpoints() {
|
||||
@@ -12,6 +13,10 @@ export default function Endpoints() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -66,6 +71,42 @@ export default function Endpoints() {
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleExport = async (endpointId: string, endpointName: string) => {
|
||||
try {
|
||||
const response = await endpointsApi.exportEndpoint(endpointId);
|
||||
const blob = new Blob([response.data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${endpointName.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_')}.kabe`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Эндпоинт экспортирован');
|
||||
} catch {
|
||||
toast.error('Ошибка экспорта эндпоинта');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.name.endsWith('.kabe')) {
|
||||
toast.error('Выберите файл с расширением .kabe');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await endpointsApi.importPreview(file);
|
||||
setImportFile(file);
|
||||
setImportPreview(response.data);
|
||||
setShowImportModal(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Ошибка чтения файла');
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -73,10 +114,26 @@ export default function Endpoints() {
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1>
|
||||
<p className="text-gray-600">Управление динамическими API эндпоинтами</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".kabe"
|
||||
className="hidden"
|
||||
onChange={handleImportFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Upload size={20} />
|
||||
Импорт
|
||||
</button>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
@@ -138,6 +195,13 @@ export default function Endpoints() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleExport(endpoint.id, endpoint.name)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Экспорт"
|
||||
>
|
||||
<Download size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(endpoint)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@@ -173,6 +237,18 @@ export default function Endpoints() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && importPreview && importFile && (
|
||||
<ImportEndpointModal
|
||||
preview={importPreview}
|
||||
file={importFile}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setImportFile(null);
|
||||
setImportPreview(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
|
||||
@@ -326,6 +326,7 @@ export default function SqlInterface() {
|
||||
value={activeTab?.query || ''}
|
||||
onChange={(value) => updateTab(activeTab.id, { query: value })}
|
||||
databaseId={activeTab?.databaseId}
|
||||
tabId={activeTab?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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({
|
||||
baseURL: '/api',
|
||||
@@ -109,15 +109,37 @@ export const endpointsApi = {
|
||||
|
||||
test: (data: {
|
||||
database_id: string;
|
||||
execution_type?: 'sql' | 'script';
|
||||
execution_type?: 'sql' | 'script' | 'aql';
|
||||
sql_query?: string;
|
||||
parameters?: any[];
|
||||
endpoint_parameters?: any[];
|
||||
script_language?: 'javascript' | 'python';
|
||||
script_code?: string;
|
||||
script_queries?: any[];
|
||||
aql_method?: string;
|
||||
aql_endpoint?: string;
|
||||
aql_body?: string;
|
||||
aql_query_params?: Record<string, string>;
|
||||
}) =>
|
||||
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
|
||||
|
||||
@@ -95,10 +95,62 @@ export interface ApiKey {
|
||||
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 {
|
||||
success: boolean;
|
||||
data?: any[];
|
||||
data?: any;
|
||||
rowCount?: number;
|
||||
executionTime?: number;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user