Compare commits

..

48 Commits

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

44
.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
node_modules
**/node_modules
# Build outputs (will be rebuilt in container)
dist
**/dist
build
**/build
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
*.log
logs
# Environment files
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
# Test files
coverage
.nyc_output
# Project-specific (not needed in app container)
.claude
.git_backup
db_connections
final_endpoints_v2
nowContext
queries

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# ============================================
# KIS API Builder - Configuration
# ============================================
# Copy this file to .env and adjust values
#
# For default setup (built-in DB):
# Only APP_PORT, DB_PASSWORD and JWT_SECRET are needed
#
# For external database:
# Set all DB_* variables
# ============================================
# External port (access from host machine)
APP_PORT=3000
# Database password (used by both built-in and external DB)
DB_PASSWORD=your_secure_password_here
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
# ============================================
# External Database (only for docker-compose.external-db.yml)
# ============================================
# DB_HOST=your-postgres-host
# DB_PORT=5432
# DB_NAME=api_builder
# DB_USER=postgres

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ coverage/
*.njsproj
*.sln
*.sw?
.claude
.claude
.git_backup/

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# ============================================
# Stage 1: Build Frontend
# ============================================
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package*.json ./
# Install dependencies
RUN npm install
# Copy frontend source
COPY frontend/ ./
# Build frontend
RUN npm run build
# ============================================
# Stage 2: Build Backend
# ============================================
FROM node:20-alpine AS backend-builder
WORKDIR /app/backend
# Copy backend package files
COPY backend/package*.json ./
# Install dependencies
RUN npm ci
# Copy backend source
COPY backend/ ./
# Build TypeScript
RUN npm run build
# ============================================
# Stage 3: Production
# ============================================
FROM node:20-alpine AS production
WORKDIR /app
# Copy backend package files and install production deps
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy built backend (to /app/backend/dist)
COPY --from=backend-builder /app/backend/dist ./dist
# 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
# Set working directory to backend
WORKDIR /app/backend
# Set environment
ENV NODE_ENV=production
ENV PORT=3000
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start the application
CMD ["node", "dist/server.js"]

View File

@@ -16,30 +16,3 @@ JWT_EXPIRES_IN=24h
# API Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# Target Databases Configuration (JSON format)
# This is where your API endpoints will execute queries
TARGET_DATABASES='[
{
"id": "main_postgres",
"name": "Main PostgreSQL",
"type": "postgresql",
"host": "localhost",
"port": 5432,
"database": "your_database",
"user": "your_user",
"password": "your_password",
"ssl": false
},
{
"id": "analytics_db",
"name": "Analytics Database",
"type": "postgresql",
"host": "localhost",
"port": 5432,
"database": "analytics",
"user": "analytics_user",
"password": "analytics_password",
"ssl": false
}
]'

View File

@@ -1,49 +0,0 @@
const { Client } = require('pg');
async function testQueries() {
// Подключаемся к целевой БД (emias_pg)
const client = new Client({
host: 'm112-pgkisc-01.ncms-i.ru',
port: 5432,
database: 'kis',
user: 'XАПИД',
password: 'c4d504412b61b0560d442686dfad27'
});
await client.connect();
console.log('Connected to kis database');
const caseId = 'f580b03b-86ee-41b6-a697-1981f116c669';
// Запрос из проблемного эндпоинта (с табами)
const queryWithTabs = `SELECT\tea.c_uid a_uid,
\tp.ehr_id ehrid,
\tp.erz_number subjectid,
\tp.namespace namespace
FROM \tmm.ehr_case ec
\tINNER JOIN mm.ehr_case_action ea ON ec.last_action_id = ea.id
\tINNER JOIN mm.hospdoc hd ON hd.ehr_case_id = ec.id
\tINNER JOIN mm.mdoc md ON md.id = hd.mdoc_id
\tINNER JOIN mm.people p ON p.id = md.people_id
WHERE\tec.id = $1
AND\thd.location_status_id = 1`;
// Запрос из рабочего эндпоинта (с пробелами и CRLF)
const queryWithSpaces = `SELECT ea.c_uid a_uid,\r\n p.ehr_id ehrid,\r\n p.erz_number subjectid,\r\n p.namespace namespace\r\n FROM mm.ehr_case ec\r\n INNER JOIN mm.ehr_case_action ea ON ec.last_action_id = ea.id\r\n INNER JOIN mm.hospdoc hd ON hd.ehr_case_id = ec.id\r\n INNER JOIN mm.mdoc md ON md.id = hd.mdoc_id\r\n INNER JOIN mm.people p ON p.id = md.people_id\r\n WHERE ec.id = $1\r\n AND hd.location_status_id = 1`;
console.log('\n=== Query with TABS (problematic) ===');
console.log('HEX first 50:', Buffer.from(queryWithTabs.substring(0, 50)).toString('hex'));
const result1 = await client.query(queryWithTabs, [caseId]);
console.log('rowCount:', result1.rowCount);
console.log('rows:', JSON.stringify(result1.rows));
console.log('\n=== Query with SPACES (working) ===');
console.log('HEX first 50:', Buffer.from(queryWithSpaces.substring(0, 50)).toString('hex'));
const result2 = await client.query(queryWithSpaces, [caseId]);
console.log('rowCount:', result2.rowCount);
console.log('rows:', JSON.stringify(result2.rows));
await client.end();
}
testQueries().catch(console.error);

