Compare commits
51 Commits
9a08396610
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c918f34595 | |||
| c047319afe | |||
| 46396c6fc4 | |||
| dcdd6fee6d | |||
| 4ae281209f | |||
| f0cbe99cb0 | |||
| 727c6765f8 | |||
| b6b7064a41 | |||
| 49f262d8ae | |||
| 3a3c87164d | |||
| e8aab43a13 | |||
| 659ebf71ea | |||
| 75d4c0b45d | |||
| 6766cd81a1 | |||
| 7e2e0103fe | |||
| 610bfb24a0 | |||
| fd08e2c3e6 | |||
| fba8069b13 | |||
| a306477d5d | |||
| 4e1044a808 | |||
| 47e998fc52 | |||
| 49938815fe | |||
| 553202c1b2 | |||
| 981b958c41 | |||
| 9293199d28 | |||
| e9e8081882 | |||
| 27c5eceaf1 | |||
| c438c4fc83 | |||
| 39b1b0ed5e | |||
| 5d3515f791 | |||
| c5b4799dcb | |||
| 4fb92470ce | |||
| c780979b57 | |||
| d8dffb5ee1 | |||
| 21b4d8e22b | |||
| 89b5a86bda | |||
| a873e18d35 | |||
| 971e6d3758 | |||
| 45f039546b | |||
| dae878089d | |||
| 6b507425aa | |||
| eeebcdac57 | |||
| 767307857e | |||
| 35f81a1663 | |||
| c879d9e98c | |||
| a8536d7916 | |||
| 58a319b66c | |||
| a5d726cf1f | |||
| 26fbcd0d78 | |||
| fac6e390ba | |||
| 094d0e510c |
16
Dockerfile
16
Dockerfile
@@ -9,7 +9,7 @@ WORKDIR /app/frontend
|
|||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy frontend source
|
# Copy frontend source
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
@@ -44,15 +44,23 @@ FROM node:20-alpine AS production
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy backend package files and install production deps
|
# Copy backend package files and install production deps
|
||||||
|
WORKDIR /app/backend
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
# Copy built backend
|
# Copy built backend (to /app/backend/dist)
|
||||||
COPY --from=backend-builder /app/backend/dist ./dist
|
COPY --from=backend-builder /app/backend/dist ./dist
|
||||||
|
|
||||||
# Copy built frontend to the location expected by backend
|
# Copy migrations (SQL files needed at runtime)
|
||||||
|
COPY --from=backend-builder /app/backend/src/migrations ./src/migrations
|
||||||
|
|
||||||
|
# Copy built frontend (to /app/frontend/dist)
|
||||||
|
WORKDIR /app
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
# Set working directory to backend
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
# Set environment
|
# Set environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ JWT_EXPIRES_IN=24h
|
|||||||
|
|
||||||
# API Rate Limiting
|
# API Rate Limiting
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { config } from './environment';
|
import { config } from './environment';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
// Main database pool for KIS API Builder metadata
|
// Main database pool for KIS API Builder metadata
|
||||||
export const mainPool = new Pool({
|
export const mainPool = new Pool({
|
||||||
@@ -28,3 +30,26 @@ export const initializeDatabase = async () => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const runMigrations = async () => {
|
||||||
|
console.log('🔄 Running migrations...');
|
||||||
|
try {
|
||||||
|
// In compiled JS, __dirname is /app/backend/dist/config
|
||||||
|
// We need to go up to /app/backend and then into src/migrations
|
||||||
|
const migrationsDir = path.join(__dirname, '../../src/migrations');
|
||||||
|
const files = fs.readdirSync(migrationsDir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
console.log(` 📄 ${file}`);
|
||||||
|
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
|
||||||
|
await mainPool.query(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Migrations completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,15 +137,17 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
|||||||
description: 'Успешный ответ',
|
description: 'Успешный ответ',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: endpoint.response_schema
|
||||||
type: 'object',
|
? endpoint.response_schema
|
||||||
properties: {
|
: {
|
||||||
success: { type: 'boolean' },
|
type: 'object',
|
||||||
data: { type: 'array', items: { type: 'object' } },
|
properties: {
|
||||||
rowCount: { type: 'number' },
|
success: { type: 'boolean' },
|
||||||
executionTime: { type: 'number' },
|
data: { type: 'array', items: { type: 'object' } },
|
||||||
},
|
rowCount: { type: 'number' },
|
||||||
},
|
executionTime: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Response } from 'express';
|
|||||||
import { AuthRequest } from '../middleware/auth';
|
import { AuthRequest } from '../middleware/auth';
|
||||||
import { mainPool } from '../config/database';
|
import { mainPool } from '../config/database';
|
||||||
import { databasePoolManager } from '../services/DatabasePoolManager';
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
|
import { generateSchemaForDatabase } from './schemaController';
|
||||||
|
|
||||||
// Только админы могут управлять базами данных
|
// Только админы могут управлять базами данных
|
||||||
export const getDatabases = async (req: AuthRequest, res: Response) => {
|
export const getDatabases = async (req: AuthRequest, res: Response) => {
|
||||||
@@ -92,6 +93,13 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
|
|||||||
// Добавить пул подключений (только для не-AQL баз)
|
// Добавить пул подключений (только для не-AQL баз)
|
||||||
if (dbType !== 'aql') {
|
if (dbType !== 'aql') {
|
||||||
await databasePoolManager.reloadPool(newDb.id);
|
await databasePoolManager.reloadPool(newDb.id);
|
||||||
|
|
||||||
|
// Generate schema in background for PostgreSQL databases
|
||||||
|
if (dbType === 'postgresql') {
|
||||||
|
generateSchemaForDatabase(newDb.id).catch(err => {
|
||||||
|
console.error('Background schema generation failed:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не возвращаем пароль
|
// Не возвращаем пароль
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -233,12 +235,18 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = {
|
// Build response based on detailed_response flag
|
||||||
success: true,
|
const detailedResponse = endpoint.detailed_response || false;
|
||||||
data: result.rows,
|
const responseData = detailedResponse
|
||||||
rowCount: result.rowCount,
|
? {
|
||||||
executionTime: result.executionTime,
|
success: true,
|
||||||
};
|
data: result.rows,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
executionTime: result.executionTime,
|
||||||
|
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
|
||||||
|
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
|
||||||
|
}
|
||||||
|
: result.rows;
|
||||||
|
|
||||||
// Log if needed
|
// Log if needed
|
||||||
if (shouldLog && endpointId) {
|
if (shouldLog && endpointId) {
|
||||||
@@ -263,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -85,6 +87,8 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint,
|
aql_endpoint,
|
||||||
aql_body,
|
aql_body,
|
||||||
aql_query_params,
|
aql_query_params,
|
||||||
|
detailed_response,
|
||||||
|
response_schema,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !method || !path) {
|
if (!name || !method || !path) {
|
||||||
@@ -119,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
|
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)
|
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,
|
||||||
@@ -143,6 +147,8 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint || null,
|
aql_endpoint || null,
|
||||||
aql_body || null,
|
aql_body || null,
|
||||||
JSON.stringify(aql_query_params || {}),
|
JSON.stringify(aql_query_params || {}),
|
||||||
|
detailed_response || false,
|
||||||
|
response_schema ? JSON.stringify(response_schema) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -178,6 +184,8 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint,
|
aql_endpoint,
|
||||||
aql_body,
|
aql_body,
|
||||||
aql_query_params,
|
aql_query_params,
|
||||||
|
detailed_response,
|
||||||
|
response_schema,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const result = await mainPool.query(
|
const result = await mainPool.query(
|
||||||
@@ -200,8 +208,10 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint = $16,
|
aql_endpoint = $16,
|
||||||
aql_body = $17,
|
aql_body = $17,
|
||||||
aql_query_params = $18,
|
aql_query_params = $18,
|
||||||
|
detailed_response = $19,
|
||||||
|
response_schema = $20,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $19
|
WHERE id = $21
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
name,
|
name,
|
||||||
@@ -222,6 +232,8 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
|||||||
aql_endpoint || null,
|
aql_endpoint || null,
|
||||||
aql_body || null,
|
aql_body || null,
|
||||||
aql_query_params ? JSON.stringify(aql_query_params) : null,
|
aql_query_params ? JSON.stringify(aql_query_params) : null,
|
||||||
|
detailed_response || false,
|
||||||
|
response_schema ? JSON.stringify(response_schema) : null,
|
||||||
id,
|
id,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -309,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) {
|
||||||
@@ -333,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) {
|
||||||
@@ -365,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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
244
backend/src/controllers/schemaController.ts
Normal file
244
backend/src/controllers/schemaController.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mainPool } from '../config/database';
|
||||||
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
default_value: string | null;
|
||||||
|
is_primary: boolean;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForeignKey {
|
||||||
|
column: string;
|
||||||
|
references_table: string;
|
||||||
|
references_column: string;
|
||||||
|
constraint_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
comment: string | null;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
foreign_keys: ForeignKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemaData {
|
||||||
|
tables: TableInfo[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PostgreSQL schema - optimized with bulk queries
|
||||||
|
async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[Schema] Starting schema parse for database ${databaseId}`);
|
||||||
|
|
||||||
|
const pool = databasePoolManager.getPool(databaseId);
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database not found or not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tables with comments
|
||||||
|
console.log(`[Schema] Fetching tables...`);
|
||||||
|
const tablesResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
t.table_schema,
|
||||||
|
t.table_name,
|
||||||
|
pg_catalog.obj_description(c.oid, 'pg_class') as table_comment
|
||||||
|
FROM information_schema.tables t
|
||||||
|
LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name
|
||||||
|
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
|
||||||
|
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
ORDER BY t.table_schema, t.table_name
|
||||||
|
`);
|
||||||
|
console.log(`[Schema] Found ${tablesResult.rows.length} tables in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
// Get ALL columns in one query
|
||||||
|
console.log(`[Schema] Fetching all columns...`);
|
||||||
|
const columnsStartTime = Date.now();
|
||||||
|
const allColumnsResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
c.table_schema,
|
||||||
|
c.table_name,
|
||||||
|
c.column_name,
|
||||||
|
c.data_type,
|
||||||
|
c.is_nullable,
|
||||||
|
c.column_default,
|
||||||
|
c.ordinal_position,
|
||||||
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
|
||||||
|
pg_catalog.col_description(pc.oid, c.ordinal_position) as column_comment
|
||||||
|
FROM information_schema.columns c
|
||||||
|
LEFT JOIN pg_catalog.pg_class pc ON pc.relname = c.table_name
|
||||||
|
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace AND pn.nspname = c.table_schema
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ku.column_name, ku.table_schema, ku.table_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage ku
|
||||||
|
ON tc.constraint_name = ku.constraint_name
|
||||||
|
AND tc.table_schema = ku.table_schema
|
||||||
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
) pk ON c.column_name = pk.column_name
|
||||||
|
AND c.table_schema = pk.table_schema
|
||||||
|
AND c.table_name = pk.table_name
|
||||||
|
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
||||||
|
`);
|
||||||
|
console.log(`[Schema] Found ${allColumnsResult.rows.length} columns in ${Date.now() - columnsStartTime}ms`);
|
||||||
|
|
||||||
|
// Get ALL foreign keys in one query
|
||||||
|
console.log(`[Schema] Fetching all foreign keys...`);
|
||||||
|
const fkStartTime = Date.now();
|
||||||
|
const allFkResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
tc.table_schema,
|
||||||
|
tc.table_name,
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name AS references_table,
|
||||||
|
ccu.column_name AS references_column,
|
||||||
|
tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
`);
|
||||||
|
console.log(`[Schema] Found ${allFkResult.rows.length} foreign keys in ${Date.now() - fkStartTime}ms`);
|
||||||
|
|
||||||
|
// Group columns by table
|
||||||
|
const columnsByTable = new Map<string, any[]>();
|
||||||
|
for (const col of allColumnsResult.rows) {
|
||||||
|
const key = `${col.table_schema}.${col.table_name}`;
|
||||||
|
if (!columnsByTable.has(key)) {
|
||||||
|
columnsByTable.set(key, []);
|
||||||
|
}
|
||||||
|
columnsByTable.get(key)!.push(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group foreign keys by table
|
||||||
|
const fkByTable = new Map<string, any[]>();
|
||||||
|
for (const fk of allFkResult.rows) {
|
||||||
|
const key = `${fk.table_schema}.${fk.table_name}`;
|
||||||
|
if (!fkByTable.has(key)) {
|
||||||
|
fkByTable.set(key, []);
|
||||||
|
}
|
||||||
|
fkByTable.get(key)!.push(fk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tables array
|
||||||
|
console.log(`[Schema] Building schema structure...`);
|
||||||
|
const tables: TableInfo[] = tablesResult.rows.map(table => {
|
||||||
|
const key = `${table.table_schema}.${table.table_name}`;
|
||||||
|
const columns = columnsByTable.get(key) || [];
|
||||||
|
const fks = fkByTable.get(key) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: table.table_name,
|
||||||
|
schema: table.table_schema,
|
||||||
|
comment: table.table_comment,
|
||||||
|
columns: columns.map(col => ({
|
||||||
|
name: col.column_name,
|
||||||
|
type: col.data_type,
|
||||||
|
nullable: col.is_nullable === 'YES',
|
||||||
|
default_value: col.column_default,
|
||||||
|
is_primary: col.is_primary,
|
||||||
|
comment: col.column_comment,
|
||||||
|
})),
|
||||||
|
foreign_keys: fks.map(fk => ({
|
||||||
|
column: fk.column_name,
|
||||||
|
references_table: fk.references_table,
|
||||||
|
references_column: fk.references_column,
|
||||||
|
constraint_name: fk.constraint_name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
console.log(`[Schema] Completed! ${tables.length} tables, ${allColumnsResult.rows.length} columns, ${allFkResult.rows.length} FKs in ${totalTime}ms`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tables,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cached schema or return null
|
||||||
|
async function getCachedSchema(databaseId: string): Promise<SchemaData | null> {
|
||||||
|
const result = await mainPool.query(
|
||||||
|
'SELECT schema_data FROM database_schemas WHERE database_id = $1',
|
||||||
|
[databaseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
return result.rows[0].schema_data as SchemaData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save schema to cache
|
||||||
|
async function saveSchemaToCache(databaseId: string, schemaData: SchemaData): Promise<void> {
|
||||||
|
await mainPool.query(`
|
||||||
|
INSERT INTO database_schemas (database_id, schema_data, updated_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (database_id)
|
||||||
|
DO UPDATE SET schema_data = $2, updated_at = NOW()
|
||||||
|
`, [databaseId, JSON.stringify(schemaData)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/workbench/schema/:databaseId
|
||||||
|
export const getSchema = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { databaseId } = req.params;
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
let schema = await getCachedSchema(databaseId);
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
// Parse and cache if not exists
|
||||||
|
schema = await parsePostgresSchema(databaseId);
|
||||||
|
await saveSchemaToCache(databaseId, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: schema });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting schema:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/workbench/schema/:databaseId/refresh
|
||||||
|
export const refreshSchema = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { databaseId } = req.params;
|
||||||
|
|
||||||
|
// Parse fresh schema
|
||||||
|
const schema = await parsePostgresSchema(databaseId);
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
await saveSchemaToCache(databaseId, schema);
|
||||||
|
|
||||||
|
res.json({ success: true, data: schema });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error refreshing schema:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate schema for a database (called from other controllers)
|
||||||
|
export const generateSchemaForDatabase = async (databaseId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const schema = await parsePostgresSchema(databaseId);
|
||||||
|
await saveSchemaToCache(databaseId, schema);
|
||||||
|
console.log(`Schema generated for database ${databaseId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error generating schema for database ${databaseId}:`, error.message);
|
||||||
|
// Don't throw - schema generation is not critical
|
||||||
|
}
|
||||||
|
};
|
||||||
46
backend/src/controllers/sqlInterfaceController.ts
Normal file
46
backend/src/controllers/sqlInterfaceController.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
|
|
||||||
|
export const executeQuery = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { database_id, query } = req.body;
|
||||||
|
|
||||||
|
if (!database_id) {
|
||||||
|
return res.status(400).json({ error: 'Database ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string' || query.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Query is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = databasePoolManager.getPool(database_id);
|
||||||
|
if (!pool) {
|
||||||
|
return res.status(404).json({ error: 'Database not found or not active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await pool.query(query);
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
fields: result.fields?.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
dataTypeID: f.dataTypeID,
|
||||||
|
})),
|
||||||
|
executionTime,
|
||||||
|
command: result.command,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('SQL execution error:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Query execution failed',
|
||||||
|
position: error.position,
|
||||||
|
detail: error.detail,
|
||||||
|
hint: error.hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
296
backend/src/controllers/syncController.ts
Normal file
296
backend/src/controllers/syncController.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
-- Добавление поддержки скриптинга для эндпоинтов
|
-- Добавление поддержки скриптинга для эндпоинтов
|
||||||
|
|
||||||
-- Добавляем новые колонки для скриптинга
|
-- Добавляем новые колонки для скриптинга (каждую отдельно с IF NOT EXISTS)
|
||||||
ALTER TABLE endpoints
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS execution_type VARCHAR(20) DEFAULT 'sql';
|
||||||
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'sql' CHECK (execution_type IN ('sql', 'script')),
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_language VARCHAR(20);
|
||||||
ADD COLUMN script_language VARCHAR(20) CHECK (script_language IN ('javascript', 'python')),
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_code TEXT;
|
||||||
ADD COLUMN script_code TEXT,
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS script_queries JSONB DEFAULT '[]'::jsonb;
|
||||||
ADD COLUMN script_queries JSONB DEFAULT '[]'::jsonb;
|
|
||||||
|
|
||||||
-- Комментарии для документации
|
-- Комментарии для документации
|
||||||
COMMENT ON COLUMN endpoints.execution_type IS 'Тип выполнения: sql - простой SQL запрос, script - скрипт с несколькими запросами';
|
COMMENT ON COLUMN endpoints.execution_type IS 'Тип выполнения: sql - простой SQL запрос, script - скрипт с несколькими запросами';
|
||||||
|
|||||||
@@ -1,40 +1,34 @@
|
|||||||
-- Add AQL support to databases table
|
-- Add AQL support to databases table
|
||||||
ALTER TABLE databases
|
ALTER TABLE databases ALTER COLUMN type TYPE VARCHAR(50);
|
||||||
ALTER COLUMN type TYPE VARCHAR(50);
|
|
||||||
|
|
||||||
-- Update the type check constraint to include 'aql'
|
-- Update the type check constraint to include 'aql'
|
||||||
ALTER TABLE databases
|
ALTER TABLE databases DROP CONSTRAINT IF EXISTS databases_type_check;
|
||||||
DROP CONSTRAINT IF EXISTS databases_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE databases
|
ALTER TABLE databases
|
||||||
ADD CONSTRAINT databases_type_check
|
ADD CONSTRAINT databases_type_check
|
||||||
CHECK (type IN ('postgresql', 'mysql', 'mssql', 'aql'));
|
CHECK (type IN ('postgresql', 'mysql', 'mssql', 'aql'));
|
||||||
|
|
||||||
-- Add AQL-specific columns to databases table
|
-- Add AQL-specific columns to databases table (each separately)
|
||||||
ALTER TABLE databases
|
ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_base_url TEXT;
|
||||||
ADD COLUMN IF NOT EXISTS aql_base_url TEXT,
|
ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_auth_type VARCHAR(50);
|
||||||
ADD COLUMN IF NOT EXISTS aql_auth_type VARCHAR(50) CHECK (aql_auth_type IN ('basic', 'bearer', 'custom')),
|
ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_auth_value TEXT;
|
||||||
ADD COLUMN IF NOT EXISTS aql_auth_value TEXT,
|
ALTER TABLE databases ADD COLUMN IF NOT EXISTS aql_headers JSONB DEFAULT '{}'::jsonb;
|
||||||
ADD COLUMN IF NOT EXISTS aql_headers JSONB DEFAULT '{}'::jsonb;
|
|
||||||
|
|
||||||
-- Add AQL support to endpoints table
|
-- Add AQL support to endpoints table
|
||||||
ALTER TABLE endpoints
|
ALTER TABLE endpoints ALTER COLUMN execution_type TYPE VARCHAR(50);
|
||||||
ALTER COLUMN execution_type TYPE VARCHAR(50);
|
|
||||||
|
|
||||||
-- Update execution_type check constraint to include 'aql'
|
-- Update execution_type check constraint to include 'aql'
|
||||||
ALTER TABLE endpoints
|
ALTER TABLE endpoints DROP CONSTRAINT IF EXISTS endpoints_execution_type_check;
|
||||||
DROP CONSTRAINT IF EXISTS endpoints_execution_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE endpoints
|
ALTER TABLE endpoints
|
||||||
ADD CONSTRAINT endpoints_execution_type_check
|
ADD CONSTRAINT endpoints_execution_type_check
|
||||||
CHECK (execution_type IN ('sql', 'script', 'aql'));
|
CHECK (execution_type IN ('sql', 'script', 'aql'));
|
||||||
|
|
||||||
-- Add AQL-specific columns to endpoints table
|
-- Add AQL-specific columns to endpoints table (each separately)
|
||||||
ALTER TABLE endpoints
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_method VARCHAR(10);
|
||||||
ADD COLUMN IF NOT EXISTS aql_method VARCHAR(10) CHECK (aql_method IN ('GET', 'POST', 'PUT', 'DELETE')),
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_endpoint TEXT;
|
||||||
ADD COLUMN IF NOT EXISTS aql_endpoint TEXT,
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_body TEXT;
|
||||||
ADD COLUMN IF NOT EXISTS aql_body TEXT,
|
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS aql_query_params JSONB DEFAULT '{}'::jsonb;
|
||||||
ADD COLUMN IF NOT EXISTS aql_query_params JSONB DEFAULT '{}'::jsonb;
|
|
||||||
|
|
||||||
-- Create index for AQL endpoints
|
-- Create index for AQL endpoints
|
||||||
CREATE INDEX IF NOT EXISTS idx_endpoints_execution_type ON endpoints(execution_type);
|
CREATE INDEX IF NOT EXISTS idx_endpoints_execution_type ON endpoints(execution_type);
|
||||||
|
|||||||
6
backend/src/migrations/006_seed_admin.sql
Normal file
6
backend/src/migrations/006_seed_admin.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Create default superadmin user (admin/admin)
|
||||||
|
-- Password should be changed after first login!
|
||||||
|
|
||||||
|
INSERT INTO users (username, password_hash, role, is_superadmin)
|
||||||
|
VALUES ('admin', '$2b$10$cYj5vFnPIqWDngjGf9guG.b.L2itqRbvcTPqxHPZIWtUHfT353L7W', 'admin', true)
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
8
backend/src/migrations/007_add_detailed_response.sql
Normal file
8
backend/src/migrations/007_add_detailed_response.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add detailed_response flag to endpoints
|
||||||
|
-- When false (default): returns only data array
|
||||||
|
-- When true: returns full response with success, data, rowCount, executionTime
|
||||||
|
|
||||||
|
ALTER TABLE endpoints
|
||||||
|
ADD COLUMN IF NOT EXISTS detailed_response BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN endpoints.detailed_response IS 'If true, returns detailed response with rowCount and executionTime. Default returns only data array.';
|
||||||
12
backend/src/migrations/008_add_database_schemas.sql
Normal file
12
backend/src/migrations/008_add_database_schemas.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Database schemas cache table
|
||||||
|
CREATE TABLE IF NOT EXISTS database_schemas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
database_id UUID NOT NULL REFERENCES databases(id) ON DELETE CASCADE,
|
||||||
|
schema_data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(database_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_database_schemas_database_id ON database_schemas(database_id);
|
||||||
7
backend/src/migrations/009_add_response_schema.sql
Normal file
7
backend/src/migrations/009_add_response_schema.sql
Normal 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.';
|
||||||
@@ -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:
|
||||||
* required: true
|
* - in: path
|
||||||
* content:
|
* name: id
|
||||||
* application/json:
|
* required: true
|
||||||
* 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;
|
||||||
|
|||||||
16
backend/src/routes/sqlInterface.ts
Normal file
16
backend/src/routes/sqlInterface.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import { executeQuery } from '../controllers/sqlInterfaceController';
|
||||||
|
import { getSchema, refreshSchema } from '../controllers/schemaController';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.post('/execute', executeQuery);
|
||||||
|
|
||||||
|
// Schema routes
|
||||||
|
router.get('/schema/:databaseId', getSchema);
|
||||||
|
router.post('/schema/:databaseId/refresh', refreshSchema);
|
||||||
|
|
||||||
|
export default router;
|
||||||
13
backend/src/routes/sync.ts
Normal file
13
backend/src/routes/sync.ts
Normal 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;
|
||||||
@@ -4,9 +4,8 @@ import helmet from 'helmet';
|
|||||||
// import rateLimit from 'express-rate-limit';
|
// import rateLimit from 'express-rate-limit';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
||||||
import { config } from './config/environment';
|
import { config } from './config/environment';
|
||||||
import { initializeDatabase } from './config/database';
|
import { initializeDatabase, runMigrations } from './config/database';
|
||||||
import { generateDynamicSwagger } from './config/dynamicSwagger';
|
import { generateDynamicSwagger } from './config/dynamicSwagger';
|
||||||
import { databasePoolManager } from './services/DatabasePoolManager';
|
import { databasePoolManager } from './services/DatabasePoolManager';
|
||||||
|
|
||||||
@@ -19,7 +18,9 @@ import databaseRoutes from './routes/databases';
|
|||||||
import databaseManagementRoutes from './routes/databaseManagement';
|
import databaseManagementRoutes from './routes/databaseManagement';
|
||||||
import userRoutes from './routes/users';
|
import userRoutes from './routes/users';
|
||||||
import logsRoutes from './routes/logs';
|
import logsRoutes from './routes/logs';
|
||||||
|
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||||
import dynamicRoutes from './routes/dynamic';
|
import dynamicRoutes from './routes/dynamic';
|
||||||
|
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({
|
||||||
@@ -66,6 +67,10 @@ app.get('/api-docs', async (_req, res, next) => {
|
|||||||
const html = swaggerUi.generateHTML(spec, {
|
const html = swaggerUi.generateHTML(spec, {
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
customCss: '.swagger-ui .topbar { display: none }',
|
||||||
customSiteTitle: 'KIS API Builder - Документация',
|
customSiteTitle: 'KIS API Builder - Документация',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true, // Keep API key after page refresh
|
||||||
|
displayRequestDuration: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,6 +93,8 @@ app.use('/api/databases', databaseRoutes);
|
|||||||
app.use('/api/db-management', databaseManagementRoutes);
|
app.use('/api/db-management', databaseManagementRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/logs', logsRoutes);
|
app.use('/api/logs', logsRoutes);
|
||||||
|
app.use('/api/workbench', sqlInterfaceRoutes);
|
||||||
|
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);
|
||||||
@@ -117,19 +124,22 @@ if (config.nodeEnv === 'production') {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Development mode - proxy to Vite dev server for non-API routes
|
// Development mode - proxy to Vite dev server for non-API routes
|
||||||
const viteProxy = createProxyMiddleware({
|
// Dynamic import to avoid requiring http-proxy-middleware in production
|
||||||
target: 'http://localhost:5173',
|
import('http-proxy-middleware').then(({ createProxyMiddleware }) => {
|
||||||
changeOrigin: true,
|
const viteProxy = createProxyMiddleware({
|
||||||
ws: true, // Enable WebSocket proxying for HMR
|
target: 'http://localhost:5173',
|
||||||
});
|
changeOrigin: true,
|
||||||
|
ws: true, // Enable WebSocket proxying for HMR
|
||||||
|
});
|
||||||
|
|
||||||
app.use((req: Request, res: Response, next: any) => {
|
app.use((req: Request, res: Response, next: any) => {
|
||||||
// If it's an API route or swagger, handle it normally
|
// If it's an API route or swagger, handle it normally
|
||||||
if (req.path.startsWith('/api/') || req.path.startsWith('/api-docs') || req.path === '/health') {
|
if (req.path.startsWith('/api/') || req.path.startsWith('/api-docs') || req.path === '/health') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
// Otherwise, proxy to Vite dev server
|
// Otherwise, proxy to Vite dev server
|
||||||
return viteProxy(req, res, next);
|
return viteProxy(req, res, next);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler for API routes only
|
// 404 handler for API routes only
|
||||||
@@ -152,6 +162,7 @@ app.use((err: any, _req: Request, res: Response, _next: any) => {
|
|||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
try {
|
try {
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
await runMigrations();
|
||||||
await databasePoolManager.initialize();
|
await databasePoolManager.initialize();
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
|
|||||||
249
backend/src/services/IsolatedScriptExecutor.ts
Normal file
249
backend/src/services/IsolatedScriptExecutor.ts
Normal 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();
|
||||||
@@ -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') {
|
||||||
|
|||||||
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'));
|
||||||
|
}
|
||||||
@@ -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
1
cli
Submodule
Submodule cli added at 5d6e7bbe56
@@ -13,8 +13,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: kis-api-builder:latest
|
||||||
container_name: kis-api-builder-app
|
container_name: kis-api-builder-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
212
frontend/package-lock.json
generated
212
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^1.1.8",
|
||||||
"@hookform/resolvers": "^3.3.3",
|
"@hookform/resolvers": "^3.3.3",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "^5.14.2",
|
"@tanstack/react-query": "^5.14.2",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ 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';
|
||||||
import Settings from '@/pages/Settings';
|
import Settings from '@/pages/Settings';
|
||||||
|
import SqlInterface from '@/pages/SqlInterface';
|
||||||
|
import DatabaseSchema from '@/pages/DatabaseSchema';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -75,6 +78,46 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/workbench"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout>
|
||||||
|
<SqlInterface />
|
||||||
|
</Layout>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schema"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout>
|
||||||
|
<DatabaseSchema />
|
||||||
|
</Layout>
|
||||||
|
</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={
|
||||||
|
|||||||
@@ -1,958 +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 || {},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink } from 'lucide-react';
|
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database, GitBranch } from 'lucide-react';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Home, label: 'Главная' },
|
{ to: '/', icon: Home, label: 'Главная' },
|
||||||
|
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' },
|
||||||
|
{ to: '/schema', icon: GitBranch, label: 'Схема БД' },
|
||||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||||
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import Editor, { Monaco, loader } from '@monaco-editor/react';
|
import Editor, { Monaco, loader } from '@monaco-editor/react';
|
||||||
import { databasesApi } from '@/services/api';
|
import { schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||||||
import * as monacoEditor from 'monaco-editor';
|
import * as monacoEditor from 'monaco-editor';
|
||||||
|
|
||||||
// Configure loader to use local Monaco
|
// Configure loader to use local Monaco
|
||||||
@@ -11,66 +11,160 @@ interface SqlEditorProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
databaseId?: string;
|
databaseId?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
tabId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for table names with 5-minute expiration
|
// Cache for schema with 5-minute expiration
|
||||||
interface TableCache {
|
interface SchemaCache {
|
||||||
tables: string[];
|
schema: SchemaData;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tablesCache = new Map<string, TableCache>();
|
const schemaCache = new Map<string, SchemaCache>();
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
const getCachedTables = async (databaseId: string): Promise<string[]> => {
|
const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
|
||||||
const cached = tablesCache.get(databaseId);
|
const cached = schemaCache.get(databaseId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Return cached data if it exists and is not expired
|
|
||||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||||
return cached.tables;
|
return cached.schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh data
|
|
||||||
try {
|
try {
|
||||||
const { data } = await databasesApi.getTables(databaseId);
|
const { data } = await schemaApi.getSchema(databaseId);
|
||||||
tablesCache.set(databaseId, {
|
if (data.success && data.data) {
|
||||||
tables: data.tables,
|
schemaCache.set(databaseId, {
|
||||||
timestamp: now,
|
schema: data.data,
|
||||||
});
|
timestamp: now,
|
||||||
return data.tables;
|
});
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch table names:', error);
|
console.error('Failed to fetch schema:', error);
|
||||||
// Return cached data if available, even if expired, as fallback
|
return cached?.schema || null;
|
||||||
return cached?.tables || [];
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global flag to ensure we only register the completion provider once
|
// Global state for completion provider
|
||||||
let completionProviderRegistered = false;
|
let completionProviderRegistered = false;
|
||||||
let currentDatabaseId: string | undefined;
|
let currentSchema: SchemaData | null = null;
|
||||||
|
|
||||||
export default function SqlEditor({ value, onChange, databaseId, height = '400px' }: SqlEditorProps) {
|
// Parse SQL to find table aliases (e.g., FROM users u, JOIN orders o)
|
||||||
|
function parseTableAliases(sql: string): Map<string, string> {
|
||||||
|
const aliases = new Map<string, string>();
|
||||||
|
|
||||||
|
// Match patterns like: FROM table_name alias, FROM table_name AS alias
|
||||||
|
// JOIN table_name alias, JOIN table_name AS alias
|
||||||
|
const patterns = [
|
||||||
|
/(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi,
|
||||||
|
/,\s*(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(sql)) !== null) {
|
||||||
|
const tableName = match[1].toLowerCase();
|
||||||
|
const alias = match[2]?.toLowerCase();
|
||||||
|
if (alias && alias !== 'on' && alias !== 'where' && alias !== 'join' && alias !== 'left' && alias !== 'inner' && alias !== 'right') {
|
||||||
|
aliases.set(alias, tableName);
|
||||||
|
}
|
||||||
|
// Also map table name to itself for direct references
|
||||||
|
aliases.set(tableName, tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tables mentioned in the query
|
||||||
|
function findTablesInQuery(sql: string, schema: SchemaData): TableInfo[] {
|
||||||
|
const tables: TableInfo[] = [];
|
||||||
|
const sqlLower = sql.toLowerCase();
|
||||||
|
|
||||||
|
for (const table of schema.tables) {
|
||||||
|
if (sqlLower.includes(table.name.toLowerCase())) {
|
||||||
|
tables.push(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get FK suggestions for ON clause
|
||||||
|
function getFkSuggestions(
|
||||||
|
tablesInQuery: TableInfo[],
|
||||||
|
schema: SchemaData,
|
||||||
|
monaco: Monaco,
|
||||||
|
range: any,
|
||||||
|
sql: string
|
||||||
|
): any[] {
|
||||||
|
const suggestions: any[] = [];
|
||||||
|
const aliases = parseTableAliases(sql);
|
||||||
|
|
||||||
|
// Create reverse alias map: tableName -> alias (or tableName if no alias)
|
||||||
|
const tableToAlias = new Map<string, string>();
|
||||||
|
for (const [alias, tableName] of aliases) {
|
||||||
|
// Prefer shorter alias over table name
|
||||||
|
const existing = tableToAlias.get(tableName);
|
||||||
|
if (!existing || alias.length < existing.length) {
|
||||||
|
tableToAlias.set(tableName, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tablesInQuery) {
|
||||||
|
for (const fk of table.foreign_keys) {
|
||||||
|
// Find the referenced table
|
||||||
|
const refTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
|
if (refTable && tablesInQuery.some(t => t.name === refTable.name)) {
|
||||||
|
// Use alias if available, otherwise table name
|
||||||
|
const sourceAlias = tableToAlias.get(table.name) || table.name;
|
||||||
|
const targetAlias = tableToAlias.get(refTable.name) || refTable.name;
|
||||||
|
|
||||||
|
const joinCondition = `${sourceAlias}.${fk.column} = ${targetAlias}.${fk.references_column}`;
|
||||||
|
suggestions.push({
|
||||||
|
label: joinCondition,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||||
|
insertText: joinCondition,
|
||||||
|
detail: `FK: ${table.name}.${fk.column} → ${refTable.name}.${fk.references_column}`,
|
||||||
|
documentation: `Foreign key relationship between ${table.name} and ${refTable.name}`,
|
||||||
|
range,
|
||||||
|
sortText: '0' + joinCondition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SqlEditor({ value, onChange, databaseId, height, tabId }: SqlEditorProps) {
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const monacoRef = useRef<Monaco | null>(null);
|
const monacoRef = useRef<Monaco | null>(null);
|
||||||
|
|
||||||
// Update current database ID when it changes
|
// Fetch schema when database changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentDatabaseId = databaseId;
|
if (databaseId) {
|
||||||
|
getCachedSchema(databaseId).then(schema => {
|
||||||
|
currentSchema = schema;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentSchema = null;
|
||||||
|
}
|
||||||
}, [databaseId]);
|
}, [databaseId]);
|
||||||
|
|
||||||
const handleEditorDidMount = (editor: any, monaco: Monaco) => {
|
const handleEditorDidMount = (editor: any, monaco: Monaco) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
monacoRef.current = monaco;
|
monacoRef.current = monaco;
|
||||||
|
|
||||||
// Register completion provider only once
|
|
||||||
if (completionProviderRegistered) {
|
if (completionProviderRegistered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
completionProviderRegistered = true;
|
completionProviderRegistered = true;
|
||||||
|
|
||||||
// Configure SQL language features
|
|
||||||
monaco.languages.registerCompletionItemProvider('sql', {
|
monaco.languages.registerCompletionItemProvider('sql', {
|
||||||
|
triggerCharacters: ['.', ' '],
|
||||||
provideCompletionItems: async (model, position) => {
|
provideCompletionItems: async (model, position) => {
|
||||||
const word = model.getWordUntilPosition(position);
|
const word = model.getWordUntilPosition(position);
|
||||||
const range = {
|
const range = {
|
||||||
@@ -80,42 +174,147 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
|
|||||||
endColumn: word.endColumn,
|
endColumn: word.endColumn,
|
||||||
};
|
};
|
||||||
|
|
||||||
let suggestions: any[] = [
|
const textUntilPosition = model.getValueInRange({
|
||||||
// SQL Keywords
|
startLineNumber: 1,
|
||||||
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range },
|
startColumn: 1,
|
||||||
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range },
|
endLineNumber: position.lineNumber,
|
||||||
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range },
|
endColumn: position.column,
|
||||||
{ label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range },
|
});
|
||||||
{ label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range },
|
|
||||||
{ label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range },
|
const lineContent = model.getLineContent(position.lineNumber);
|
||||||
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range },
|
const textBeforeCursor = lineContent.substring(0, position.column - 1);
|
||||||
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range },
|
|
||||||
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range },
|
let suggestions: any[] = [];
|
||||||
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range },
|
|
||||||
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
|
// Check if we're after a dot (table.column completion)
|
||||||
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
|
const dotMatch = textBeforeCursor.match(/(\w+)\.\s*$/);
|
||||||
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
|
if (dotMatch && currentSchema) {
|
||||||
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
|
const prefix = dotMatch[1].toLowerCase();
|
||||||
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
|
const aliases = parseTableAliases(textUntilPosition);
|
||||||
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
|
const tableName = aliases.get(prefix) || prefix;
|
||||||
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
|
|
||||||
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
|
const table = currentSchema.tables.find(t => t.name.toLowerCase() === tableName);
|
||||||
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
|
if (table) {
|
||||||
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
|
// Return only columns for this table
|
||||||
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
|
return {
|
||||||
|
suggestions: table.columns.map(col => ({
|
||||||
|
label: col.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: col.name,
|
||||||
|
detail: `${col.type}${col.is_primary ? ' (PK)' : ''}${!col.nullable ? ' NOT NULL' : ''}`,
|
||||||
|
documentation: col.comment || undefined,
|
||||||
|
range,
|
||||||
|
sortText: col.is_primary ? '0' + col.name : '1' + col.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're after ON keyword (FK suggestions)
|
||||||
|
// Match: "ON ", "ON ", "on ", after JOIN ... ON
|
||||||
|
const fullText = model.getValue();
|
||||||
|
const onMatch = textBeforeCursor.match(/\bON\s*$/i) || textUntilPosition.match(/\bJOIN\s+\w+\s+\w*\s+ON\s*$/i);
|
||||||
|
if (onMatch && currentSchema) {
|
||||||
|
const tablesInQuery = findTablesInQuery(fullText, currentSchema);
|
||||||
|
const fkSuggestions = getFkSuggestions(tablesInQuery, currentSchema, monaco, range, fullText);
|
||||||
|
// Add FK suggestions to the top, but also include other suggestions
|
||||||
|
if (fkSuggestions.length > 0) {
|
||||||
|
suggestions = [...fkSuggestions, ...suggestions];
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL Keywords
|
||||||
|
const keywords = [
|
||||||
|
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
|
||||||
|
'FULL OUTER JOIN', 'CROSS JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE',
|
||||||
|
'BETWEEN', 'IS NULL', 'IS NOT NULL', 'GROUP BY', 'ORDER BY', 'HAVING',
|
||||||
|
'LIMIT', 'OFFSET', 'UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT',
|
||||||
|
'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
|
||||||
|
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'TRUNCATE',
|
||||||
|
'DISTINCT', 'AS', 'ASC', 'DESC', 'NULLS FIRST', 'NULLS LAST',
|
||||||
|
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'COALESCE', 'NULLIF',
|
||||||
|
'EXISTS', 'NOT EXISTS', 'ANY', 'ALL',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fetch table names from database if databaseId is provided (with caching)
|
suggestions = keywords.map(kw => ({
|
||||||
if (currentDatabaseId) {
|
label: kw,
|
||||||
const tables = await getCachedTables(currentDatabaseId);
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||||
const tableSuggestions = tables.map(table => ({
|
insertText: kw + ' ',
|
||||||
label: table,
|
range,
|
||||||
kind: monaco.languages.CompletionItemKind.Class,
|
sortText: '2' + kw,
|
||||||
insertText: table,
|
}));
|
||||||
detail: 'Table',
|
|
||||||
range,
|
// SQL Functions
|
||||||
}));
|
const functions = [
|
||||||
suggestions = [...suggestions, ...tableSuggestions];
|
{ name: 'COUNT', snippet: 'COUNT($0)' },
|
||||||
|
{ name: 'SUM', snippet: 'SUM($0)' },
|
||||||
|
{ name: 'AVG', snippet: 'AVG($0)' },
|
||||||
|
{ name: 'MAX', snippet: 'MAX($0)' },
|
||||||
|
{ name: 'MIN', snippet: 'MIN($0)' },
|
||||||
|
{ name: 'COALESCE', snippet: 'COALESCE($1, $0)' },
|
||||||
|
{ name: 'NULLIF', snippet: 'NULLIF($1, $0)' },
|
||||||
|
{ name: 'CAST', snippet: 'CAST($1 AS $0)' },
|
||||||
|
{ name: 'CONCAT', snippet: 'CONCAT($0)' },
|
||||||
|
{ name: 'SUBSTRING', snippet: 'SUBSTRING($1 FROM $2 FOR $0)' },
|
||||||
|
{ name: 'TRIM', snippet: 'TRIM($0)' },
|
||||||
|
{ name: 'UPPER', snippet: 'UPPER($0)' },
|
||||||
|
{ name: 'LOWER', snippet: 'LOWER($0)' },
|
||||||
|
{ name: 'LENGTH', snippet: 'LENGTH($0)' },
|
||||||
|
{ name: 'NOW', snippet: 'NOW()' },
|
||||||
|
{ name: 'CURRENT_DATE', snippet: 'CURRENT_DATE' },
|
||||||
|
{ name: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP' },
|
||||||
|
{ name: 'DATE_TRUNC', snippet: "DATE_TRUNC('$1', $0)" },
|
||||||
|
{ name: 'EXTRACT', snippet: 'EXTRACT($1 FROM $0)' },
|
||||||
|
{ name: 'TO_CHAR', snippet: "TO_CHAR($1, '$0')" },
|
||||||
|
{ name: 'TO_DATE', snippet: "TO_DATE($1, '$0')" },
|
||||||
|
{ name: 'ARRAY_AGG', snippet: 'ARRAY_AGG($0)' },
|
||||||
|
{ name: 'STRING_AGG', snippet: "STRING_AGG($1, '$0')" },
|
||||||
|
{ name: 'JSON_AGG', snippet: 'JSON_AGG($0)' },
|
||||||
|
{ name: 'ROW_NUMBER', snippet: 'ROW_NUMBER() OVER ($0)' },
|
||||||
|
{ name: 'RANK', snippet: 'RANK() OVER ($0)' },
|
||||||
|
{ name: 'DENSE_RANK', snippet: 'DENSE_RANK() OVER ($0)' },
|
||||||
|
{ name: 'LAG', snippet: 'LAG($1) OVER ($0)' },
|
||||||
|
{ name: 'LEAD', snippet: 'LEAD($1) OVER ($0)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
suggestions.push(...functions.map(fn => ({
|
||||||
|
label: fn.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Function,
|
||||||
|
insertText: fn.snippet,
|
||||||
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||||
|
detail: 'Function',
|
||||||
|
range,
|
||||||
|
sortText: '3' + fn.name,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Add tables and columns from schema
|
||||||
|
if (currentSchema) {
|
||||||
|
// Tables
|
||||||
|
for (const table of currentSchema.tables) {
|
||||||
|
suggestions.push({
|
||||||
|
label: table.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Class,
|
||||||
|
insertText: table.name,
|
||||||
|
detail: `Table (${table.columns.length} columns)`,
|
||||||
|
documentation: table.comment || undefined,
|
||||||
|
range,
|
||||||
|
sortText: '1' + table.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// All columns (with table prefix for context)
|
||||||
|
for (const col of table.columns) {
|
||||||
|
suggestions.push({
|
||||||
|
label: `${table.name}.${col.name}`,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: `${table.name}.${col.name}`,
|
||||||
|
detail: col.type,
|
||||||
|
documentation: col.comment || undefined,
|
||||||
|
range,
|
||||||
|
sortText: '4' + table.name + col.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { suggestions };
|
return { suggestions };
|
||||||
@@ -124,10 +323,11 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
<div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}>
|
||||||
<Editor
|
<Editor
|
||||||
height={height}
|
height="100%"
|
||||||
defaultLanguage="sql"
|
language="sql"
|
||||||
|
path={tabId}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(value) => onChange(value || '')}
|
onChange={(value) => onChange(value || '')}
|
||||||
onMount={handleEditorDidMount}
|
onMount={handleEditorDidMount}
|
||||||
|
|||||||
692
frontend/src/pages/DatabaseSchema.tsx
Normal file
692
frontend/src/pages/DatabaseSchema.tsx
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
MarkerType,
|
||||||
|
Position,
|
||||||
|
Handle,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import Dagre from '@dagrejs/dagre';
|
||||||
|
import { Database as DatabaseIcon, Loader2, Key, Link, Search, X, Table2, MessageSquare, Columns } from 'lucide-react';
|
||||||
|
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||||||
|
import { Database } from '@/types';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
// Search result type
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'table' | 'column' | 'comment';
|
||||||
|
tableName: string;
|
||||||
|
tableSchema: string;
|
||||||
|
columnName?: string;
|
||||||
|
displayText: string;
|
||||||
|
secondaryText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom node for table with per-column handles
|
||||||
|
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
||||||
|
const fkColumns = new Set(data.foreign_keys.map(fk => fk.column));
|
||||||
|
const pkColumns = new Set(data.columns.filter(c => c.is_primary).map(c => c.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm"
|
||||||
|
title={data.comment || undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon size={14} />
|
||||||
|
<span className="truncate">{data.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{data.columns.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.name}
|
||||||
|
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative"
|
||||||
|
title={col.comment || undefined}
|
||||||
|
>
|
||||||
|
{/* Handles on both sides for dynamic edge routing */}
|
||||||
|
{pkColumns.has(col.name) && (
|
||||||
|
<>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id={`target-left-${col.name}`}
|
||||||
|
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Right}
|
||||||
|
id={`target-right-${col.name}`}
|
||||||
|
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-right-1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fkColumns.has(col.name) && (
|
||||||
|
<>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id={`source-left-${col.name}`}
|
||||||
|
className="!bg-blue-500 !w-2 !h-2 !border-0 !-left-1"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id={`source-right-${col.name}`}
|
||||||
|
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{col.is_primary && <Key size={11} className="text-yellow-500" />}
|
||||||
|
{fkColumns.has(col.name) && (
|
||||||
|
<Link size={11} className="text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`font-medium truncate ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
|
||||||
|
{col.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 truncate flex-1 text-right">{col.type}</span>
|
||||||
|
{!col.nullable && <span className="text-red-400 text-[10px] flex-shrink-0">NN</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data.columns.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-400 italic">No columns</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.foreign_keys.length > 0 && (
|
||||||
|
<div className="bg-blue-50 px-3 py-1.5 text-xs text-blue-600 border-t border-blue-100">
|
||||||
|
{data.foreign_keys.length} FK
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
table: TableNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNodeHeight(table: TableInfo): number {
|
||||||
|
const headerHeight = 44;
|
||||||
|
const columnHeight = 26;
|
||||||
|
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
|
||||||
|
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTableWeights(schema: SchemaData): Map<string, number> {
|
||||||
|
const weights = new Map<string, number>();
|
||||||
|
|
||||||
|
schema.tables.forEach(table => {
|
||||||
|
const key = `${table.schema}.${table.name}`;
|
||||||
|
let weight = table.foreign_keys.length;
|
||||||
|
|
||||||
|
schema.tables.forEach(other => {
|
||||||
|
other.foreign_keys.forEach(fk => {
|
||||||
|
if (fk.references_table === table.name) {
|
||||||
|
weight++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
weights.set(key, weight);
|
||||||
|
});
|
||||||
|
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges with optimal handle selection based on node positions
|
||||||
|
function buildEdgesWithPositions(
|
||||||
|
schema: SchemaData,
|
||||||
|
nodePositions: Map<string, { x: number; y: number; width: number }>
|
||||||
|
): Edge[] {
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
|
schema.tables.forEach((table) => {
|
||||||
|
const sourceId = `${table.schema}.${table.name}`;
|
||||||
|
const sourcePos = nodePositions.get(sourceId);
|
||||||
|
if (!sourcePos) return;
|
||||||
|
|
||||||
|
table.foreign_keys.forEach((fk) => {
|
||||||
|
let targetTable = schema.tables.find(t =>
|
||||||
|
t.name === fk.references_table && t.schema === table.schema
|
||||||
|
);
|
||||||
|
if (!targetTable) {
|
||||||
|
targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTable) {
|
||||||
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
|
const targetPos = nodePositions.get(targetId);
|
||||||
|
if (!targetPos) return;
|
||||||
|
|
||||||
|
// Calculate center positions
|
||||||
|
const sourceCenterX = sourcePos.x + sourcePos.width / 2;
|
||||||
|
const targetCenterX = targetPos.x + targetPos.width / 2;
|
||||||
|
|
||||||
|
// Choose handles based on relative positions
|
||||||
|
const sourceOnRight = sourceCenterX < targetCenterX;
|
||||||
|
const sourceHandle = sourceOnRight
|
||||||
|
? `source-right-${fk.column}`
|
||||||
|
: `source-left-${fk.column}`;
|
||||||
|
const targetHandle = sourceOnRight
|
||||||
|
? `target-left-${fk.references_column}`
|
||||||
|
: `target-right-${fk.references_column}`;
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `${fk.constraint_name}`,
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
sourceHandle,
|
||||||
|
targetHandle,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: '#3b82f6', strokeWidth: 1.5 },
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: '#3b82f6',
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]; nodePositions: Map<string, { x: number; y: number; width: number }> } {
|
||||||
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
const weights = calculateTableWeights(schema);
|
||||||
|
|
||||||
|
const sortedTables = [...schema.tables].sort((a, b) => {
|
||||||
|
const weightA = weights.get(`${a.schema}.${a.name}`) || 0;
|
||||||
|
const weightB = weights.get(`${b.schema}.${b.name}`) || 0;
|
||||||
|
return weightB - weightA;
|
||||||
|
});
|
||||||
|
|
||||||
|
g.setGraph({
|
||||||
|
rankdir: 'TB',
|
||||||
|
nodesep: 40,
|
||||||
|
ranksep: 80,
|
||||||
|
marginx: 30,
|
||||||
|
marginy: 30,
|
||||||
|
ranker: 'network-simplex',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeWidth = 280;
|
||||||
|
|
||||||
|
sortedTables.forEach((table) => {
|
||||||
|
const nodeId = `${table.schema}.${table.name}`;
|
||||||
|
const height = getNodeHeight(table);
|
||||||
|
g.setNode(nodeId, { width: nodeWidth, height });
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.tables.forEach((table) => {
|
||||||
|
const sourceId = `${table.schema}.${table.name}`;
|
||||||
|
table.foreign_keys.forEach((fk) => {
|
||||||
|
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
|
if (targetTable) {
|
||||||
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
|
g.setEdge(sourceId, targetId, { weight: 2 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Dagre.layout(g);
|
||||||
|
|
||||||
|
const nodePositions = new Map<string, { x: number; y: number; width: number }>();
|
||||||
|
|
||||||
|
const nodes: Node[] = schema.tables.map((table) => {
|
||||||
|
const nodeId = `${table.schema}.${table.name}`;
|
||||||
|
const nodeWithPosition = g.node(nodeId);
|
||||||
|
const x = nodeWithPosition.x - nodeWithPosition.width / 2;
|
||||||
|
const y = nodeWithPosition.y - nodeWithPosition.height / 2;
|
||||||
|
|
||||||
|
nodePositions.set(nodeId, { x, y, width: nodeWidth });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nodeId,
|
||||||
|
type: 'table',
|
||||||
|
position: { x, y },
|
||||||
|
data: { ...table } as TableInfo & Record<string, unknown>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = buildEdgesWithPositions(schema, nodePositions);
|
||||||
|
|
||||||
|
return { nodes, edges, nodePositions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabaseSchema() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const [selectedDbId, setSelectedDbId] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [selectedTables, setSelectedTables] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
||||||
|
queryKey: ['databases'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await databasesApi.getAll();
|
||||||
|
return data.filter((db: Database) => db.type !== 'aql');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: schemaResponse, isLoading: isLoadingSchema } = useQuery({
|
||||||
|
queryKey: ['schema', selectedDbId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await schemaApi.getSchema(selectedDbId);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!selectedDbId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: () => schemaApi.refreshSchema(selectedDbId),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.setQueryData(['schema', selectedDbId], response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const schema = schemaResponse?.data;
|
||||||
|
|
||||||
|
// Search across tables, columns, and comments
|
||||||
|
const searchResults = useMemo((): SearchResult[] => {
|
||||||
|
if (!schema || !searchQuery.trim()) return [];
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
schema.tables.forEach(table => {
|
||||||
|
const tableNameLower = table.name.toLowerCase();
|
||||||
|
|
||||||
|
// Search by table name
|
||||||
|
if (tableNameLower.includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'table',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
displayText: table.name,
|
||||||
|
secondaryText: table.schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by table comment
|
||||||
|
if (table.comment && table.comment.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'comment',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
displayText: table.name,
|
||||||
|
secondaryText: table.comment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by column name
|
||||||
|
table.columns.forEach(col => {
|
||||||
|
if (col.name.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'column',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
columnName: col.name,
|
||||||
|
displayText: `${table.name}.${col.name}`,
|
||||||
|
secondaryText: col.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by column comment
|
||||||
|
if (col.comment && col.comment.toLowerCase().includes(query)) {
|
||||||
|
results.push({
|
||||||
|
type: 'comment',
|
||||||
|
tableName: table.name,
|
||||||
|
tableSchema: table.schema,
|
||||||
|
columnName: col.name,
|
||||||
|
displayText: `${table.name}.${col.name}`,
|
||||||
|
secondaryText: col.comment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate relevance score (lower = better match)
|
||||||
|
const getRelevanceScore = (result: SearchResult): number => {
|
||||||
|
const searchTarget = result.columnName?.toLowerCase() || result.tableName.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (searchTarget === query) return 0;
|
||||||
|
// Starts with query
|
||||||
|
if (searchTarget.startsWith(query)) return 1;
|
||||||
|
// Query position in string (earlier = better)
|
||||||
|
const position = searchTarget.indexOf(query);
|
||||||
|
return 2 + position;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort: by type (table -> column -> comment), then by relevance
|
||||||
|
const typeOrder: Record<SearchResult['type'], number> = {
|
||||||
|
'table': 0,
|
||||||
|
'column': 1,
|
||||||
|
'comment': 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.sort((a, b) => {
|
||||||
|
// First by type
|
||||||
|
const typeDiff = typeOrder[a.type] - typeOrder[b.type];
|
||||||
|
if (typeDiff !== 0) return typeDiff;
|
||||||
|
|
||||||
|
// Then by relevance within same type
|
||||||
|
return getRelevanceScore(a) - getRelevanceScore(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.slice(0, 50);
|
||||||
|
}, [schema, searchQuery]);
|
||||||
|
|
||||||
|
// Get related tables (tables connected via FK)
|
||||||
|
const getRelatedTables = useCallback((tableNames: Set<string>, allTables: TableInfo[]): Set<string> => {
|
||||||
|
const related = new Set<string>();
|
||||||
|
tableNames.forEach(name => related.add(name));
|
||||||
|
|
||||||
|
allTables.forEach(table => {
|
||||||
|
if (tableNames.has(table.name)) {
|
||||||
|
table.foreign_keys.forEach(fk => {
|
||||||
|
related.add(fk.references_table);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
table.foreign_keys.forEach(fk => {
|
||||||
|
if (tableNames.has(fk.references_table)) {
|
||||||
|
related.add(table.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return related;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter schema for selected tables + related tables, or show all if nothing selected
|
||||||
|
const filteredSchema = useMemo((): SchemaData | null => {
|
||||||
|
if (!schema) return null;
|
||||||
|
if (selectedTables.size === 0) return schema; // Show all when nothing selected
|
||||||
|
|
||||||
|
const relatedTables = getRelatedTables(selectedTables, schema.tables);
|
||||||
|
const filteredTables = schema.tables.filter(t => relatedTables.has(t.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tables: filteredTables,
|
||||||
|
updated_at: schema.updated_at,
|
||||||
|
};
|
||||||
|
}, [schema, selectedTables, getRelatedTables]);
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
const [nodePositions, setNodePositions] = useState<Map<string, { x: number; y: number; width: number }>>(new Map());
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges, nodePositions: initialPositions } = useMemo(() => {
|
||||||
|
if (!filteredSchema || filteredSchema.tables.length === 0) {
|
||||||
|
return { nodes: [], edges: [], nodePositions: new Map() };
|
||||||
|
}
|
||||||
|
return getLayoutedElements(filteredSchema);
|
||||||
|
}, [filteredSchema]);
|
||||||
|
|
||||||
|
// Update positions when layout changes
|
||||||
|
useEffect(() => {
|
||||||
|
setNodePositions(initialPositions);
|
||||||
|
}, [initialPositions]);
|
||||||
|
|
||||||
|
// Reset and rebuild when filtered schema changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear first to avoid stale edges
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
|
||||||
|
// Then set new layout after a tick
|
||||||
|
if (layoutedNodes.length > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
// Edges will be set by nodePositions effect
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||||
|
|
||||||
|
// Recalculate edges when positions change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filteredSchema || nodePositions.size === 0) return;
|
||||||
|
const newEdges = buildEdgesWithPositions(filteredSchema, nodePositions);
|
||||||
|
setEdges(newEdges);
|
||||||
|
}, [nodePositions, filteredSchema, setEdges]);
|
||||||
|
|
||||||
|
// Recalculate edges when nodes are dragged
|
||||||
|
const handleNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||||
|
if (!filteredSchema) return;
|
||||||
|
|
||||||
|
setNodePositions(prev => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(node.id, { x: node.position.x, y: node.position.y, width: 280 });
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [filteredSchema]);
|
||||||
|
|
||||||
|
const addTable = (tableName: string) => {
|
||||||
|
setSelectedTables(prev => new Set([...prev, tableName]));
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTable = (tableName: string) => {
|
||||||
|
setSelectedTables(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(tableName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedTables(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchIcon = (type: SearchResult['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'table':
|
||||||
|
return <Table2 size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
case 'column':
|
||||||
|
return <Columns size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
case 'comment':
|
||||||
|
return <MessageSquare size={14} className="text-gray-600 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchTypeLabel = (type: SearchResult['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'table': return 'таблица';
|
||||||
|
case 'column': return 'столбец';
|
||||||
|
case 'comment': return 'коммент';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingDatabases) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col -m-8 bg-white overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon size={18} className="text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={selectedDbId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedDbId(e.target.value);
|
||||||
|
setSelectedTables(new Set());
|
||||||
|
setSearchQuery('');
|
||||||
|
// Clear canvas immediately on database change
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
|
||||||
|
>
|
||||||
|
<option value="">Выберите базу данных</option>
|
||||||
|
{databases.map((db: Database) => (
|
||||||
|
<option key={db.id} value={db.id}>
|
||||||
|
{db.name} ({db.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDbId && schema && (
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по таблицам, столбцам, комментариям..."
|
||||||
|
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-80 overflow-auto z-50">
|
||||||
|
{searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${result.tableName}-${result.columnName || ''}-${result.type}-${idx}`}
|
||||||
|
onClick={() => addTable(result.tableName)}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2 border-b border-gray-50 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-gray-100 rounded text-[10px] text-gray-500 flex-shrink-0">
|
||||||
|
{getSearchIcon(result.type)}
|
||||||
|
{getSearchTypeLabel(result.type)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium truncate">{result.displayText}</span>
|
||||||
|
{result.secondaryText && (
|
||||||
|
<span className="text-gray-400 text-xs truncate ml-auto max-w-48">{result.secondaryText}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDbId && user?.is_superadmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => refreshMutation.mutate()}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-md disabled:opacity-50 text-sm"
|
||||||
|
title="Обновить схему из базы данных"
|
||||||
|
>
|
||||||
|
{refreshMutation.isPending ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
|
<path d="M3 3v5h5"/>
|
||||||
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||||
|
<path d="M16 21h5v-5"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schema && (
|
||||||
|
<div className="text-sm text-gray-500 ml-auto">
|
||||||
|
{filteredSchema ? filteredSchema.tables.length : 0} / {schema.tables.length} таблиц
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected tables chips */}
|
||||||
|
{selectedTables.size > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-blue-50 flex-shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-600 font-medium">Фильтр:</span>
|
||||||
|
{[...selectedTables].map(tableName => (
|
||||||
|
<span
|
||||||
|
key={tableName}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-white border border-blue-200 text-blue-700 rounded text-xs shadow-sm"
|
||||||
|
>
|
||||||
|
<Table2 size={12} />
|
||||||
|
{tableName}
|
||||||
|
<button
|
||||||
|
onClick={() => removeTable(tableName)}
|
||||||
|
className="hover:text-blue-900 ml-1"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 ml-2 underline"
|
||||||
|
>
|
||||||
|
Показать все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schema visualization */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{!selectedDbId && (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Выберите базу данных для просмотра схемы
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDbId && isLoadingSchema && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeDragStop={handleNodeDragStop}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.01}
|
||||||
|
maxZoom={2}
|
||||||
|
onlyRenderVisibleElements={true}
|
||||||
|
defaultEdgeOptions={{
|
||||||
|
type: 'smoothstep',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Background color="#e5e7eb" gap={16} />
|
||||||
|
<Controls />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor="#6366f1"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
style={{ background: '#f3f4f6' }}
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
В базе данных нет таблиц
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1405
frontend/src/pages/EndpointEditor.tsx
Normal file
1405
frontend/src/pages/EndpointEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,10 +98,26 @@ 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">
|
||||||
<Plus size={20} />
|
<input
|
||||||
Новый эндпоинт
|
ref={fileInputRef}
|
||||||
</button>
|
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} />
|
||||||
|
Новый эндпоинт
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
@@ -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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 +207,126 @@ 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 ? (
|
||||||
{tree.map(folder => (
|
/* Результаты поиска */
|
||||||
<TreeNode
|
<>
|
||||||
key={folder.id}
|
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
|
||||||
folder={folder}
|
<div className="text-center py-12 text-gray-500">
|
||||||
level={0}
|
<p>Ничего не найдено по запросу «{searchQuery}»</p>
|
||||||
expandedFolders={expandedFolders}
|
</div>
|
||||||
onToggle={toggleFolder}
|
) : (
|
||||||
onEditFolder={handleEditFolder}
|
<>
|
||||||
onDeleteFolder={handleDeleteFolder}
|
{searchResults.folders.map((folder: any) => (
|
||||||
onCreateSubfolder={handleCreateFolder}
|
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
|
||||||
onCreateEndpoint={handleCreateEndpoint}
|
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||||
onEditEndpoint={handleEditEndpoint}
|
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||||
onDeleteEndpoint={handleDeleteEndpoint}
|
<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 => (
|
||||||
|
<TreeNode
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
level={0}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
onToggle={toggleFolder}
|
||||||
|
onEditFolder={handleEditFolder}
|
||||||
|
onDeleteFolder={handleDeleteFolder}
|
||||||
|
onCreateSubfolder={handleCreateFolder}
|
||||||
|
onCreateEndpoint={handleCreateEndpoint}
|
||||||
|
onEditEndpoint={handleEditEndpoint}
|
||||||
|
onDeleteEndpoint={handleDeleteEndpoint}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Корневые эндпоинты (без папки) */}
|
{rootEndpoints.map(endpoint => (
|
||||||
{rootEndpoints.map(endpoint => (
|
<EndpointNode
|
||||||
<EndpointNode
|
key={endpoint.id}
|
||||||
key={endpoint.id}
|
endpoint={endpoint}
|
||||||
endpoint={endpoint}
|
level={0}
|
||||||
level={0}
|
onEdit={handleEditEndpoint}
|
||||||
onEdit={handleEditEndpoint}
|
onDelete={handleDeleteEndpoint}
|
||||||
onDelete={handleDeleteEndpoint}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
|
|
||||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">
|
||||||
<p>Нет папок и эндпоинтов.</p>
|
<p>Нет папок и эндпоинтов.</p>
|
||||||
<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 })}
|
||||||
|
|||||||
482
frontend/src/pages/SqlInterface.tsx
Normal file
482
frontend/src/pages/SqlInterface.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2, GripHorizontal } from 'lucide-react';
|
||||||
|
import { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api';
|
||||||
|
import { Database } from '@/types';
|
||||||
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
|
|
||||||
|
interface SqlTab {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
databaseId: string;
|
||||||
|
result: SqlQueryResult | null;
|
||||||
|
isExecuting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SqlInterfaceState {
|
||||||
|
tabs: SqlTab[];
|
||||||
|
activeTabId: string;
|
||||||
|
splitPosition: number; // percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sql_interface_state';
|
||||||
|
|
||||||
|
const generateTabId = () => `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const createNewTab = (databaseId: string = '', name?: string): SqlTab => ({
|
||||||
|
id: generateTabId(),
|
||||||
|
name: name || 'Новый запрос',
|
||||||
|
query: '',
|
||||||
|
databaseId,
|
||||||
|
result: null,
|
||||||
|
isExecuting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadState = (): SqlInterfaceState | null => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load SQL interface state:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveState = (state: SqlInterfaceState) => {
|
||||||
|
try {
|
||||||
|
const stateToSave = {
|
||||||
|
...state,
|
||||||
|
tabs: state.tabs.map(tab => ({
|
||||||
|
...tab,
|
||||||
|
isExecuting: false,
|
||||||
|
result: tab.result ? {
|
||||||
|
...tab.result,
|
||||||
|
data: tab.result.data?.slice(0, 100),
|
||||||
|
} : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save SQL interface state:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SqlInterface() {
|
||||||
|
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
||||||
|
queryKey: ['databases'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await databasesApi.getAll();
|
||||||
|
return data.filter((db: Database) => db.type !== 'aql');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [state, setState] = useState<SqlInterfaceState>(() => {
|
||||||
|
const saved = loadState();
|
||||||
|
if (saved && saved.tabs.length > 0) {
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
splitPosition: saved.splitPosition || 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const initialTab = createNewTab();
|
||||||
|
return {
|
||||||
|
tabs: [initialTab],
|
||||||
|
activeTabId: initialTab.id,
|
||||||
|
splitPosition: 50,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveState(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (databases.length > 0) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tabs: prev.tabs.map(tab =>
|
||||||
|
tab.databaseId === '' ? { ...tab, databaseId: databases[0].id } : tab
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [databases]);
|
||||||
|
|
||||||
|
const activeTab = state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
|
||||||
|
|
||||||
|
const updateTab = useCallback((tabId: string, updates: Partial<SqlTab>) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tabs: prev.tabs.map(tab =>
|
||||||
|
tab.id === tabId ? { ...tab, ...updates } : tab
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTab = useCallback(() => {
|
||||||
|
const defaultDbId = databases.length > 0 ? databases[0].id : '';
|
||||||
|
const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tabs: [...prev.tabs, newTab],
|
||||||
|
activeTabId: newTab.id,
|
||||||
|
}));
|
||||||
|
}, [databases, state.tabs.length]);
|
||||||
|
|
||||||
|
const closeTab = useCallback((tabId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setState(prev => {
|
||||||
|
if (prev.tabs.length === 1) {
|
||||||
|
const defaultDbId = databases.length > 0 ? databases[0].id : '';
|
||||||
|
const newTab = createNewTab(defaultDbId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tabs: [newTab],
|
||||||
|
activeTabId: newTab.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTabs = prev.tabs.filter(t => t.id !== tabId);
|
||||||
|
const newActiveId = prev.activeTabId === tabId
|
||||||
|
? newTabs[Math.max(0, prev.tabs.findIndex(t => t.id === tabId) - 1)].id
|
||||||
|
: prev.activeTabId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tabs: newTabs,
|
||||||
|
activeTabId: newActiveId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [databases]);
|
||||||
|
|
||||||
|
const setActiveTab = useCallback((tabId: string) => {
|
||||||
|
setState(prev => ({ ...prev, activeTabId: tabId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeQuery = useCallback(async () => {
|
||||||
|
if (!activeTab || !activeTab.databaseId || !activeTab.query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTab(activeTab.id, { isExecuting: true, result: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await sqlInterfaceApi.execute(activeTab.databaseId, activeTab.query);
|
||||||
|
updateTab(activeTab.id, { isExecuting: false, result: data });
|
||||||
|
} catch (error: any) {
|
||||||
|
updateTab(activeTab.id, {
|
||||||
|
isExecuting: false,
|
||||||
|
result: {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error || error.message || 'Query execution failed',
|
||||||
|
detail: error.response?.data?.detail,
|
||||||
|
hint: error.response?.data?.hint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeTab, updateTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeQuery();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [executeQuery]);
|
||||||
|
|
||||||
|
// Drag to resize
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging.current = true;
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDragging.current || !containerRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const percentage = (y / rect.height) * 100;
|
||||||
|
const clamped = Math.min(Math.max(percentage, 20), 80);
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, splitPosition: clamped }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isDragging.current) {
|
||||||
|
isDragging.current = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoadingDatabases) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col -m-8 bg-white overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
|
||||||
|
{/* Tabs bar */}
|
||||||
|
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2 flex-shrink-0 overflow-x-auto">
|
||||||
|
{state.tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200 flex-shrink-0
|
||||||
|
${tab.id === state.activeTabId
|
||||||
|
? 'bg-white text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="max-w-32 truncate">{tab.name}</span>
|
||||||
|
{tab.isExecuting && <Loader2 className="animate-spin" size={14} />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => closeTab(tab.id, e)}
|
||||||
|
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={addTab}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded flex-shrink-0"
|
||||||
|
title="Новая вкладка"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon size={18} className="text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={activeTab?.databaseId || ''}
|
||||||
|
onChange={(e) => updateTab(activeTab.id, { databaseId: e.target.value })}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
|
||||||
|
>
|
||||||
|
<option value="">Выберите базу данных</option>
|
||||||
|
{databases.map((db: Database) => (
|
||||||
|
<option key={db.id} value={db.id}>
|
||||||
|
{db.name} ({db.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={executeQuery}
|
||||||
|
disabled={!activeTab?.databaseId || !activeTab?.query.trim() || activeTab?.isExecuting}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||||
|
>
|
||||||
|
{activeTab?.isExecuting ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Play size={16} />
|
||||||
|
)}
|
||||||
|
Выполнить
|
||||||
|
<span className="text-xs opacity-75 ml-1">(Ctrl+Enter)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeTab?.name || ''}
|
||||||
|
onChange={(e) => updateTab(activeTab.id, { name: e.target.value })}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 w-48"
|
||||||
|
placeholder="Название вкладки"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area with resizable split */}
|
||||||
|
<div ref={containerRef} className="flex-1 flex flex-col min-h-0 relative">
|
||||||
|
{/* SQL Editor */}
|
||||||
|
<div
|
||||||
|
className="min-h-0"
|
||||||
|
style={{ height: `${state.splitPosition}%` }}
|
||||||
|
>
|
||||||
|
<SqlEditor
|
||||||
|
value={activeTab?.query || ''}
|
||||||
|
onChange={(value) => updateTab(activeTab.id, { query: value })}
|
||||||
|
databaseId={activeTab?.databaseId}
|
||||||
|
tabId={activeTab?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
className="h-2 bg-gray-200 hover:bg-primary-300 cursor-row-resize flex items-center justify-center border-y border-gray-300 flex-shrink-0 transition-colors"
|
||||||
|
>
|
||||||
|
<GripHorizontal size={16} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results panel */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col bg-white overflow-hidden"
|
||||||
|
style={{ height: `calc(${100 - state.splitPosition}% - 8px)` }}
|
||||||
|
>
|
||||||
|
{/* Result header */}
|
||||||
|
{activeTab?.result && (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm flex-shrink-0">
|
||||||
|
{activeTab.result.success ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span>Успешно</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-gray-600">
|
||||||
|
<span>{activeTab.result.rowCount ?? 0} строк</span>
|
||||||
|
</div>
|
||||||
|
{activeTab.result.executionTime !== undefined && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>{activeTab.result.executionTime} мс</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab.result.command && (
|
||||||
|
<span className="text-gray-400">{activeTab.result.command}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-red-600">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<span>Ошибка</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{!activeTab?.result && !activeTab?.isExecuting && (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Выполните запрос, чтобы увидеть результаты
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.isExecuting && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.result && !activeTab.result.success && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700 font-medium">{activeTab.result.error}</p>
|
||||||
|
{activeTab.result.detail && (
|
||||||
|
<p className="text-red-600 mt-2 text-sm">{activeTab.result.detail}</p>
|
||||||
|
)}
|
||||||
|
{activeTab.result.hint && (
|
||||||
|
<p className="text-red-500 mt-2 text-sm italic">Подсказка: {activeTab.result.hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.result?.success && activeTab.result.data && (
|
||||||
|
<ResultTable
|
||||||
|
data={activeTab.result.data}
|
||||||
|
fields={activeTab.result.fields || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultTableProps {
|
||||||
|
data: any[];
|
||||||
|
fields: { name: string; dataTypeID: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultTable({ data, fields }: ResultTableProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Запрос выполнен успешно, но не вернул данных
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = fields.length > 0
|
||||||
|
? fields.map(f => f.name)
|
||||||
|
: Object.keys(data[0] || {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto h-full">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 border-r border-b border-gray-200 w-12">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-gray-600 border-r border-b border-gray-200 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td className="px-3 py-1.5 text-gray-400 border-r border-gray-100 text-xs">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-1.5 border-r border-gray-100 max-w-md truncate"
|
||||||
|
title={formatCellValue(row[col])}
|
||||||
|
>
|
||||||
|
{formatCellValue(row[col])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellValue(value: any): string {
|
||||||
|
if (value === null) return 'NULL';
|
||||||
|
if (value === undefined) return '';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult } from '@/types';
|
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse } from '@/types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -109,15 +109,37 @@ export const endpointsApi = {
|
|||||||
|
|
||||||
test: (data: {
|
test: (data: {
|
||||||
database_id: string;
|
database_id: string;
|
||||||
execution_type?: 'sql' | 'script';
|
execution_type?: 'sql' | 'script' | 'aql';
|
||||||
sql_query?: string;
|
sql_query?: string;
|
||||||
parameters?: any[];
|
parameters?: any[];
|
||||||
endpoint_parameters?: any[];
|
endpoint_parameters?: any[];
|
||||||
script_language?: 'javascript' | 'python';
|
script_language?: 'javascript' | 'python';
|
||||||
script_code?: string;
|
script_code?: string;
|
||||||
script_queries?: any[];
|
script_queries?: any[];
|
||||||
|
aql_method?: string;
|
||||||
|
aql_endpoint?: string;
|
||||||
|
aql_body?: string;
|
||||||
|
aql_query_params?: Record<string, string>;
|
||||||
}) =>
|
}) =>
|
||||||
api.post<QueryTestResult>('/endpoints/test', data),
|
api.post<QueryTestResult>('/endpoints/test', data),
|
||||||
|
|
||||||
|
exportEndpoint: (id: string) =>
|
||||||
|
api.get(`/endpoints/${id}/export`, { responseType: 'blob' }),
|
||||||
|
|
||||||
|
importPreview: (file: File) =>
|
||||||
|
file.arrayBuffer().then(buffer =>
|
||||||
|
api.post<ImportPreviewResponse>('/endpoints/import/preview', buffer, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
importConfirm: (data: {
|
||||||
|
file_data: string;
|
||||||
|
database_mapping: Record<string, string>;
|
||||||
|
folder_id?: string | null;
|
||||||
|
override_path?: string;
|
||||||
|
}) =>
|
||||||
|
api.post<Endpoint>('/endpoints/import', data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Folders API
|
// Folders API
|
||||||
@@ -168,4 +190,61 @@ export const databasesApi = {
|
|||||||
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
|
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SQL Interface API
|
||||||
|
export interface SqlQueryResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any[];
|
||||||
|
rowCount?: number;
|
||||||
|
fields?: { name: string; dataTypeID: number }[];
|
||||||
|
executionTime?: number;
|
||||||
|
command?: string;
|
||||||
|
error?: string;
|
||||||
|
position?: number;
|
||||||
|
detail?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sqlInterfaceApi = {
|
||||||
|
execute: (databaseId: string, query: string) =>
|
||||||
|
api.post<SqlQueryResult>('/workbench/execute', { database_id: databaseId, query }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schema API
|
||||||
|
export interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
default_value: string | null;
|
||||||
|
is_primary: boolean;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForeignKey {
|
||||||
|
column: string;
|
||||||
|
references_table: string;
|
||||||
|
references_column: string;
|
||||||
|
constraint_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
comment: string | null;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
foreign_keys: ForeignKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaData {
|
||||||
|
tables: TableInfo[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schemaApi = {
|
||||||
|
getSchema: (databaseId: string) =>
|
||||||
|
api.get<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}`),
|
||||||
|
|
||||||
|
refreshSchema: (databaseId: string) =>
|
||||||
|
api.post<{ success: boolean; data: SchemaData }>(`/workbench/schema/${databaseId}/refresh`),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ export interface Endpoint {
|
|||||||
aql_endpoint?: string;
|
aql_endpoint?: string;
|
||||||
aql_body?: string;
|
aql_body?: string;
|
||||||
aql_query_params?: Record<string, string>;
|
aql_query_params?: Record<string, string>;
|
||||||
|
// Response format
|
||||||
|
detailed_response?: boolean;
|
||||||
|
response_schema?: object | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -93,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user