Add test/prod environments for databases

- Migration 010: test_* columns on databases table, environment column on request_logs
- DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback
- All executors (SQL, Script, AQL): environment param threaded through execution paths
- dynamicApiController: X-Environment header detection, environment in logging
- databaseManagementController: CRUD for test credentials, testConnection with ?env=test
- Frontend: test env form in DatabaseModal, env toggle in EndpointEditor test panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:04:11 +03:00
parent c918f34595
commit e9032001bd
16 changed files with 571 additions and 216 deletions

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# KIS API Builder
Dynamic API construction system for KIS/PMU/CR_PPAK databases. Allows creating HTTP endpoints (SQL queries, JS/Python scripts, AQL HTTP calls) via web UI, serving them at `/api/v1/*`, and syncing via CLI (`kisync`).
## Stack
- **Backend:** Node.js, Express 4.18, TypeScript, PostgreSQL (metadata store), PM2
- **Frontend:** React 18, TypeScript, Vite 5, Tailwind CSS, Zustand, Monaco Editor
- **CLI (`kisync`):** TypeScript, Commander.js, Node-Fetch
- **Target DBs:** PostgreSQL, MySQL, MSSQL, AQL (custom HTTP API)
## Project structure
```
backend/ # Express API server
src/
config/ # database.ts, environment.ts, dynamicSwagger.ts
controllers/ # endpointController, dynamicApiController, syncController
middleware/ # auth.ts (JWT), apiKey.ts
migrations/ # 001-009 sequential SQL migrations
routes/ # REST + dynamic wildcard router
services/ # SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor, DatabasePoolManager
types/ # index.ts — all interfaces
frontend/ # React SPA
src/
pages/ # Endpoints, Folders, ApiKeys, DatabaseManagement, SqlWorkbench
components/ # Reusable UI components
services/ # api.ts (Axios client)
stores/ # Zustand stores (auth, endpoints, etc.)
cli/ # kisync CLI tool
src/
commands/ # init, pull, push, status, update
api.ts # HTTP client to backend
files.ts # Endpoint ↔ filesystem serialization
hash.ts # SHA256 change detection
```
## Key concepts
- **Endpoint execution types:** `sql` (direct query), `script` (JS in VM / Python subprocess), `aql` (HTTP proxy)
- **Dynamic routing:** `router.all('*')` at `/api/v1/` matches endpoints by path + method from DB
- **Auth:** JWT for web UI, API keys (`X-API-Key` header) for external consumers
- **Permissions:** endpoint-level or folder-level (`folder:<id>`), with parent chain traversal
- **CLI sync:** hash-based change detection, conflict resolution via `_base_updated_at`
- **Query safety:** DDL/DML blocklist (DROP, TRUNCATE, ALTER, CREATE, DELETE FROM), single-statement only
## Commands
```bash
npm run dev # concurrently backend (nodemon) + frontend (vite)
npm run build # tsc + vite build
npm run migrate # run SQL migrations
npm run start:prod # PM2 production (port 10805)
```
## Production
- PM2 fork mode, port 10805, max 500MB RAM
- Frontend served as static files from `frontend/dist`
- SPA fallback for non-API routes
## Pipeline (agent workflow)
```
analyst -> spec.md
planner -> plan.md (reads spec)
developer -> code + testing-instructions.md (reads spec + plan)
tester-ui -> testing results (replay-loop with developer on FAIL)
code-reviewer -> review.md (APPROVE | REQUEST_CHANGES | BLOCK)
deployer -> deployment record (pre-step: safety-reviewer)
tech-writer -> updates docs + CLAUDE.md
```

View File

@@ -14,6 +14,7 @@ export const getDatabases = async (req: Request, res: Response) => {
host: db.host,
port: db.port,
database: db.database_name,
has_test_env: db.has_test_env || false,
}));
res.json(sanitized);

View File