View File

@@ -7,8 +7,8 @@
"dev": "nodemon",
"build": "tsc",
"start": "node dist/server.js",
"migrate": "ts-node src/migrations/run.ts",
"seed": "ts-node src/migrations/seed.ts"
"migrate": "ts-node src/scripts/run.ts",
"seed": "ts-node src/scripts/seed.ts"
},
"keywords": [
"api",

View File

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

View File

@@ -2,6 +2,7 @@ import { Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager';
import { generateSchemaForDatabase } from './schemaController';
// Только админы могут управлять базами данных
export const getDatabases = async (req: AuthRequest, res: Response) => {
@@ -92,6 +93,13 @@ export const createDatabase = async (req: AuthRequest, res: Response) => {
// Добавить пул подключений (только для не-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);
});
}
}
// Не возвращаем пароль

View File

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

View File

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

View File

@@ -0,0 +1,244 @@
import { Request, Response } from 'express';
import { mainPool } from '../config/database';
import { databasePoolManager } from '../services/DatabasePoolManager';
interface ColumnInfo {
name: string;
type: string;
nullable: boolean;
default_value: string | null;
is_primary: boolean;
comment: string | null;
}
interface ForeignKey {
column: string;
references_table: string;
references_column: string;
constraint_name: string;
}
interface TableInfo {
name: string;
schema: string;
comment: string | null;
columns: ColumnInfo[];
foreign_keys: ForeignKey[];
}
interface SchemaData {
tables: TableInfo[];
updated_at: string;
}
// Parse PostgreSQL schema - optimized with bulk queries
async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
const startTime = Date.now();
console.log(`[Schema] Starting schema parse for database ${databaseId}`);
const pool = databasePoolManager.getPool(databaseId);
if (!pool) {
throw new Error('Database not found or not active');
}
// Get all tables with comments
console.log(`[Schema] Fetching tables...`);
const tablesResult = await pool.query(`
SELECT
t.table_schema,
t.table_name,
pg_catalog.obj_description(c.oid, 'pg_class') as table_comment
FROM information_schema.tables t
LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
AND t.table_type = 'BASE TABLE'
ORDER BY t.table_schema, t.table_name
`);
console.log(`[Schema] Found ${tablesResult.rows.length} tables in ${Date.now() - startTime}ms`);
// Get ALL columns in one query
console.log(`[Schema] Fetching all columns...`);
const columnsStartTime = Date.now();
const allColumnsResult = await pool.query(`
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.ordinal_position,
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
pg_catalog.col_description(pc.oid, c.ordinal_position) as column_comment
FROM information_schema.columns c
LEFT JOIN pg_catalog.pg_class pc ON pc.relname = c.table_name
LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace AND pn.nspname = c.table_schema
LEFT JOIN (
SELECT ku.column_name, ku.table_schema, ku.table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage ku
ON tc.constraint_name = ku.constraint_name
AND tc.table_schema = ku.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
) pk ON c.column_name = pk.column_name
AND c.table_schema = pk.table_schema
AND c.table_name = pk.table_name
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.table_schema, c.table_name, c.ordinal_position
`);
console.log(`[Schema] Found ${allColumnsResult.rows.length} columns in ${Date.now() - columnsStartTime}ms`);
// Get ALL foreign keys in one query
console.log(`[Schema] Fetching all foreign keys...`);
const fkStartTime = Date.now();
const allFkResult = await pool.query(`
SELECT
tc.table_schema,
tc.table_name,
kcu.column_name,
ccu.table_name AS references_table,
ccu.column_name AS references_column,
tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
`);
console.log(`[Schema] Found ${allFkResult.rows.length} foreign keys in ${Date.now() - fkStartTime}ms`);
// Group columns by table
const columnsByTable = new Map<string, any[]>();
for (const col of allColumnsResult.rows) {
const key = `${col.table_schema}.${col.table_name}`;
if (!columnsByTable.has(key)) {
columnsByTable.set(key, []);
}
columnsByTable.get(key)!.push(col);
}
// Group foreign keys by table
const fkByTable = new Map<string, any[]>();
for (const fk of allFkResult.rows) {
const key = `${fk.table_schema}.${fk.table_name}`;
if (!fkByTable.has(key)) {
fkByTable.set(key, []);
}
fkByTable.get(key)!.push(fk);
}
// Build tables array
console.log(`[Schema] Building schema structure...`);
const tables: TableInfo[] = tablesResult.rows.map(table => {
const key = `${table.table_schema}.${table.table_name}`;
const columns = columnsByTable.get(key) || [];
const fks = fkByTable.get(key) || [];
return {
name: table.table_name,
schema: table.table_schema,
comment: table.table_comment,
columns: columns.map(col => ({
name: col.column_name,
type: col.data_type,
nullable: col.is_nullable === 'YES',
default_value: col.column_default,
is_primary: col.is_primary,
comment: col.column_comment,
})),
foreign_keys: fks.map(fk => ({
column: fk.column_name,
references_table: fk.references_table,
references_column: fk.references_column,
constraint_name: fk.constraint_name,
})),
};
});
const totalTime = Date.now() - startTime;
console.log(`[Schema] Completed! ${tables.length} tables, ${allColumnsResult.rows.length} columns, ${allFkResult.rows.length} FKs in ${totalTime}ms`);
return {
tables,
updated_at: new Date().toISOString(),
};
}
// Get cached schema or return null
async function getCachedSchema(databaseId: string): Promise<SchemaData | null> {
const result = await mainPool.query(
'SELECT schema_data FROM database_schemas WHERE database_id = $1',
[databaseId]
);
if (result.rows.length > 0) {
return result.rows[0].schema_data as SchemaData;
}
return null;
}
// Save schema to cache
async function saveSchemaToCache(databaseId: string, schemaData: SchemaData): Promise<void> {
await mainPool.query(`
INSERT INTO database_schemas (database_id, schema_data, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (database_id)
DO UPDATE SET schema_data = $2, updated_at = NOW()
`, [databaseId, JSON.stringify(schemaData)]);
}
// GET /api/workbench/schema/:databaseId
export const getSchema = async (req: Request, res: Response) => {
try {
const { databaseId } = req.params;
// Try to get from cache first
let schema = await getCachedSchema(databaseId);
if (!schema) {
// Parse and cache if not exists
schema = await parsePostgresSchema(databaseId);
await saveSchemaToCache(databaseId, schema);
}
res.json({ success: true, data: schema });
} catch (error: any) {
console.error('Error getting schema:', error);
res.status(500).json({ success: false, error: error.message });
}
};
// POST /api/workbench/schema/:databaseId/refresh
export const refreshSchema = async (req: Request, res: Response) => {
try {
const { databaseId } = req.params;
// Parse fresh schema
const schema = await parsePostgresSchema(databaseId);
// Save to cache
await saveSchemaToCache(databaseId, schema);
res.json({ success: true, data: schema });
} catch (error: any) {
console.error('Error refreshing schema:', error);
res.status(500).json({ success: false, error: error.message });
}
};
// Generate schema for a database (called from other controllers)
export const generateSchemaForDatabase = async (databaseId: string): Promise<void> => {
try {
const schema = await parsePostgresSchema(databaseId);
await saveSchemaToCache(databaseId, schema);
console.log(`Schema generated for database ${databaseId}`);
} catch (error: any) {
console.error(`Error generating schema for database ${databaseId}:`, error.message);
// Don't throw - schema generation is not critical
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
-- Database schemas cache table
CREATE TABLE IF NOT EXISTS database_schemas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID NOT NULL REFERENCES databases(id) ON DELETE CASCADE,
schema_data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(database_id)
);
-- Index for faster lookups
CREATE INDEX IF NOT EXISTS idx_database_schemas_database_id ON database_schemas(database_id);

View File

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

View File

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

View File

@@ -6,14 +6,18 @@ async function runMigrations() {
console.log('Running migrations...');
try {
const migrationFile = fs.readFileSync(
path.join(__dirname, '001_initial_schema.sql'),
'utf-8'
);
const migrationsDir = path.join(__dirname, '../migrations');
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
console.log(` Running ${file}...`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
await mainPool.query(sql);
}
await mainPool.query(migrationFile);
console.log('✅ Migrations completed successfully');
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);

View File

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

View File

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

View File

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

View File

@@ -47,18 +47,12 @@ export class SqlExecutor {
this.validateQuery(sqlQuery);
// Log SQL query and parameters before execution
console.log('\n[SQL DB]', databaseId);
// @ts-ignore - accessing pool options for debugging
const poolOpts = pool.options;
console.log('[SQL Pool Config] host:', poolOpts?.host, 'database:', poolOpts?.database, 'user:', poolOpts?.user);
console.log('[SQL Query]', sqlQuery);
console.log('\n[SQL Query]', sqlQuery);
console.log('[SQL Params]', params);
// Execute with retry mechanism
const result = await this.retryQuery(async () => {
const queryResult = await pool.query(sqlQuery, params);
console.log('[SQL Result] rowCount:', queryResult.rowCount, 'rows:', JSON.stringify(queryResult.rows).substring(0, 500));
return queryResult;
return await pool.query(sqlQuery, params);
}, 3, 500); // 3 попытки с задержкой 500ms
const executionTime = Date.now() - startTime;

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# ============================================
# KIS API Builder - External Database
# ============================================
# Use this when you have your own PostgreSQL
#
# 1. Copy .env.example to .env
# 2. Set DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
# 3. Run: docker compose -f docker-compose.external-db.yml up -d
# ============================================
services:
app:
build:
context: .
dockerfile: Dockerfile
image: kis-api-builder:latest
container_name: kis-api-builder-app
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: ${DB_HOST:?DB_HOST is required}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-api_builder}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
JWT_SECRET: ${JWT_SECRET:-change-this-secret-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# ============================================
# KIS API Builder - Docker Compose
# ============================================
# Default setup with built-in PostgreSQL
# Just run: docker compose up -d
#
# For external database, use:
# docker compose -f docker-compose.external-db.yml up -d
# ============================================
services:
# PostgreSQL Database (built-in)
db:
image: postgres:16-alpine
container_name: kis-api-builder-db
restart: unless-stopped
environment:
POSTGRES_DB: api_builder
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/src/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d api_builder"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- kis-network
# Application (Backend + Frontend)
app:
build:
context: .
dockerfile: Dockerfile
container_name: kis-api-builder-app
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: db
DB_PORT: 5432
DB_NAME: api_builder
DB_USER: postgres
DB_PASSWORD: ${DB_PASSWORD:-postgres}
JWT_SECRET: ${JWT_SECRET:-change-this-secret-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
depends_on:
db:
condition: service_healthy
networks:
- kis-network
volumes:
postgres_data:
networks:
kis-network:
driver: bridge

View File

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

View File

@@ -14,9 +14,11 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.8",
"@hookform/resolvers": "^3.3.3",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.14.2",
"@xyflow/react": "^12.10.0",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { NavLink } from 'react-router-dom';
import { 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';
const navItems = [
{ to: '/', icon: Home, label: 'Главная' },
{ to: '/workbench', icon: Database, label: 'SQL интерфейс' },
{ to: '/schema', icon: GitBranch, label: 'Схема БД' },
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
{ to: '/folders', icon: Folder, label: 'Папки' },
{ to: '/api-keys', icon: Key, label: 'API Ключи' },

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect } from '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';
// Configure loader to use local Monaco
@@ -11,66 +11,160 @@ interface SqlEditorProps {
onChange: (value: string) => void;
databaseId?: string;
height?: string;
tabId?: string;
}
// Cache for table names with 5-minute expiration
interface TableCache {
tables: string[];
// Cache for schema with 5-minute expiration
interface SchemaCache {
schema: SchemaData;
timestamp: number;
}
const tablesCache = new Map<string, TableCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const schemaCache = new Map<string, SchemaCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const getCachedTables = async (databaseId: string): Promise<string[]> => {
const cached = tablesCache.get(databaseId);
const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
const cached = schemaCache.get(databaseId);
const now = Date.now();
// Return cached data if it exists and is not expired
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.tables;
return cached.schema;
}
// Fetch fresh data
try {
const { data } = await databasesApi.getTables(databaseId);
tablesCache.set(databaseId, {
tables: data.tables,
timestamp: now,
});
return data.tables;
const { data } = await schemaApi.getSchema(databaseId);
if (data.success && data.data) {
schemaCache.set(databaseId, {
schema: data.data,
timestamp: now,
});
return data.data;
}
return null;
} catch (error) {
console.error('Failed to fetch table names:', error);
// Return cached data if available, even if expired, as fallback
return cached?.tables || [];
console.error('Failed to fetch schema:', error);
return cached?.schema || null;
}
};
// Global flag to ensure we only register the completion provider once
// Global state for completion provider
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 monacoRef = useRef<Monaco | null>(null);
// Update current database ID when it changes
// Fetch schema when database changes
useEffect(() => {
currentDatabaseId = databaseId;
if (databaseId) {
getCachedSchema(databaseId).then(schema => {
currentSchema = schema;
});
} else {
currentSchema = null;
}
}, [databaseId]);
const handleEditorDidMount = (editor: any, monaco: Monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
// Register completion provider only once
if (completionProviderRegistered) {
return;
}
completionProviderRegistered = true;
// Configure SQL language features
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' '],
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position);
const range = {
@@ -80,42 +174,147 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
endColumn: word.endColumn,
};
let suggestions: any[] = [
// SQL Keywords
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range },
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range },
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range },
{ 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 },
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range },
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range },
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range },
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range },
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const lineContent = model.getLineContent(position.lineNumber);
const textBeforeCursor = lineContent.substring(0, position.column - 1);
let suggestions: any[] = [];
// Check if we're after a dot (table.column completion)
const dotMatch = textBeforeCursor.match(/(\w+)\.\s*$/);
if (dotMatch && currentSchema) {
const prefix = dotMatch[1].toLowerCase();
const aliases = parseTableAliases(textUntilPosition);
const tableName = aliases.get(prefix) || prefix;
const table = currentSchema.tables.find(t => t.name.toLowerCase() === tableName);
if (table) {
// Return only columns for this table
return {
suggestions: table.columns.map(col => ({
label: col.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: col.name,
detail: `${col.type}${col.is_primary ? ' (PK)' : ''}${!col.nullable ? ' NOT NULL' : ''}`,
documentation: col.comment || undefined,
range,
sortText: col.is_primary ? '0' + col.name : '1' + col.name,
})),
};
}
}
// Check if we're after ON keyword (FK suggestions)
// Match: "ON ", "ON ", "on ", after JOIN ... ON
const fullText = model.getValue();
const onMatch = textBeforeCursor.match(/\bON\s*$/i) || textUntilPosition.match(/\bJOIN\s+\w+\s+\w*\s+ON\s*$/i);
if (onMatch && currentSchema) {
const tablesInQuery = findTablesInQuery(fullText, currentSchema);
const fkSuggestions = getFkSuggestions(tablesInQuery, currentSchema, monaco, range, fullText);
// Add FK suggestions to the top, but also include other suggestions
if (fkSuggestions.length > 0) {
suggestions = [...fkSuggestions, ...suggestions];
return { suggestions };
}
}
// SQL Keywords
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)
if (currentDatabaseId) {
const tables = await getCachedTables(currentDatabaseId);
const tableSuggestions = tables.map(table => ({
label: table,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table,
detail: 'Table',
range,
}));
suggestions = [...suggestions, ...tableSuggestions];
suggestions = keywords.map(kw => ({
label: kw,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: kw + ' ',
range,
sortText: '2' + kw,
}));
// SQL Functions
const functions = [
{ name: 'COUNT', snippet: 'COUNT($0)' },
{ name: 'SUM', snippet: 'SUM($0)' },
{ name: 'AVG', snippet: 'AVG($0)' },
{ name: 'MAX', snippet: 'MAX($0)' },
{ name: 'MIN', snippet: 'MIN($0)' },
{ name: 'COALESCE', snippet: 'COALESCE($1, $0)' },
{ name: 'NULLIF', snippet: 'NULLIF($1, $0)' },
{ name: 'CAST', snippet: 'CAST($1 AS $0)' },
{ name: 'CONCAT', snippet: 'CONCAT($0)' },
{ name: 'SUBSTRING', snippet: 'SUBSTRING($1 FROM $2 FOR $0)' },
{ name: 'TRIM', snippet: 'TRIM($0)' },
{ name: 'UPPER', snippet: 'UPPER($0)' },
{ name: 'LOWER', snippet: 'LOWER($0)' },
{ name: 'LENGTH', snippet: 'LENGTH($0)' },
{ name: 'NOW', snippet: 'NOW()' },
{ name: 'CURRENT_DATE', snippet: 'CURRENT_DATE' },
{ name: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP' },
{ name: 'DATE_TRUNC', snippet: "DATE_TRUNC('$1', $0)" },
{ name: 'EXTRACT', snippet: 'EXTRACT($1 FROM $0)' },
{ name: 'TO_CHAR', snippet: "TO_CHAR($1, '$0')" },
{ name: 'TO_DATE', snippet: "TO_DATE($1, '$0')" },
{ name: 'ARRAY_AGG', snippet: 'ARRAY_AGG($0)' },
{ name: 'STRING_AGG', snippet: "STRING_AGG($1, '$0')" },
{ name: 'JSON_AGG', snippet: 'JSON_AGG($0)' },
{ name: 'ROW_NUMBER', snippet: 'ROW_NUMBER() OVER ($0)' },
{ name: 'RANK', snippet: 'RANK() OVER ($0)' },
{ name: 'DENSE_RANK', snippet: 'DENSE_RANK() OVER ($0)' },
{ name: 'LAG', snippet: 'LAG($1) OVER ($0)' },
{ name: 'LEAD', snippet: 'LEAD($1) OVER ($0)' },
];
suggestions.push(...functions.map(fn => ({
label: fn.name,
kind: monaco.languages.CompletionItemKind.Function,
insertText: fn.snippet,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: 'Function',
range,
sortText: '3' + fn.name,
})));
// Add tables and columns from schema
if (currentSchema) {
// Tables
for (const table of currentSchema.tables) {
suggestions.push({
label: table.name,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table.name,
detail: `Table (${table.columns.length} columns)`,
documentation: table.comment || undefined,
range,
sortText: '1' + table.name,
});
// All columns (with table prefix for context)
for (const col of table.columns) {
suggestions.push({
label: `${table.name}.${col.name}`,
kind: monaco.languages.CompletionItemKind.Field,
insertText: `${table.name}.${col.name}`,
detail: col.type,
documentation: col.comment || undefined,
range,
sortText: '4' + table.name + col.name,
});
}
}
}
return { suggestions };
@@ -124,10 +323,11 @@ export default function SqlEditor({ value, onChange, databaseId, height = '400px
};
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
height={height}
defaultLanguage="sql"
height="100%"
language="sql"
path={tabId}
value={value}
onChange={(value) => onChange(value || '')}
onMount={handleEditorDidMount}

View File

@@ -0,0 +1,692 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ReactFlow,
Node,
Edge,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
MarkerType,
Position,
Handle,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import Dagre from '@dagrejs/dagre';
import { Database as DatabaseIcon, Loader2, Key, Link, Search, X, Table2, MessageSquare, Columns } from 'lucide-react';
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
import { Database } from '@/types';
import { useAuthStore } from '@/stores/authStore';
// Search result type
interface SearchResult {
type: 'table' | 'column' | 'comment';
tableName: string;
tableSchema: string;
columnName?: string;
displayText: string;
secondaryText?: string;
}
// Custom node for table with per-column handles
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
const fkColumns = new Set(data.foreign_keys.map(fk => fk.column));
const pkColumns = new Set(data.columns.filter(c => c.is_primary).map(c => c.name));
return (
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden relative">
<div
className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm"
title={data.comment || undefined}
>
<div className="flex items-center gap-2">
<DatabaseIcon size={14} />
<span className="truncate">{data.name}</span>
</div>
</div>
<div className="divide-y divide-gray-100">
{data.columns.map((col) => (
<div
key={col.name}
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative"
title={col.comment || undefined}
>
{/* Handles on both sides for dynamic edge routing */}
{pkColumns.has(col.name) && (
<>
<Handle
type="target"
position={Position.Left}
id={`target-left-${col.name}`}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="target"
position={Position.Right}
id={`target-right-${col.name}`}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)}
{fkColumns.has(col.name) && (
<>
<Handle
type="source"
position={Position.Left}
id={`source-left-${col.name}`}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="source"
position={Position.Right}
id={`source-right-${col.name}`}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{col.is_primary && <Key size={11} className="text-yellow-500" />}
{fkColumns.has(col.name) && (
<Link size={11} className="text-blue-500" />
)}
</div>
<span className={`font-medium truncate ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
{col.name}
</span>
<span className="text-gray-400 truncate flex-1 text-right">{col.type}</span>
{!col.nullable && <span className="text-red-400 text-[10px] flex-shrink-0">NN</span>}
</div>
))}
{data.columns.length === 0 && (
<div className="px-3 py-2 text-xs text-gray-400 italic">No columns</div>
)}
</div>
{data.foreign_keys.length > 0 && (
<div className="bg-blue-50 px-3 py-1.5 text-xs text-blue-600 border-t border-blue-100">
{data.foreign_keys.length} FK
</div>
)}
</div>
);
}
const nodeTypes = {
table: TableNode,
};
function getNodeHeight(table: TableInfo): number {
const headerHeight = 44;
const columnHeight = 26;
const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0;
return headerHeight + (table.columns.length * columnHeight) + fkBarHeight + 8;
}
function calculateTableWeights(schema: SchemaData): Map<string, number> {
const weights = new Map<string, number>();
schema.tables.forEach(table => {
const key = `${table.schema}.${table.name}`;
let weight = table.foreign_keys.length;
schema.tables.forEach(other => {
other.foreign_keys.forEach(fk => {
if (fk.references_table === table.name) {
weight++;
}
});
});
weights.set(key, weight);
});
return weights;
}
// Build edges with optimal handle selection based on node positions
function buildEdgesWithPositions(
schema: SchemaData,
nodePositions: Map<string, { x: number; y: number; width: number }>
): Edge[] {
const edges: Edge[] = [];
schema.tables.forEach((table) => {
const sourceId = `${table.schema}.${table.name}`;
const sourcePos = nodePositions.get(sourceId);
if (!sourcePos) return;
table.foreign_keys.forEach((fk) => {
let targetTable = schema.tables.find(t =>
t.name === fk.references_table && t.schema === table.schema
);
if (!targetTable) {
targetTable = schema.tables.find(t => t.name === fk.references_table);
}
if (targetTable) {
const targetId = `${targetTable.schema}.${targetTable.name}`;
const targetPos = nodePositions.get(targetId);
if (!targetPos) return;
// Calculate center positions
const sourceCenterX = sourcePos.x + sourcePos.width / 2;
const targetCenterX = targetPos.x + targetPos.width / 2;
// Choose handles based on relative positions
const sourceOnRight = sourceCenterX < targetCenterX;
const sourceHandle = sourceOnRight
? `source-right-${fk.column}`
: `source-left-${fk.column}`;
const targetHandle = sourceOnRight
? `target-left-${fk.references_column}`
: `target-right-${fk.references_column}`;
edges.push({
id: `${fk.constraint_name}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
width: 15,
height: 15,
},
});
}
});
});
return edges;
}
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]; nodePositions: Map<string, { x: number; y: number; width: number }> } {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
const weights = calculateTableWeights(schema);
const sortedTables = [...schema.tables].sort((a, b) => {
const weightA = weights.get(`${a.schema}.${a.name}`) || 0;
const weightB = weights.get(`${b.schema}.${b.name}`) || 0;
return weightB - weightA;
});
g.setGraph({
rankdir: 'TB',
nodesep: 40,
ranksep: 80,
marginx: 30,
marginy: 30,
ranker: 'network-simplex',
});
const nodeWidth = 280;
sortedTables.forEach((table) => {
const nodeId = `${table.schema}.${table.name}`;
const height = getNodeHeight(table);
g.setNode(nodeId, { width: nodeWidth, height });
});
schema.tables.forEach((table) => {
const sourceId = `${table.schema}.${table.name}`;
table.foreign_keys.forEach((fk) => {
const targetTable = schema.tables.find(t => t.name === fk.references_table);
if (targetTable) {
const targetId = `${targetTable.schema}.${targetTable.name}`;
g.setEdge(sourceId, targetId, { weight: 2 });
}
});
});
Dagre.layout(g);
const nodePositions = new Map<string, { x: number; y: number; width: number }>();
const nodes: Node[] = schema.tables.map((table) => {
const nodeId = `${table.schema}.${table.name}`;
const nodeWithPosition = g.node(nodeId);
const x = nodeWithPosition.x - nodeWithPosition.width / 2;
const y = nodeWithPosition.y - nodeWithPosition.height / 2;
nodePositions.set(nodeId, { x, y, width: nodeWidth });
return {
id: nodeId,
type: 'table',
position: { x, y },
data: { ...table } as TableInfo & Record<string, unknown>,
};
});
const edges = buildEdgesWithPositions(schema, nodePositions);
return { nodes, edges, nodePositions };
}
export default function DatabaseSchema() {
const queryClient = useQueryClient();
const user = useAuthStore((state) => state.user);
const [selectedDbId, setSelectedDbId] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedTables, setSelectedTables] = useState<Set<string>>(new Set());
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
queryKey: ['databases'],
queryFn: async () => {
const { data } = await databasesApi.getAll();
return data.filter((db: Database) => db.type !== 'aql');
},
});
const { data: schemaResponse, isLoading: isLoadingSchema } = useQuery({
queryKey: ['schema', selectedDbId],
queryFn: async () => {
const { data } = await schemaApi.getSchema(selectedDbId);
return data;
},
enabled: !!selectedDbId,
});
const refreshMutation = useMutation({
mutationFn: () => schemaApi.refreshSchema(selectedDbId),
onSuccess: (response) => {
queryClient.setQueryData(['schema', selectedDbId], response.data);
},
});
const schema = schemaResponse?.data;
// Search across tables, columns, and comments
const searchResults = useMemo((): SearchResult[] => {
if (!schema || !searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
const results: SearchResult[] = [];
schema.tables.forEach(table => {
const tableNameLower = table.name.toLowerCase();
// Search by table name
if (tableNameLower.includes(query)) {
results.push({
type: 'table',
tableName: table.name,
tableSchema: table.schema,
displayText: table.name,
secondaryText: table.schema,
});
}
// Search by table comment
if (table.comment && table.comment.toLowerCase().includes(query)) {
results.push({
type: 'comment',
tableName: table.name,
tableSchema: table.schema,
displayText: table.name,
secondaryText: table.comment,
});
}
// Search by column name
table.columns.forEach(col => {
if (col.name.toLowerCase().includes(query)) {
results.push({
type: 'column',
tableName: table.name,
tableSchema: table.schema,
columnName: col.name,
displayText: `${table.name}.${col.name}`,
secondaryText: col.type,
});
}
// Search by column comment
if (col.comment && col.comment.toLowerCase().includes(query)) {
results.push({
type: 'comment',
tableName: table.name,
tableSchema: table.schema,
columnName: col.name,
displayText: `${table.name}.${col.name}`,
secondaryText: col.comment,
});
}
});
});
// Calculate relevance score (lower = better match)
const getRelevanceScore = (result: SearchResult): number => {
const searchTarget = result.columnName?.toLowerCase() || result.tableName.toLowerCase();
// Exact match
if (searchTarget === query) return 0;
// Starts with query
if (searchTarget.startsWith(query)) return 1;
// Query position in string (earlier = better)
const position = searchTarget.indexOf(query);
return 2 + position;
};
// Sort: by type (table -> column -> comment), then by relevance
const typeOrder: Record<SearchResult['type'], number> = {
'table': 0,
'column': 1,
'comment': 2,
};
results.sort((a, b) => {
// First by type
const typeDiff = typeOrder[a.type] - typeOrder[b.type];
if (typeDiff !== 0) return typeDiff;
// Then by relevance within same type
return getRelevanceScore(a) - getRelevanceScore(b);
});
return results.slice(0, 50);
}, [schema, searchQuery]);
// Get related tables (tables connected via FK)
const getRelatedTables = useCallback((tableNames: Set<string>, allTables: TableInfo[]): Set<string> => {
const related = new Set<string>();
tableNames.forEach(name => related.add(name));
allTables.forEach(table => {
if (tableNames.has(table.name)) {
table.foreign_keys.forEach(fk => {
related.add(fk.references_table);
});
}
table.foreign_keys.forEach(fk => {
if (tableNames.has(fk.references_table)) {
related.add(table.name);
}
});
});
return related;
}, []);
// Filter schema for selected tables + related tables, or show all if nothing selected
const filteredSchema = useMemo((): SchemaData | null => {
if (!schema) return null;
if (selectedTables.size === 0) return schema; // Show all when nothing selected
const relatedTables = getRelatedTables(selectedTables, schema.tables);
const filteredTables = schema.tables.filter(t => relatedTables.has(t.name));
return {
tables: filteredTables,
updated_at: schema.updated_at,
};
}, [schema, selectedTables, getRelatedTables]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [nodePositions, setNodePositions] = useState<Map<string, { x: number; y: number; width: number }>>(new Map());
const { nodes: layoutedNodes, edges: layoutedEdges, nodePositions: initialPositions } = useMemo(() => {
if (!filteredSchema || filteredSchema.tables.length === 0) {
return { nodes: [], edges: [], nodePositions: new Map() };
}
return getLayoutedElements(filteredSchema);
}, [filteredSchema]);
// Update positions when layout changes
useEffect(() => {
setNodePositions(initialPositions);
}, [initialPositions]);
// Reset and rebuild when filtered schema changes
useEffect(() => {
// Clear first to avoid stale edges
setNodes([]);
setEdges([]);
// Then set new layout after a tick
if (layoutedNodes.length > 0) {
requestAnimationFrame(() => {
setNodes(layoutedNodes);
// Edges will be set by nodePositions effect
});
}
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
// Recalculate edges when positions change
useEffect(() => {
if (!filteredSchema || nodePositions.size === 0) return;
const newEdges = buildEdgesWithPositions(filteredSchema, nodePositions);
setEdges(newEdges);
}, [nodePositions, filteredSchema, setEdges]);
// Recalculate edges when nodes are dragged
const handleNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
if (!filteredSchema) return;
setNodePositions(prev => {
const updated = new Map(prev);
updated.set(node.id, { x: node.position.x, y: node.position.y, width: 280 });
return updated;
});
}, [filteredSchema]);
const addTable = (tableName: string) => {
setSelectedTables(prev => new Set([...prev, tableName]));
setSearchQuery('');
};
const removeTable = (tableName: string) => {
setSelectedTables(prev => {
const next = new Set(prev);
next.delete(tableName);
return next;
});
};
const clearSelection = () => {
setSelectedTables(new Set());
};
const getSearchIcon = (type: SearchResult['type']) => {
switch (type) {
case 'table':
return <Table2 size={14} className="text-gray-600 flex-shrink-0" />;
case 'column':
return <Columns size={14} className="text-gray-600 flex-shrink-0" />;
case 'comment':
return <MessageSquare size={14} className="text-gray-600 flex-shrink-0" />;
}
};
const getSearchTypeLabel = (type: SearchResult['type']) => {
switch (type) {
case 'table': return 'таблица';
case 'column': return 'столбец';
case 'comment': return 'коммент';
}
};
if (isLoadingDatabases) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-primary-600" size={32} />
</div>
);
}
return (
<div className="flex flex-col -m-8 bg-white overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
{/* Toolbar */}
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
<div className="flex items-center gap-2">
<DatabaseIcon size={18} className="text-gray-500" />
<select
value={selectedDbId}
onChange={(e) => {
setSelectedDbId(e.target.value);
setSelectedTables(new Set());
setSearchQuery('');
// Clear canvas immediately on database change
setNodes([]);
setEdges([]);
}}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
>
<option value="">Выберите базу данных</option>
{databases.map((db: Database) => (
<option key={db.id} value={db.id}>
{db.name} ({db.type})
</option>
))}
</select>
</div>
{selectedDbId && schema && (
<div className="relative flex-1 max-w-md">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по таблицам, столбцам, комментариям..."
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-80 overflow-auto z-50">
{searchResults.map((result, idx) => (
<button
key={`${result.tableName}-${result.columnName || ''}-${result.type}-${idx}`}
onClick={() => addTable(result.tableName)}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2 border-b border-gray-50 last:border-0"
>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-gray-100 rounded text-[10px] text-gray-500 flex-shrink-0">
{getSearchIcon(result.type)}
{getSearchTypeLabel(result.type)}
</span>
<span className="font-medium truncate">{result.displayText}</span>
{result.secondaryText && (
<span className="text-gray-400 text-xs truncate ml-auto max-w-48">{result.secondaryText}</span>
)}
</button>
))}
</div>
)}
</div>
)}
{selectedDbId && user?.is_superadmin && (
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-md disabled:opacity-50 text-sm"
title="Обновить схему из базы данных"
>
{refreshMutation.isPending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 21h5v-5"/>
</svg>
)}
</button>
)}
{schema && (
<div className="text-sm text-gray-500 ml-auto">
{filteredSchema ? filteredSchema.tables.length : 0} / {schema.tables.length} таблиц
</div>
)}
</div>
{/* Selected tables chips */}
{selectedTables.size > 0 && (
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-blue-50 flex-shrink-0 flex-wrap">
<span className="text-xs text-gray-600 font-medium">Фильтр:</span>
{[...selectedTables].map(tableName => (
<span
key={tableName}
className="inline-flex items-center gap-1 px-2 py-1 bg-white border border-blue-200 text-blue-700 rounded text-xs shadow-sm"
>
<Table2 size={12} />
{tableName}
<button
onClick={() => removeTable(tableName)}
className="hover:text-blue-900 ml-1"
>
<X size={12} />
</button>
</span>
))}
<button
onClick={clearSelection}
className="text-xs text-blue-600 hover:text-blue-800 ml-2 underline"
>
Показать все
</button>
</div>
)}
{/* Schema visualization */}
<div className="flex-1">
{!selectedDbId && (
<div className="flex items-center justify-center h-full text-gray-400">
Выберите базу данных для просмотра схемы
</div>
)}
{selectedDbId && isLoadingSchema && (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-primary-600" size={32} />
</div>
)}
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.01}
maxZoom={2}
onlyRenderVisibleElements={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
>
<Background color="#e5e7eb" gap={16} />
<Controls />
<MiniMap
nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.1)"
style={{ background: '#f3f4f6' }}
pannable
zoomable
/>
</ReactFlow>
)}
{selectedDbId && !isLoadingSchema && schema && schema.tables.length === 0 && (
<div className="flex items-center justify-center h-full text-gray-400">
В базе данных нет таблиц
</div>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

2
todo Normal file
View File

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