Compare commits
10 Commits
4de1118804
...
9a08396610
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a08396610 | |||
|
|
12736f5b79 | ||
|
|
5255e0622e | ||
|
|
afd79b9c2e | ||
|
|
bd755cd19f | ||
|
|
0cca6f5d8e | ||
|
|
675d455d23 | ||
|
|
09ca6e1fd2 | ||
|
|
ced086db7f | ||
|
|
31506d2f87 |
44
.dockerignore
Normal file
44
.dockerignore
Normal 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
29
.env.example
Normal 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ coverage/
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.claude
|
.claude
|
||||||
|
.git_backup/
|
||||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ============================================
|
||||||
|
# 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 ci
|
||||||
|
|
||||||
|
# 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
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built backend
|
||||||
|
COPY --from=backend-builder /app/backend/dist ./dist
|
||||||
|
|
||||||
|
# Copy built frontend to the location expected by backend
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|
||||||
|
# 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"]
|
||||||
@@ -16,30 +16,3 @@ JWT_EXPIRES_IN=24h
|
|||||||
# API Rate Limiting
|
# API Rate Limiting
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
]'
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"migrate": "ts-node src/migrations/run.ts",
|
"migrate": "ts-node src/scripts/run.ts",
|
||||||
"seed": "ts-node src/migrations/seed.ts"
|
"seed": "ts-node src/scripts/seed.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"api",
|
"api",
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ async function runMigrations() {
|
|||||||
console.log('Running migrations...');
|
console.log('Running migrations...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const migrationFile = fs.readFileSync(
|
const migrationsDir = path.join(__dirname, '../migrations');
|
||||||
path.join(__dirname, '001_initial_schema.sql'),
|
const files = fs.readdirSync(migrationsDir)
|
||||||
'utf-8'
|
.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');
|
console.log('✅ Migrations completed successfully');
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Migration failed:', error);
|
console.error('❌ Migration failed:', error);
|
||||||
@@ -108,9 +108,9 @@ export class ScriptExecutor {
|
|||||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||||
const userFunction = new AsyncFunction('params', 'execQuery', code);
|
const userFunction = new AsyncFunction('params', 'execQuery', code);
|
||||||
|
|
||||||
// Устанавливаем таймаут
|
// Устанавливаем таймаут (10 минут)
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('Script execution timeout (30s)')), 30000);
|
setTimeout(() => reject(new Error('Script execution timeout (10min)')), 600000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Выполняем скрипт с таймаутом
|
// Выполняем скрипт с таймаутом
|
||||||
@@ -322,11 +322,11 @@ print(json.dumps(result))
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Таймаут 30 секунд
|
// Таймаут 10 минут
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
python.kill();
|
python.kill();
|
||||||
reject(new Error('Python script execution timeout (30s)'));
|
reject(new Error('Python script execution timeout (10min)'));
|
||||||
}, 30000);
|
}, 600000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,18 +47,12 @@ export class SqlExecutor {
|
|||||||
this.validateQuery(sqlQuery);
|
this.validateQuery(sqlQuery);
|
||||||
|
|
||||||
// Log SQL query and parameters before execution
|
// Log SQL query and parameters before execution
|
||||||
console.log('\n[SQL DB]', databaseId);
|
console.log('\n[SQL Query]', sqlQuery);
|
||||||
// @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('[SQL Params]', params);
|
console.log('[SQL Params]', params);
|
||||||
|
|
||||||
// Execute with retry mechanism
|
// Execute with retry mechanism
|
||||||
const result = await this.retryQuery(async () => {
|
const result = await this.retryQuery(async () => {
|
||||||
const queryResult = await pool.query(sqlQuery, params);
|
return await pool.query(sqlQuery, params);
|
||||||
console.log('[SQL Result] rowCount:', queryResult.rowCount, 'rows:', JSON.stringify(queryResult.rows).substring(0, 500));
|
|
||||||
return queryResult;
|
|
||||||
}, 3, 500); // 3 попытки с задержкой 500ms
|
}, 3, 500); // 3 попытки с задержкой 500ms
|
||||||
|
|
||||||
const executionTime = Date.now() - startTime;
|
const executionTime = Date.now() - startTime;
|
||||||
|
|||||||
29
docker-compose.external-db.yml
Normal file
29
docker-compose.external-db.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ============================================
|
||||||
|
# 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
|
||||||
|
container_name: kis-api-builder-app
|
||||||
|
restart: unless-stopped
|
||||||
|
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
63
docker-compose.yml
Normal 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
|
||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
import { endpointsApi, foldersApi } from '@/services/api';
|
import { endpointsApi, foldersApi } from '@/services/api';
|
||||||
import { Endpoint, EndpointParameter } from '@/types';
|
import { Endpoint, EndpointParameter } from '@/types';
|
||||||
import { Plus, Trash2, Play, Edit2 } from 'lucide-react';
|
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import CodeEditor from '@/components/CodeEditor';
|
import CodeEditor from '@/components/CodeEditor';
|
||||||
@@ -45,6 +45,8 @@ export default function EndpointModal({
|
|||||||
|
|
||||||
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
|
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
|
||||||
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
|
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
|
||||||
|
const [parametersExpanded, setParametersExpanded] = useState(true);
|
||||||
|
const [queriesExpanded, setQueriesExpanded] = useState(true);
|
||||||
|
|
||||||
// Определяем тип выбранной базы данных
|
// Определяем тип выбранной базы данных
|
||||||
const selectedDatabase = databases.find(db => db.id === formData.database_id);
|
const selectedDatabase = databases.find(db => db.id === formData.database_id);
|
||||||
@@ -227,17 +229,29 @@ export default function EndpointModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="border border-gray-200 rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100 rounded-t-lg"
|
||||||
Параметры запроса
|
onClick={() => setParametersExpanded(!parametersExpanded)}
|
||||||
<span className="text-xs text-gray-500 ml-2">
|
>
|
||||||
|
<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 запросе)
|
(используйте $имяПараметра в QL запросе)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
const newParam: EndpointParameter = {
|
const newParam: EndpointParameter = {
|
||||||
name: '',
|
name: '',
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
@@ -246,100 +260,103 @@ export default function EndpointModal({
|
|||||||
description: '',
|
description: '',
|
||||||
};
|
};
|
||||||
setFormData({ ...formData, parameters: [...formData.parameters, newParam] });
|
setFormData({ ...formData, parameters: [...formData.parameters, newParam] });
|
||||||
|
setParametersExpanded(true);
|
||||||
}}
|
}}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Добавить параметр
|
Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.parameters.length > 0 ? (
|
{parametersExpanded && (
|
||||||
<div className="space-y-3 mb-4 border border-gray-200 rounded-lg p-4">
|
formData.parameters.length > 0 ? (
|
||||||
{formData.parameters.map((param: any, index: number) => (
|
<div className="space-y-3 p-4">
|
||||||
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
|
{formData.parameters.map((param: any, index: number) => (
|
||||||
<div className="flex-1 grid grid-cols-5 gap-2">
|
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
|
||||||
<input
|
<div className="flex-1 grid grid-cols-5 gap-2">
|
||||||
type="text"
|
|
||||||
placeholder="Имя параметра"
|
|
||||||
value={param.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newParams = [...formData.parameters];
|
|
||||||
newParams[index].name = e.target.value;
|
|
||||||
setFormData({ ...formData, parameters: newParams });
|
|
||||||
}}
|
|
||||||
className="input text-sm"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={param.type}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newParams = [...formData.parameters];
|
|
||||||
newParams[index].type = e.target.value as 'string' | 'number' | 'boolean' | 'date';
|
|
||||||
setFormData({ ...formData, parameters: newParams });
|
|
||||||
}}
|
|
||||||
className="input text-sm"
|
|
||||||
>
|
|
||||||
<option value="string">string</option>
|
|
||||||
<option value="number">number</option>
|
|
||||||
<option value="boolean">boolean</option>
|
|
||||||
<option value="date">date</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={param.in}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newParams = [...formData.parameters];
|
|
||||||
newParams[index].in = e.target.value as 'query' | 'body' | 'path';
|
|
||||||
setFormData({ ...formData, parameters: newParams });
|
|
||||||
}}
|
|
||||||
className="input text-sm"
|
|
||||||
>
|
|
||||||
<option value="query">Query</option>
|
|
||||||
<option value="body">Body</option>
|
|
||||||
<option value="path">Path</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Описание"
|
|
||||||
value={param.description || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newParams = [...formData.parameters];
|
|
||||||
newParams[index].description = e.target.value;
|
|
||||||
setFormData({ ...formData, parameters: newParams });
|
|
||||||
}}
|
|
||||||
className="input text-sm"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-1 text-sm">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
checked={param.required}
|
placeholder="Имя параметра"
|
||||||
|
value={param.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newParams = [...formData.parameters];
|
const newParams = [...formData.parameters];
|
||||||
newParams[index].required = e.target.checked;
|
newParams[index].name = e.target.value;
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="input text-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">Обязательный</span>
|
<select
|
||||||
</label>
|
value={param.type}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newParams = [...formData.parameters];
|
||||||
|
newParams[index].type = e.target.value as 'string' | 'number' | 'boolean' | 'date';
|
||||||
|
setFormData({ ...formData, parameters: newParams });
|
||||||
|
}}
|
||||||
|
className="input text-sm"
|
||||||
|
>
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="number">number</option>
|
||||||
|
<option value="boolean">boolean</option>
|
||||||
|
<option value="date">date</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={param.in}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newParams = [...formData.parameters];
|
||||||
|
newParams[index].in = e.target.value as 'query' | 'body' | 'path';
|
||||||
|
setFormData({ ...formData, parameters: newParams });
|
||||||
|
}}
|
||||||
|
className="input text-sm"
|
||||||
|
>
|
||||||
|
<option value="query">Query</option>
|
||||||
|
<option value="body">Body</option>
|
||||||
|
<option value="path">Path</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Описание"
|
||||||
|
value={param.description || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newParams = [...formData.parameters];
|
||||||
|
newParams[index].description = e.target.value;
|
||||||
|
setFormData({ ...formData, parameters: newParams });
|
||||||
|
}}
|
||||||
|
className="input text-sm"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newParams = [...formData.parameters];
|
||||||
|
newParams[index].required = e.target.checked;
|
||||||
|
setFormData({ ...formData, parameters: newParams });
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Обязательный</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
|
||||||
|
setFormData({ ...formData, parameters: newParams });
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-red-50 rounded text-red-600"
|
||||||
|
title="Удалить параметр"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
))}
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => {
|
) : (
|
||||||
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
|
<div className="text-center py-4 bg-white rounded-b-lg">
|
||||||
setFormData({ ...formData, parameters: newParams });
|
<p className="text-sm text-gray-500">Нет параметров. Добавьте параметры для динамического запроса.</p>
|
||||||
}}
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
@@ -447,14 +464,26 @@ export default function EndpointModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="border border-gray-200 rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100 rounded-t-lg"
|
||||||
SQL Запросы для скрипта
|
onClick={() => setQueriesExpanded(!queriesExpanded)}
|
||||||
</label>
|
>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
const newQueries = [...formData.script_queries, {
|
const newQueries = [...formData.script_queries, {
|
||||||
name: '',
|
name: '',
|
||||||
sql: '',
|
sql: '',
|
||||||
@@ -466,72 +495,75 @@ export default function EndpointModal({
|
|||||||
}];
|
}];
|
||||||
setFormData({ ...formData, script_queries: newQueries });
|
setFormData({ ...formData, script_queries: newQueries });
|
||||||
setEditingQueryIndex(newQueries.length - 1);
|
setEditingQueryIndex(newQueries.length - 1);
|
||||||
|
setQueriesExpanded(true);
|
||||||
}}
|
}}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Добавить запрос
|
Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{formData.script_queries.length > 0 ? (
|
{queriesExpanded && (
|
||||||
<div className="space-y-2 mb-4">
|
formData.script_queries.length > 0 ? (
|
||||||
{formData.script_queries.map((query: any, idx: number) => (
|
<div className="space-y-2 p-4">
|
||||||
<div key={idx} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm transition-shadow">
|
{formData.script_queries.map((query: any, idx: number) => (
|
||||||
<div className="flex items-center justify-between">
|
<div key={idx} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm transition-shadow">
|
||||||
<div className="flex-1">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex-1">
|
||||||
<code className="text-sm font-semibold text-gray-900">{query.name || 'Безымянный запрос'}</code>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{query.database_id && (
|
<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 className="text-xs text-gray-500">
|
||||||
</span>
|
→ {databases.find(db => db.id === query.database_id)?.name || 'БД не выбрана'}
|
||||||
{databases.find(db => db.id === query.database_id)?.type === 'aql' && (
|
</span>
|
||||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">AQL</span>
|
{databases.find(db => db.id === query.database_id)?.type === 'aql' && (
|
||||||
)}
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">AQL</span>
|
||||||
</>
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{query.sql && (
|
||||||
|
<div className="text-xs text-gray-600 font-mono bg-gray-50 p-2 rounded mt-1 truncate">
|
||||||
|
{query.sql.substring(0, 100)}{query.sql.length > 100 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{query.aql_endpoint && (
|
||||||
|
<div className="text-xs text-gray-600 font-mono bg-purple-50 p-2 rounded mt-1">
|
||||||
|
<span className="text-purple-700 font-semibold">{query.aql_method}</span> {query.aql_endpoint}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{query.sql && (
|
<div className="flex gap-2 ml-4">
|
||||||
<div className="text-xs text-gray-600 font-mono bg-gray-50 p-2 rounded mt-1 truncate">
|
<button
|
||||||
{query.sql.substring(0, 100)}{query.sql.length > 100 ? '...' : ''}
|
type="button"
|
||||||
</div>
|
onClick={() => setEditingQueryIndex(idx)}
|
||||||
)}
|
className="p-2 hover:bg-blue-50 rounded text-blue-600"
|
||||||
{query.aql_endpoint && (
|
title="Редактировать запрос"
|
||||||
<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}
|
<Edit2 size={16} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="flex gap-2 ml-4">
|
onClick={() => {
|
||||||
<button
|
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
|
||||||
type="button"
|
setFormData({ ...formData, script_queries: newQueries });
|
||||||
onClick={() => setEditingQueryIndex(idx)}
|
}}
|
||||||
className="p-2 hover:bg-blue-50 rounded text-blue-600"
|
className="p-2 hover:bg-red-50 rounded text-red-600"
|
||||||
title="Редактировать запрос"
|
title="Удалить запрос"
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
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>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="text-center py-6 bg-white rounded-b-lg">
|
||||||
<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>
|
||||||
<p className="text-sm text-gray-500">Нет SQL запросов. Добавьте запросы для использования в скрипте.</p>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user