@@ -3,13 +3,15 @@ import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager';
import { generateSchemaForDatabase } from './schemaController';
import { Environment } from '../types';
// Только админы могут управлять базами данных
export const getDatabases = async (req: AuthRequest, res: Response) => {
try {
const result = await mainPool.query(
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at
FROM databases ORDER BY name`
);
@@ -28,6 +30,8 @@ export const getDatabase = async (req: AuthRequest, res: Response) => {
const result = await mainPool.query(
`SELECT id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at
FROM databases WHERE id = $1`,
[id]
@@ -48,18 +52,18 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
try {
const {
name, type, host, port, database_name, username, password, ssl,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
} = req.body;
const dbType = type || 'postgresql';
// Валидация для обычных БД
if (dbType !== 'aql') {
if (!name || !host || !port || !database_name || !username || !password) {
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
}
} else {
// Валидация для AQL
if (!name || !aql_base_url) {
return res.status(400).json({ error: 'Не заполнены обязательные поля для AQL базы' });
}
@@ -68,9 +72,11 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
const result = await mainPool.query(
`INSERT INTO databases (
name, type, host, port, database_name, username, password, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
name,
@@ -84,17 +90,25 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
aql_base_url || null,
aql_auth_type || null,
aql_auth_value || null,
aql_headers ? JSON.stringify(aql_headers) : null
aql_headers ? JSON.stringify(aql_headers) : null,
has_test_env || false,
test_host || null,
test_port || null,
test_database_name || null,
test_username || null,
test_password || null,
test_ssl || null,
test_aql_base_url || null,
test_aql_auth_value || null,
test_aql_headers ? JSON.stringify(test_aql_headers) : null,
]
);
const newDb = result.rows[0];
// Добавить пул подключений (только для не-AQL баз)
if (dbType !== 'aql') {
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);
@@ -102,9 +116,10 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
}
}
// Не возвращаем пароль
delete newDb.password;
delete newDb.aql_auth_value; // Также не возвращаем auth value
delete newDb.aql_auth_value;
delete newDb.test_password;
delete newDb.test_aql_auth_value;
res.status(201).json(newDb);
} catch (error: any) {
@@ -121,62 +136,67 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => {
const { id } = req.params;
const {
name, type, host, port, database_name, username, password, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers
aql_base_url, aql_auth_type, aql_auth_value, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_password, test_ssl,
test_aql_base_url, test_aql_auth_value, test_aql_headers
} = req.body;
// Если пароль/auth не передан, не обновляем его
let query;
let params;
// Build SET clauses and params dynamically to handle optional password fields
const setClauses: string[] = [];
const params: any[] = [];
let idx = 1;
if (password || aql_auth_value) {
query = `
UPDATE databases
SET name = COALESCE($1, name),
type = COALESCE($2, type),
host = COALESCE($3, host),
port = COALESCE($4, port),
database_name = COALESCE($5, database_name),
username = COALESCE($6, username),
password = COALESCE($7, password),
ssl = COALESCE($8, ssl),
is_active = COALESCE($9, is_active),
aql_base_url = COALESCE($10, aql_base_url),
aql_auth_type = COALESCE($11, aql_auth_type),
aql_auth_value = COALESCE($12, aql_auth_value),
aql_headers = COALESCE($13, aql_headers),
updated_at = CURRENT_TIMESTAMP
WHERE id = $14
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
`;
params = [
name, type, host, port, database_name, username, password || aql_auth_value, ssl, is_active,
aql_base_url, aql_auth_type, aql_auth_value, aql_headers ? JSON.stringify(aql_headers) : null, id
];
} else {
query = `
UPDATE databases
SET name = COALESCE($1, name),
type = COALESCE($2, type),
host = COALESCE($3, host),
port = COALESCE($4, port),
database_name = COALESCE($5, database_name),
username = COALESCE($6, username),
ssl = COALESCE($7, ssl),
is_active = COALESCE($8, is_active),
aql_base_url = COALESCE($9, aql_base_url),
aql_auth_type = COALESCE($10, aql_auth_type),
aql_headers = COALESCE($11, aql_headers),
updated_at = CURRENT_TIMESTAMP
WHERE id = $12
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers, created_at, updated_at
`;
params = [
name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers ? JSON.stringify(aql_headers) : null, id
];
}
const addField = (clause: string, value: any) => {
setClauses.push(`${clause} = $${idx}`);
params.push(value);
idx++;
};
const addCoalesce = (field: string, value: any) => {
setClauses.push(`${field} = COALESCE($${idx}, ${field})`);
params.push(value);
idx++;
};
addCoalesce('name', name);
addCoalesce('type', type);
addCoalesce('host', host);
addCoalesce('port', port);
addCoalesce('database_name', database_name);
addCoalesce('username', username);
if (password) { addField('password', password); }
addCoalesce('ssl', ssl);
addCoalesce('is_active', is_active);
addCoalesce('aql_base_url', aql_base_url);
addCoalesce('aql_auth_type', aql_auth_type);
if (aql_auth_value) { addField('aql_auth_value', aql_auth_value); }
addCoalesce('aql_headers', aql_headers ? JSON.stringify(aql_headers) : null);
addCoalesce('has_test_env', has_test_env);
// Test fields: use direct SET (nullable — user may want to clear them)
addField('test_host', test_host || null);
addField('test_port', test_port || null);
addField('test_database_name', test_database_name || null);
addField('test_username', test_username || null);
if (test_password) { addField('test_password', test_password); }
addField('test_ssl', test_ssl !== undefined ? test_ssl : null);
addField('test_aql_base_url', test_aql_base_url || null);
if (test_aql_auth_value) { addField('test_aql_auth_value', test_aql_auth_value); }
addField('test_aql_headers', test_aql_headers ? JSON.stringify(test_aql_headers) : null);
setClauses.push('updated_at = CURRENT_TIMESTAMP');
params.push(id);
const whereIdx = idx;
const query = `
UPDATE databases
SET ${setClauses.join(', ')}
WHERE id = $${whereIdx}
RETURNING id, name, type, host, port, database_name, username, ssl, is_active,
aql_base_url, aql_auth_type, aql_headers,
has_test_env, test_host, test_port, test_database_name, test_username, test_ssl,
test_aql_base_url, test_aql_headers,
created_at, updated_at`;
const result = await mainPool.query(query, params);
@@ -184,7 +204,6 @@ export const updateDatabase = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ error: 'База данных не найдена' });
}
// Перезагрузить пул (только для не-AQL баз)
if (result.rows[0].type !== 'aql') {
await databasePoolManager.reloadPool(id);
}
@@ -203,7 +222,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
// Проверяем, используется ли база данных в эндпоинтах
const endpointCheck = await mainPool.query(
'SELECT COUNT(*) FROM endpoints WHERE database_id = $1',
[id]
@@ -224,7 +242,6 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ error: 'База данных не найдена' });
}
// Удалить пул
databasePoolManager.removePool(id);
res.json({ message: 'База данных удалена успешно' });
@@ -237,8 +254,8 @@ export const deleteDatabase = async (req: AuthRequest, res: Response) => {
export const testDatabaseConnection = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const env = (req.query.env as string)?.toLowerCase() === 'test' ? 'test' : 'prod' as Environment;
// Получаем тип БД
const dbResult = await mainPool.query('SELECT type FROM databases WHERE id = $1', [id]);
if (dbResult.rows.length === 0) {
@@ -248,17 +265,15 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
const dbType = dbResult.rows[0].type;
if (dbType === 'aql') {
// Для AQL используем aqlExecutor
const { aqlExecutor } = require('../services/AqlExecutor');
const result = await aqlExecutor.testConnection(id);
const result = await aqlExecutor.testConnection(id, env);
res.json({
success: result.success,
message: result.success ? 'Подключение успешно' : result.error || 'Ошибка подключения',
});
} else {
// Для обычных БД используем databasePoolManager
const isConnected = await databasePoolManager.testConnection(id);
const isConnected = await databasePoolManager.testConnection(id, env);
res.json({
success: isConnected,

View File

@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
import { mainPool } from '../config/database';
import { sqlExecutor } from '../services/SqlExecutor';
import { scriptExecutor } from '../services/ScriptExecutor';
import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types';
import { EndpointParameter, ScriptQuery, ScriptExecutionError, Environment } from '../types';
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
const startTime = Date.now();
@@ -11,6 +11,9 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
let endpointId: string | null = null;
try {
const environment: Environment =
(req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod';
// Extract the path from the request (remove /api/v1 prefix)
const requestPath = req.path; // This already has the path without /api/v1
const requestMethod = req.method.toUpperCase();
@@ -171,7 +174,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
body: aqlBody,
queryParams: aqlQueryParams,
parameters: requestParams,
});
}, environment);
} else if (executionType === 'script') {
// Execute script
const scriptLanguage = endpoint.script_language;
@@ -199,6 +202,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
scriptQueries,
requestParams,
endpointParameters: parameters,
environment,
});
result = {
@@ -231,7 +235,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
result = await sqlExecutor.executeQuery(
endpoint.database_id,
processedQuery,
queryParams
queryParams,
environment
);
}
@@ -264,6 +269,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
error_message: null,
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
user_agent: req.headers['user-agent'] || 'unknown',
environment,
});
}
@@ -295,6 +301,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
error_message: error.message,
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
user_agent: req.headers['user-agent'] || 'unknown',
environment: (req.headers['x-environment'] as string)?.toLowerCase() === 'test' ? 'test' : 'prod',
});
}
@@ -315,6 +322,7 @@ async function logRequest(data: {
error_message: string | null;
ip_address: string;
user_agent: string;
environment?: string;
}) {
try {
await mainPool.query(
@@ -322,8 +330,8 @@ async function logRequest(data: {
endpoint_id, api_key_id, method, path,
request_params, request_body, response_status,
response_data, execution_time, error_message,
ip_address, user_agent
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
ip_address, user_agent, environment
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[
data.endpoint_id,
data.api_key_id,
@@ -337,6 +345,7 @@ async function logRequest(data: {
data.error_message,
data.ip_address,
data.user_agent,
data.environment || 'prod',
]
);
} catch (error) {

View File

@@ -2,7 +2,7 @@ import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { v4 as uuidv4 } from 'uuid';
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError } from '../types';
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
export const getEndpoints = async (req: AuthRequest, res: Response) => {
@@ -287,8 +287,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
aql_endpoint,
aql_body,
aql_query_params,
environment: reqEnv,
} = req.body;
const environment: Environment = reqEnv === 'prod' ? 'prod' : 'test';
const execType = execution_type || 'sql';
if (execType === 'sql') {
@@ -314,7 +316,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
}
const { sqlExecutor } = require('../services/SqlExecutor');
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || []);
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment);
res.json({
success: true,
@@ -346,6 +348,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
scriptQueries: script_queries || [],
requestParams,
endpointParameters: endpoint_parameters || [],
environment,
});
res.json({
@@ -377,7 +380,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
body: aql_body,
queryParams: aql_query_params,
parameters: requestParams,
});
}, environment);
res.json({
success: true,

View File

@@ -0,0 +1,18 @@
-- Test environment credentials for databases
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_host VARCHAR(255);
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_port INTEGER;
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_database_name VARCHAR(255);
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_username VARCHAR(255);
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_password VARCHAR(255);
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_ssl BOOLEAN;
-- AQL test credentials
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_base_url TEXT;
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_auth_value TEXT;
ALTER TABLE databases ADD COLUMN IF NOT EXISTS test_aql_headers JSONB;
-- Quick toggle
ALTER TABLE databases ADD COLUMN IF NOT EXISTS has_test_env BOOLEAN DEFAULT false;
-- Track environment in request logs
ALTER TABLE request_logs ADD COLUMN IF NOT EXISTS environment VARCHAR(10) DEFAULT 'prod';

View File

@@ -1,5 +1,6 @@
import { QueryResult, DatabaseConfig } from '../types';
import { QueryResult, DatabaseConfig, Environment } from '../types';
import { mainPool } from '../config/database';
import { databasePoolManager } from './DatabasePoolManager';
interface AqlRequestConfig {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
@@ -73,19 +74,30 @@ export class AqlExecutor {
*/
async executeAqlQuery(
databaseId: string,
config: AqlRequestConfig
config: AqlRequestConfig,
environment: Environment = 'prod'
): Promise<QueryResult> {
const startTime = Date.now();
try {
// Получаем конфигурацию БД
const dbConfig = await this.getDatabaseConfig(databaseId);
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
if (!dbConfig) {
throw new Error(`AQL database with id ${databaseId} not found or not configured`);
}
if (!dbConfig.aql_base_url) {
// Use test credentials when environment is test and test env is configured
let baseUrl = dbConfig.aql_base_url;
let authValue = dbConfig.aql_auth_value;
let headers_config = dbConfig.aql_headers;
if (environment === 'test' && dbConfig.has_test_env) {
if (dbConfig.test_aql_base_url) baseUrl = dbConfig.test_aql_base_url;
if (dbConfig.test_aql_auth_value) authValue = dbConfig.test_aql_auth_value;
if (dbConfig.test_aql_headers) headers_config = dbConfig.test_aql_headers;
}
if (!baseUrl) {
throw new Error(`AQL base URL not configured for database ${databaseId}`);
}
@@ -100,7 +112,7 @@ export class AqlExecutor {
: '';
// Формируем полный URL
const fullUrl = `${dbConfig.aql_base_url}${processedEndpoint}${queryString}`;
const fullUrl = `${baseUrl}${processedEndpoint}${queryString}`;
// Обрабатываем body с параметрами
let processedBody: string | undefined;
@@ -115,19 +127,19 @@ export class AqlExecutor {
};
// Добавляем аутентификацию
if (dbConfig.aql_auth_type === 'basic' && dbConfig.aql_auth_value) {
headers['Authorization'] = `Basic ${dbConfig.aql_auth_value}`;
} else if (dbConfig.aql_auth_type === 'bearer' && dbConfig.aql_auth_value) {
headers['Authorization'] = `Bearer ${dbConfig.aql_auth_value}`;
} else if (dbConfig.aql_auth_type === 'custom' && dbConfig.aql_auth_value) {
headers['Authorization'] = dbConfig.aql_auth_value;
if (dbConfig.aql_auth_type === 'basic' && authValue) {
headers['Authorization'] = `Basic ${authValue}`;
} else if (dbConfig.aql_auth_type === 'bearer' && authValue) {
headers['Authorization'] = `Bearer ${authValue}`;
} else if (dbConfig.aql_auth_type === 'custom' && authValue) {
headers['Authorization'] = authValue;
}
// Добавляем кастомные заголовки из конфигурации БД
if (dbConfig.aql_headers) {
const customHeaders = typeof dbConfig.aql_headers === 'string'
? JSON.parse(dbConfig.aql_headers)
: dbConfig.aql_headers;
if (headers_config) {
const customHeaders = typeof headers_config === 'string'
? JSON.parse(headers_config)
: headers_config;
Object.assign(headers, customHeaders);
}
@@ -234,28 +246,29 @@ export class AqlExecutor {
/**
* Тестирует подключение к AQL базе
*/
async testConnection(databaseId: string): Promise<{ success: boolean; error?: string }> {
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<{ success: boolean; error?: string }> {
try {
const dbConfig = await this.getDatabaseConfig(databaseId);
const dbConfig = await databasePoolManager.getDatabaseConfig(databaseId);
if (!dbConfig) {
return { success: false, error: 'Database not found' };
}
if (!dbConfig.aql_base_url) {
let testUrl = dbConfig.aql_base_url;
if (environment === 'test' && dbConfig.has_test_env && dbConfig.test_aql_base_url) {
testUrl = dbConfig.test_aql_base_url;
}
if (!testUrl) {
return { success: false, error: 'AQL base URL not configured' };
}
// Пробуем выполнить простой запрос для проверки соединения
const response = await fetch(dbConfig.aql_base_url, {
const response = await fetch(testUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
headers: { 'Accept': 'application/json' },
});
if (response.ok || response.status === 404) {
// 404 тоже OK - это значит что сервер доступен
return { success: true };
} else {
return { success: false, error: `HTTP ${response.status}` };

View File

@@ -1,12 +1,16 @@
import { Pool } from 'pg';
import { DatabaseConfig } from '../types';
import { DatabaseConfig, Environment } from '../types';
import { mainPool } from '../config/database';
interface PoolEntry {
prod: Pool;
test?: Pool;
}
class DatabasePoolManager {
private pools: Map<string, Pool> = new Map();
private pools: Map<string, PoolEntry> = new Map();
async initialize() {
// Load databases from DB instead of env
await this.loadDatabasesFromDB();
}
@@ -17,67 +21,112 @@ class DatabasePoolManager {
);
for (const row of result.rows) {
const dbConfig: DatabaseConfig = {
id: row.id,
name: row.name,
type: row.type,
host: row.host,
port: row.port,
database_name: row.database_name,
username: row.username,
password: row.password,
ssl: row.ssl,
is_active: row.is_active,
};
const dbConfig = this.rowToConfig(row);
this.addPool(dbConfig);
}
console.log(`Loaded ${result.rows.length} database connection(s) from DB`);
console.log(`Loaded ${result.rows.length} database connection(s) from DB`);
} catch (error) {
console.error('Failed to load databases from DB:', error);
console.error('Failed to load databases from DB:', error);
}
}
private rowToConfig(row: any): DatabaseConfig {
return {
id: row.id,
name: row.name,
type: row.type,
host: row.host,
port: row.port,
database_name: row.database_name,
username: row.username,
password: row.password,
ssl: row.ssl,
is_active: row.is_active,
aql_base_url: row.aql_base_url,
aql_auth_type: row.aql_auth_type,
aql_auth_value: row.aql_auth_value,
aql_headers: row.aql_headers,
has_test_env: row.has_test_env,
test_host: row.test_host,
test_port: row.test_port,
test_database_name: row.test_database_name,
test_username: row.test_username,
test_password: row.test_password,
test_ssl: row.test_ssl,
test_aql_base_url: row.test_aql_base_url,
test_aql_auth_value: row.test_aql_auth_value,
test_aql_headers: row.test_aql_headers,
};
}
private createPool(host: string, port: number, database: string, user: string, password: string, ssl: boolean): Pool {
const pool = new Pool({
host,
port,
database,
user,
password,
ssl: ssl ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 60000,
connectionTimeoutMillis: 10000,
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
});
pool.on('error', (err) => {
console.error(`Database pool error:`, err);
});
return pool;
}
addPool(dbConfig: DatabaseConfig) {
if (this.pools.has(dbConfig.id)) {
console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`);
return;
}
const pool = new Pool({
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database_name,
user: dbConfig.username,
password: dbConfig.password,
ssl: dbConfig.ssl ? { rejectUnauthorized: false } : false,
max: 20, // Увеличено количество соединений
idleTimeoutMillis: 60000, // 60 секунд
connectionTimeoutMillis: 10000, // 10 секунд (было 2 секунды)
keepAlive: true, // Поддерживать соединения активными
keepAliveInitialDelayMillis: 10000, // Начать keepAlive через 10 секунд
});
const prodPool = this.createPool(
dbConfig.host, dbConfig.port, dbConfig.database_name,
dbConfig.username, dbConfig.password, dbConfig.ssl
);
pool.on('error', (err) => {
console.error(`Database pool error for ${dbConfig.id}:`, err);
});
const entry: PoolEntry = { prod: prodPool };
this.pools.set(dbConfig.id, pool);
console.log(`✅ Pool created for database: ${dbConfig.name} (${dbConfig.id})`);
if (dbConfig.has_test_env && dbConfig.test_host) {
entry.test = this.createPool(
dbConfig.test_host,
dbConfig.test_port || dbConfig.port,
dbConfig.test_database_name || dbConfig.database_name,
dbConfig.test_username || dbConfig.username,
dbConfig.test_password || dbConfig.password,
dbConfig.test_ssl !== undefined ? dbConfig.test_ssl : dbConfig.ssl
);
console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod + test]`);
} else {
console.log(`Pool created for database: ${dbConfig.name} (${dbConfig.id}) [prod]`);
}
this.pools.set(dbConfig.id, entry);
}
removePool(databaseId: string) {
const pool = this.pools.get(databaseId);
if (pool) {
pool.end();
const entry = this.pools.get(databaseId);
if (entry) {
entry.prod.end();
if (entry.test) entry.test.end();
this.pools.delete(databaseId);
console.log(`Pool removed for database: ${databaseId}`);
}
}
getPool(databaseId: string): Pool | undefined {
return this.pools.get(databaseId);
getPool(databaseId: string, environment: Environment = 'prod'): Pool | undefined {
const entry = this.pools.get(databaseId);
if (!entry) return undefined;
if (environment === 'test' && entry.test) return entry.test;
return entry.prod;
}
async getDatabaseConfig(databaseId: string): Promise<DatabaseConfig | null> {
@@ -86,26 +135,8 @@ class DatabasePoolManager {
'SELECT * FROM databases WHERE id = $1',
[databaseId]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
id: row.id,
name: row.name,
type: row.type,
host: row.host,
port: row.port,
database_name: row.database_name,
username: row.username,
password: row.password,
ssl: row.ssl,
is_active: row.is_active,
aql_base_url: row.aql_base_url,
aql_auth_type: row.aql_auth_type,
aql_auth_value: row.aql_auth_value,
aql_headers: row.aql_headers,
};
if (result.rows.length === 0) return null;
return this.rowToConfig(result.rows[0]);
} catch (error) {
console.error('Error fetching database config:', error);
return null;
@@ -115,7 +146,7 @@ class DatabasePoolManager {
async getAllDatabaseConfigs(): Promise<DatabaseConfig[]> {
try {
const result = await mainPool.query(
'SELECT id, name, type, host, port, database_name, is_active FROM databases WHERE is_active = true'
'SELECT id, name, type, host, port, database_name, is_active, has_test_env FROM databases WHERE is_active = true'
);
return result.rows;
} catch (error) {
@@ -124,53 +155,40 @@ class DatabasePoolManager {
}
}
async testConnection(databaseId: string): Promise<boolean> {
const pool = this.getPool(databaseId);
if (!pool) {
return false;
}
async testConnection(databaseId: string, environment: Environment = 'prod'): Promise<boolean> {
const pool = this.getPool(databaseId, environment);
if (!pool) return false;
try {
const client = await pool.connect();
client.release();
return true;
} catch (error) {
console.error(`Connection test failed for ${databaseId}:`, error);
console.error(`Connection test failed for ${databaseId} (${environment}):`, error);
return false;
}
}
async reloadPool(databaseId: string) {
// Remove old pool
this.removePool(databaseId);
// Load new config from DB
const result = await mainPool.query(
'SELECT * FROM databases WHERE id = $1 AND is_active = true',
[databaseId]
);
if (result.rows.length > 0) {
const row = result.rows[0];
const dbConfig: DatabaseConfig = {
id: row.id,
name: row.name,
type: row.type,
host: row.host,
port: row.port,
database_name: row.database_name,
username: row.username,
password: row.password,
ssl: row.ssl,
is_active: row.is_active,
};
const dbConfig = this.rowToConfig(result.rows[0]);
this.addPool(dbConfig);
}
}
async closeAll() {
const promises = Array.from(this.pools.values()).map((pool) => pool.end());
const promises: Promise<void>[] = [];
for (const entry of this.pools.values()) {
promises.push(entry.prod.end());
if (entry.test) promises.push(entry.test.end());
}
await Promise.all(promises);
this.pools.clear();
console.log('All database pools closed');

View File

@@ -1,7 +1,7 @@
import * as vm from 'vm';
import { sqlExecutor } from './SqlExecutor';
import { aqlExecutor } from './AqlExecutor';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types';
import { databasePoolManager } from './DatabasePoolManager';
interface IsolatedScriptContext {
@@ -9,6 +9,7 @@ interface IsolatedScriptContext {
scriptQueries: ScriptQuery[];
requestParams: Record<string, any>;
endpointParameters: EndpointParameter[];
environment?: Environment;
}
export class IsolatedScriptExecutor {
@@ -74,7 +75,7 @@ export class IsolatedScriptExecutor {
body: query.aql_body || '',
queryParams: query.aql_query_params || {},
parameters: allParams,
});
}, context.environment);
queries.push({
name: queryName,
@@ -118,7 +119,7 @@ export class IsolatedScriptExecutor {
paramValues.push(value !== undefined ? value : null);
});
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues, context.environment);
queries.push({
name: queryName,

View File

@@ -1,7 +1,7 @@
import { spawn } from 'child_process';
import { sqlExecutor } from './SqlExecutor';
import { aqlExecutor } from './AqlExecutor';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError, Environment } from '../types';
import { databasePoolManager } from './DatabasePoolManager';
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
@@ -10,6 +10,7 @@ interface ScriptContext {
scriptQueries: ScriptQuery[];
requestParams: Record<string, any>;
endpointParameters: EndpointParameter[];
environment?: Environment;
}
export class ScriptExecutor {
@@ -135,7 +136,7 @@ print(json.dumps(result))
body: query.aql_body || '',
queryParams: query.aql_query_params || {},
parameters: allParams,
});
}, context.environment);
queries.push({
name: request.query_name,
@@ -195,7 +196,8 @@ print(json.dumps(result))
const result = await sqlExecutor.executeQuery(
dbId,
processedQuery,
paramValues
paramValues,
context.environment
);
queries.push({

View File

@@ -1,4 +1,4 @@
import { QueryResult } from '../types';
import { QueryResult, Environment } from '../types';
import { databasePoolManager } from './DatabasePoolManager';
export class SqlExecutor {
@@ -32,9 +32,10 @@ export class SqlExecutor {
async executeQuery(
databaseId: string,
sqlQuery: string,
params: any[] = []
params: any[] = [],
environment: Environment = 'prod'
): Promise<QueryResult> {
const pool = databasePoolManager.getPool(databaseId);
const pool = databasePoolManager.getPool(databaseId, environment);
if (!pool) {
throw new Error(`Database with id ${databaseId} not found or not configured`);

View File

@@ -1,3 +1,5 @@
export type Environment = 'prod' | 'test';
export interface DatabaseConfig {
id: string;
name: string;
@@ -14,6 +16,17 @@ export interface DatabaseConfig {
aql_auth_type?: 'basic' | 'bearer' | 'custom';
aql_auth_value?: string;
aql_headers?: Record<string, string>;
// Test environment fields
has_test_env?: boolean;
test_host?: string;
test_port?: number;
test_database_name?: string;
test_username?: string;
test_password?: string;
test_ssl?: boolean;
test_aql_base_url?: string;
test_aql_auth_value?: string;
test_aql_headers?: Record<string, string>;
created_at?: Date;
updated_at?: Date;
}

View File

@@ -93,6 +93,7 @@ export default function EndpointEditor() {
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
const [curlApiKey, setCurlApiKey] = useState('');
const [testEnvironment, setTestEnvironment] = useState<'test' | 'prod'>('test');
const selectedDatabase = databases?.find(db => db.id === formData.database_id);
const isAqlDatabase = selectedDatabase?.type === 'aql';
@@ -189,6 +190,7 @@ export default function EndpointEditor() {
aql_query_params: typeof formData.aql_query_params === 'string' ? {} : formData.aql_query_params || {},
parameters: paramValues,
endpoint_parameters: formData.parameters,
environment: testEnvironment,
} as any);
} else if (formData.execution_type === 'script') {
const scriptQueries = formData.script_queries || [];
@@ -201,6 +203,7 @@ export default function EndpointEditor() {
script_queries: scriptQueries,
parameters: paramValues,
endpoint_parameters: formData.parameters,
environment: testEnvironment,
});
} else {
return endpointsApi.test({
@@ -209,6 +212,7 @@ export default function EndpointEditor() {
sql_query: formData.sql_query || '',
parameters: paramValues,
endpoint_parameters: formData.parameters,
environment: testEnvironment,
});
}
},
@@ -901,8 +905,38 @@ export default function EndpointEditor() {
</div>
)}
{/* Test button */}
<div className="card p-4">
{/* Environment toggle + Test button */}
<div className="card p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500">Среда:</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200">
<button
type="button"
onClick={() => setTestEnvironment('test')}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
testEnvironment === 'test'
? 'bg-orange-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
Test
</button>
<button
type="button"
onClick={() => setTestEnvironment('prod')}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
testEnvironment === 'prod'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
Prod
</button>
</div>
{selectedDatabase && !selectedDatabase.has_test_env && testEnvironment === 'test' && (
<span className="text-xs text-amber-600">Test env не настроена fallback на prod</span>
)}
</div>
<button
type="button"
onClick={() => testMutation.mutate()}
@@ -915,10 +949,14 @@ export default function EndpointEditor() {
: !formData.script_code
)
}
className="btn btn-primary w-full flex items-center justify-center gap-2"
className={`btn w-full flex items-center justify-center gap-2 ${
testEnvironment === 'test'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'btn-primary'
}`}
>
<Play size={18} />
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
{testMutation.isPending ? 'Тестирование...' : `Тест (${testEnvironment})`}
</button>
</div>

View File

@@ -284,13 +284,13 @@ function DatabasesSubTab() {
onError: () => toast.error('Не удалось удалить базу данных'),
});
const testConnection = async (databaseId: string) => {
const testConnection = async (databaseId: string, env?: 'prod' | 'test') => {
try {
const { data } = await dbManagementApi.test(databaseId);
const { data } = await dbManagementApi.test(databaseId, env);
if (data.success) {
toast.success('Подключение успешно!');
toast.success(env === 'test' ? 'Test Env: подключение успешно!' : 'Подключение успешно!');
} else {
toast.error('Ошибка подключения');
toast.error(env === 'test' ? 'Test Env: ошибка подключения' : 'Ошибка подключения');
}
} catch (error) {
toast.error('Ошибка тестирования подключения');
@@ -350,6 +350,9 @@ function DatabasesSubTab() {
{db.is_active && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
)}
{db.has_test_env && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded">Test Env</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
{db.type === 'aql' ? (
@@ -375,6 +378,14 @@ function DatabasesSubTab() {
>
Тест
</button>
{db.has_test_env && (
<button
onClick={() => testConnection(db.id, 'test')}
className="btn text-sm bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100"
>
Тест (Test Env)
</button>
)}
<button
onClick={() => handleEdit(db)}
className="btn btn-secondary text-sm flex items-center gap-1"
@@ -431,6 +442,7 @@ function DatabaseModal({
}) {
const queryClient = useQueryClient();
const [showPassword, setShowPassword] = useState(false);
const [showTestEnv, setShowTestEnv] = useState(database?.has_test_env || false);
const [formData, setFormData] = useState({
name: database?.name || '',
type: database?.type || 'postgresql',
@@ -446,18 +458,30 @@ function DatabaseModal({
aql_auth_type: database?.aql_auth_type || 'basic',
aql_auth_value: database?.aql_auth_value || '',
aql_headers: database?.aql_headers || {},
// Test environment
has_test_env: database?.has_test_env || false,
test_host: database?.test_host || '',
test_port: database?.test_port || 5432,
test_database_name: database?.test_database_name || '',
test_username: database?.test_username || '',
test_password: '',
test_ssl: database?.test_ssl || false,
test_aql_base_url: database?.test_aql_base_url || '',
test_aql_auth_value: '',
test_aql_headers: database?.test_aql_headers || {},
});
const saveMutation = useMutation({
mutationFn: (data: any) => {
const payload = { ...data };
// Если редактируем и пароль пустой, удаляем его из payload
if (database && !payload.password) {
delete payload.password;
}
// Для AQL: если редактируем и auth_value пустой, удаляем его
if (database && data.type === 'aql' && !payload.aql_auth_value) {
delete payload.aql_auth_value;
if (database && !payload.password) delete payload.password;
if (database && data.type === 'aql' && !payload.aql_auth_value) delete payload.aql_auth_value;
if (database && !payload.test_password) delete payload.test_password;
if (database && !payload.test_aql_auth_value) delete payload.test_aql_auth_value;
if (!payload.has_test_env) {
delete payload.test_host; delete payload.test_port; delete payload.test_database_name;
delete payload.test_username; delete payload.test_password; delete payload.test_ssl;
delete payload.test_aql_base_url; delete payload.test_aql_auth_value; delete payload.test_aql_headers;
}
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
},
@@ -693,6 +717,123 @@ function DatabaseModal({
</>
)}
{/* Test Environment Section */}
<div className="border-t border-gray-200 pt-4 mt-4">
<button
type="button"
onClick={() => {
const next = !showTestEnv;
setShowTestEnv(next);
setFormData({ ...formData, has_test_env: next });
}}
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900"
>
<span className={`transform transition-transform ${showTestEnv ? 'rotate-90' : ''}`}>&#9654;</span>
Тестовая среда
{formData.has_test_env && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">Включена</span>
)}
</button>
{showTestEnv && (
<div className="mt-4 space-y-4 pl-4 border-l-2 border-orange-200">
<p className="text-xs text-gray-500">
Альтернативные креды для тестирования endpoint-ов. При тестировании запросы пойдут на эту БД вместо продовой.
</p>
{formData.type === 'aql' ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test AQL Base URL</label>
<input
type="text"
value={formData.test_aql_base_url}
onChange={(e) => setFormData({ ...formData, test_aql_base_url: e.target.value })}
className="input w-full"
placeholder="http://test-api.example.com/api/rest/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test Auth Value</label>
<input
type="password"
value={formData.test_aql_auth_value}
onChange={(e) => setFormData({ ...formData, test_aql_auth_value: e.target.value })}
className="input w-full"
placeholder={database ? '(не менять)' : 'Значение'}
/>
</div>
</>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test Host</label>
<input
type="text"
value={formData.test_host}
onChange={(e) => setFormData({ ...formData, test_host: e.target.value })}
className="input w-full"
placeholder="test-db-host"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test Port</label>
<input
type="number"
value={formData.test_port}
onChange={(e) => setFormData({ ...formData, test_port: parseInt(e.target.value) })}
className="input w-full"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test DB Name</label>
<input
type="text"
value={formData.test_database_name}
onChange={(e) => setFormData({ ...formData, test_database_name: e.target.value })}
className="input w-full"
placeholder="test_database"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test User</label>
<input
type="text"
value={formData.test_username}
onChange={(e) => setFormData({ ...formData, test_username: e.target.value })}
className="input w-full"
placeholder="test_user"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Test Password</label>
<input
type="password"
value={formData.test_password}
onChange={(e) => setFormData({ ...formData, test_password: e.target.value })}
className="input w-full"
placeholder={database ? '(не менять)' : 'Пароль'}
/>
</div>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.test_ssl}
onChange={(e) => setFormData({ ...formData, test_ssl: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Test SSL</span>
</label>
</>
)}
</div>
)}
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button type="button" onClick={onClose} className="btn btn-secondary">
Отмена

View File

@@ -86,8 +86,8 @@ export const dbManagementApi = {
delete: (id: string) =>
api.delete(`/db-management/${id}`),
test: (id: string) =>
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test`),
test: (id: string, env?: 'prod' | 'test') =>
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test${env === 'test' ? '?env=test' : ''}`),
};
// Endpoints API
@@ -120,6 +120,7 @@ export const endpointsApi = {
aql_endpoint?: string;
aql_body?: string;
aql_query_params?: Record<string, string>;
environment?: 'prod' | 'test';
}) =>
api.post<QueryTestResult>('/endpoints/test', data),

View File

@@ -22,6 +22,15 @@ export interface Database {
aql_auth_type?: 'basic' | 'bearer' | 'custom';
aql_auth_value?: string;
aql_headers?: Record<string, string>;
// Test environment
has_test_env?: boolean;
test_host?: string;
test_port?: number;
test_database_name?: string;
test_username?: string;
test_ssl?: boolean;
test_aql_base_url?: string;
test_aql_headers?: Record<string, string>;
}
export interface Folder {