Compare commits
12 Commits
75d4c0b45d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c918f34595 | |||
| c047319afe | |||
| 46396c6fc4 | |||
| dcdd6fee6d | |||
| 4ae281209f | |||
| f0cbe99cb0 | |||
| 727c6765f8 | |||
| b6b7064a41 | |||
| 49f262d8ae | |||
| 3a3c87164d | |||
| e8aab43a13 | |||
| 659ebf71ea |
@@ -44,6 +44,7 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
||||
e.path,
|
||||
e.parameters,
|
||||
e.is_public,
|
||||
e.response_schema,
|
||||
fp.full_path as folder_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folder_path fp ON e.folder_id = fp.id
|
||||
@@ -136,15 +137,17 @@ export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
||||
description: 'Успешный ответ',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
data: { type: 'array', items: { type: 'object' } },
|
||||
rowCount: { type: 'number' },
|
||||
executionTime: { type: 'number' },
|
||||
},
|
||||
},
|
||||
schema: endpoint.response_schema
|
||||
? endpoint.response_schema
|
||||
: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
data: { type: 'array', items: { type: 'object' } },
|
||||
rowCount: { type: 'number' },
|
||||
executionTime: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiKeyRequest } from '../middleware/apiKey';
|
||||
import { mainPool } from '../config/database';
|
||||
import { sqlExecutor } from '../services/SqlExecutor';
|
||||
import { scriptExecutor } from '../services/ScriptExecutor';
|
||||
import { EndpointParameter, ScriptQuery } from '../types';
|
||||
import { EndpointParameter, ScriptQuery, ScriptExecutionError } from '../types';
|
||||
|
||||
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
@@ -202,9 +202,11 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
});
|
||||
|
||||
result = {
|
||||
rows: scriptResult,
|
||||
rows: scriptResult.result,
|
||||
rowCount: 0,
|
||||
executionTime: 0,
|
||||
scriptLogs: scriptResult.logs,
|
||||
scriptQueries: scriptResult.queries,
|
||||
};
|
||||
} else {
|
||||
// Execute SQL query
|
||||
@@ -241,6 +243,8 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
...(result.scriptLogs && result.scriptLogs.length > 0 ? { logs: result.scriptLogs } : {}),
|
||||
...(result.scriptQueries && result.scriptQueries.length > 0 ? { queries: result.scriptQueries } : {}),
|
||||
}
|
||||
: result.rows;
|
||||
|
||||
@@ -267,9 +271,12 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
} catch (error: any) {
|
||||
console.error('Dynamic API execution error:', error);
|
||||
|
||||
const errorResponse = {
|
||||
const isScriptError = error instanceof ScriptExecutionError;
|
||||
const errorResponse: any = {
|
||||
success: false,
|
||||
error: error.message,
|
||||
...(isScriptError && error.logs.length > 0 ? { logs: error.logs } : {}),
|
||||
...(isScriptError && error.queries.length > 0 ? { queries: error.queries } : {}),
|
||||
};
|
||||
|
||||
// Log error if needed
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ExportedEndpoint, ExportedScriptQuery } from '../types';
|
||||
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError } from '../types';
|
||||
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||
|
||||
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
||||
@@ -88,6 +88,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body,
|
||||
aql_query_params,
|
||||
detailed_response,
|
||||
response_schema,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !method || !path) {
|
||||
@@ -122,9 +123,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, detailed_response
|
||||
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
@@ -147,6 +148,7 @@ export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body || null,
|
||||
JSON.stringify(aql_query_params || {}),
|
||||
detailed_response || false,
|
||||
response_schema ? JSON.stringify(response_schema) : null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -183,6 +185,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body,
|
||||
aql_query_params,
|
||||
detailed_response,
|
||||
response_schema,
|
||||
} = req.body;
|
||||
|
||||
const result = await mainPool.query(
|
||||
@@ -206,8 +209,9 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body = $17,
|
||||
aql_query_params = $18,
|
||||
detailed_response = $19,
|
||||
response_schema = $20,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $20
|
||||
WHERE id = $21
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
@@ -229,6 +233,7 @@ export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
aql_body || null,
|
||||
aql_query_params ? JSON.stringify(aql_query_params) : null,
|
||||
detailed_response || false,
|
||||
response_schema ? JSON.stringify(response_schema) : null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
@@ -388,13 +393,14 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Invalid execution_type' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
const isScriptError = error instanceof ScriptExecutionError;
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
detail: error.detail || undefined,
|
||||
hint: error.hint || undefined,
|
||||
logs: [],
|
||||
queries: [],
|
||||
logs: isScriptError ? error.logs : [],
|
||||
queries: isScriptError ? error.queries : [],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -487,14 +493,16 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
is_public: endpoint.is_public || false,
|
||||
enable_logging: endpoint.enable_logging || false,
|
||||
detailed_response: endpoint.detailed_response || false,
|
||||
response_schema: endpoint.response_schema || null,
|
||||
folder_name: folderName,
|
||||
};
|
||||
|
||||
const encrypted = encryptEndpointData(exportData);
|
||||
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_');
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
|
||||
res.send(encrypted);
|
||||
} catch (error) {
|
||||
console.error('Export endpoint error:', error);
|
||||
@@ -698,9 +706,9 @@ export const importEndpoint = 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, detailed_response
|
||||
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
RETURNING *`,
|
||||
[
|
||||
exportData.name,
|
||||
@@ -723,6 +731,7 @@ export const importEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
exportData.aql_body || null,
|
||||
JSON.stringify(exportData.aql_query_params || {}),
|
||||
exportData.detailed_response || false,
|
||||
exportData.response_schema ? JSON.stringify(exportData.response_schema) : null,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
296
backend/src/controllers/syncController.ts
Normal file
296
backend/src/controllers/syncController.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
/**
|
||||
* GET /api/sync/pull
|
||||
* Returns all endpoints + folders + database name mappings for CLI sync
|
||||
*/
|
||||
export const syncPull = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// Get all endpoints with folder and database names
|
||||
const endpointsResult = await mainPool.query(`
|
||||
SELECT
|
||||
e.*,
|
||||
f.name as folder_name,
|
||||
d.name as database_name,
|
||||
d.type as database_type
|
||||
FROM endpoints e
|
||||
LEFT JOIN folders f ON e.folder_id = f.id
|
||||
LEFT JOIN databases d ON e.database_id = d.id
|
||||
ORDER BY e.created_at ASC
|
||||
`);
|
||||
|
||||
// Get all folders with parent info
|
||||
const foldersResult = await mainPool.query(`
|
||||
SELECT f.*, pf.name as parent_name
|
||||
FROM folders f
|
||||
LEFT JOIN folders pf ON f.parent_id = pf.id
|
||||
ORDER BY f.created_at ASC
|
||||
`);
|
||||
|
||||
// Get database name->id mapping (active only)
|
||||
const databasesResult = await mainPool.query(
|
||||
'SELECT id, name, type FROM databases WHERE is_active = true ORDER BY name'
|
||||
);
|
||||
|
||||
// Resolve script_queries database names
|
||||
const endpoints = await Promise.all(endpointsResult.rows.map(async (ep: any) => {
|
||||
const scriptQueries = ep.script_queries || [];
|
||||
const resolvedScriptQueries = [];
|
||||
|
||||
for (const sq of scriptQueries) {
|
||||
let sqDbName: string | null = null;
|
||||
let sqDbType: string | null = null;
|
||||
if (sq.database_id) {
|
||||
const sqDb = databasesResult.rows.find((d: any) => d.id === sq.database_id);
|
||||
if (sqDb) {
|
||||
sqDbName = sqDb.name;
|
||||
sqDbType = sqDb.type;
|
||||
}
|
||||
}
|
||||
resolvedScriptQueries.push({
|
||||
...sq,
|
||||
database_name: sqDbName,
|
||||
database_type: sqDbType,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: ep.id,
|
||||
name: ep.name,
|
||||
description: ep.description,
|
||||
method: ep.method,
|
||||
path: ep.path,
|
||||
execution_type: ep.execution_type || 'sql',
|
||||
database_name: ep.database_name,
|
||||
database_type: ep.database_type,
|
||||
database_id: ep.database_id,
|
||||
sql_query: ep.sql_query,
|
||||
parameters: ep.parameters || [],
|
||||
script_language: ep.script_language,
|
||||
script_code: ep.script_code,
|
||||
script_queries: resolvedScriptQueries,
|
||||
aql_method: ep.aql_method,
|
||||
aql_endpoint: ep.aql_endpoint,
|
||||
aql_body: ep.aql_body,
|
||||
aql_query_params: ep.aql_query_params,
|
||||
is_public: ep.is_public,
|
||||
enable_logging: ep.enable_logging,
|
||||
detailed_response: ep.detailed_response,
|
||||
response_schema: ep.response_schema,
|
||||
folder_id: ep.folder_id,
|
||||
folder_name: ep.folder_name,
|
||||
created_at: ep.created_at,
|
||||
updated_at: ep.updated_at,
|
||||
};
|
||||
}));
|
||||
|
||||
const folders = foldersResult.rows.map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parent_id: f.parent_id,
|
||||
parent_name: f.parent_name,
|
||||
created_at: f.created_at,
|
||||
updated_at: f.updated_at,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
endpoints,
|
||||
folders,
|
||||
databases: databasesResult.rows,
|
||||
server_time: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sync pull error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/sync/status
|
||||
* Accepts {endpoints: [{id, updated_at}], folders: [{id, updated_at}]}
|
||||
* Returns which items changed on server since the given timestamps
|
||||
*/
|
||||
export const syncStatus = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { endpoints: clientEndpoints, folders: clientFolders } = req.body;
|
||||
|
||||
// Get current server state
|
||||
const serverEndpoints = await mainPool.query(
|
||||
'SELECT id, name, updated_at FROM endpoints'
|
||||
);
|
||||
const serverFolders = await mainPool.query(
|
||||
'SELECT id, name, updated_at FROM folders'
|
||||
);
|
||||
|
||||
const clientEndpointMap = new Map(
|
||||
(clientEndpoints || []).map((e: any) => [e.id, e.updated_at])
|
||||
);
|
||||
const clientFolderMap = new Map(
|
||||
(clientFolders || []).map((f: any) => [f.id, f.updated_at])
|
||||
);
|
||||
|
||||
// Find changed/new/deleted endpoints
|
||||
const changedEndpoints: any[] = [];
|
||||
const newEndpoints: any[] = [];
|
||||
for (const ep of serverEndpoints.rows) {
|
||||
const clientUpdatedAt = clientEndpointMap.get(ep.id);
|
||||
if (!clientUpdatedAt) {
|
||||
newEndpoints.push({ id: ep.id, name: ep.name });
|
||||
} else if (new Date(String(ep.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
|
||||
changedEndpoints.push({ id: ep.id, name: ep.name, server_updated_at: ep.updated_at });
|
||||
}
|
||||
}
|
||||
|
||||
const serverEndpointIds = new Set(serverEndpoints.rows.map((e: any) => e.id));
|
||||
const deletedEndpoints = (clientEndpoints || [])
|
||||
.filter((e: any) => !serverEndpointIds.has(e.id))
|
||||
.map((e: any) => ({ id: e.id }));
|
||||
|
||||
// Find changed/new/deleted folders
|
||||
const changedFolders: any[] = [];
|
||||
const newFolders: any[] = [];
|
||||
for (const f of serverFolders.rows) {
|
||||
const clientUpdatedAt = clientFolderMap.get(f.id);
|
||||
if (!clientUpdatedAt) {
|
||||
newFolders.push({ id: f.id, name: f.name });
|
||||
} else if (new Date(String(f.updated_at)).getTime() > new Date(String(clientUpdatedAt)).getTime()) {
|
||||
changedFolders.push({ id: f.id, name: f.name, server_updated_at: f.updated_at });
|
||||
}
|
||||
}
|
||||
|
||||
const serverFolderIds = new Set(serverFolders.rows.map((f: any) => f.id));
|
||||
const deletedFolders = (clientFolders || [])
|
||||
.filter((f: any) => !serverFolderIds.has(f.id))
|
||||
.map((f: any) => ({ id: f.id }));
|
||||
|
||||
res.json({
|
||||
endpoints: {
|
||||
changed: changedEndpoints,
|
||||
new: newEndpoints,
|
||||
deleted: deletedEndpoints,
|
||||
},
|
||||
folders: {
|
||||
changed: changedFolders,
|
||||
new: newFolders,
|
||||
deleted: deletedFolders,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sync status error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/sync/push
|
||||
* Accepts array of endpoints to create/update with conflict detection
|
||||
*/
|
||||
export const syncPush = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { endpoints: pushEndpoints, force } = req.body;
|
||||
|
||||
if (!pushEndpoints || !Array.isArray(pushEndpoints)) {
|
||||
return res.status(400).json({ error: 'endpoints array is required' });
|
||||
}
|
||||
|
||||
const conflicts: any[] = [];
|
||||
const results: any[] = [];
|
||||
|
||||
for (const ep of pushEndpoints) {
|
||||
// Check if endpoint exists
|
||||
if (ep.id) {
|
||||
const existing = await mainPool.query(
|
||||
'SELECT id, updated_at, name FROM endpoints WHERE id = $1',
|
||||
[ep.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
const serverUpdatedAt = new Date(existing.rows[0].updated_at).getTime();
|
||||
const clientBaseUpdatedAt = ep._base_updated_at
|
||||
? new Date(ep._base_updated_at).getTime()
|
||||
: 0;
|
||||
|
||||
// Conflict: server was modified after client's last sync
|
||||
if (!force && clientBaseUpdatedAt && serverUpdatedAt > clientBaseUpdatedAt) {
|
||||
conflicts.push({
|
||||
id: ep.id,
|
||||
name: existing.rows[0].name,
|
||||
server_updated_at: existing.rows[0].updated_at,
|
||||
client_base_updated_at: ep._base_updated_at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update existing endpoint
|
||||
const result = await mainPool.query(
|
||||
`UPDATE endpoints
|
||||
SET name = $1, description = $2, method = $3, path = $4,
|
||||
database_id = $5, sql_query = $6, parameters = $7,
|
||||
folder_id = $8, is_public = $9, enable_logging = $10,
|
||||
execution_type = $11, script_language = $12, script_code = $13,
|
||||
script_queries = $14, aql_method = $15, aql_endpoint = $16,
|
||||
aql_body = $17, aql_query_params = $18, detailed_response = $19,
|
||||
response_schema = $20, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $21
|
||||
RETURNING *`,
|
||||
[
|
||||
ep.name, ep.description || '', ep.method, ep.path,
|
||||
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
|
||||
ep.folder_id || null, ep.is_public || false, ep.enable_logging || false,
|
||||
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
|
||||
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
|
||||
ep.aql_endpoint || null, ep.aql_body || null,
|
||||
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
|
||||
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
|
||||
ep.id,
|
||||
]
|
||||
);
|
||||
results.push({ action: 'updated', endpoint: result.rows[0] });
|
||||
} else {
|
||||
// Endpoint with this ID doesn't exist on server — create it
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoints (
|
||||
id, name, description, method, path, database_id, sql_query, parameters,
|
||||
folder_id, user_id, is_public, enable_logging,
|
||||
execution_type, script_language, script_code, script_queries,
|
||||
aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response, response_schema
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||
RETURNING *`,
|
||||
[
|
||||
ep.id, ep.name, ep.description || '', ep.method, ep.path,
|
||||
ep.database_id || null, ep.sql_query || '', JSON.stringify(ep.parameters || []),
|
||||
ep.folder_id || null, req.user!.id, ep.is_public || false, ep.enable_logging || false,
|
||||
ep.execution_type || 'sql', ep.script_language || null, ep.script_code || null,
|
||||
JSON.stringify(ep.script_queries || []), ep.aql_method || null,
|
||||
ep.aql_endpoint || null, ep.aql_body || null,
|
||||
JSON.stringify(ep.aql_query_params || {}), ep.detailed_response || false,
|
||||
ep.response_schema ? JSON.stringify(ep.response_schema) : null,
|
||||
]
|
||||
);
|
||||
results.push({ action: 'created', endpoint: result.rows[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return res.status(409).json({
|
||||
error: 'Conflicts detected',
|
||||
conflicts,
|
||||
applied: results,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Sync push error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Endpoint path already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
7
backend/src/migrations/009_add_response_schema.sql
Normal file
7
backend/src/migrations/009_add_response_schema.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add response_schema to endpoints for Swagger documentation
|
||||
-- Stores an OpenAPI-compatible JSON schema describing the 200 response
|
||||
|
||||
ALTER TABLE endpoints
|
||||
ADD COLUMN IF NOT EXISTS response_schema JSONB DEFAULT NULL;
|
||||
|
||||
COMMENT ON COLUMN endpoints.response_schema IS 'Optional OpenAPI JSON schema for the 200 response, displayed in Swagger documentation.';
|
||||
13
backend/src/routes/sync.ts
Normal file
13
backend/src/routes/sync.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { syncPull, syncPush, syncStatus } from '../controllers/syncController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/pull', syncPull);
|
||||
router.post('/status', syncStatus);
|
||||
router.post('/push', syncPush);
|
||||
|
||||
export default router;
|
||||
@@ -20,6 +20,7 @@ import userRoutes from './routes/users';
|
||||
import logsRoutes from './routes/logs';
|
||||
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||
import dynamicRoutes from './routes/dynamic';
|
||||
import syncRoutes from './routes/sync';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
@@ -34,8 +35,8 @@ app.set('trust proxy', true);
|
||||
// crossOriginEmbedderPolicy: false,
|
||||
// }));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// Rate limiting - DISABLED
|
||||
// const limiter = rateLimit({
|
||||
@@ -93,6 +94,7 @@ app.use('/api/db-management', databaseManagementRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
app.use('/api/workbench', sqlInterfaceRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
|
||||
// Dynamic API routes (user-created endpoints)
|
||||
app.use('/api/v1', dynamicRoutes);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vm from 'vm';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
interface IsolatedScriptContext {
|
||||
@@ -228,7 +228,9 @@ export class IsolatedScriptExecutor {
|
||||
}
|
||||
timerIds.clear();
|
||||
|
||||
throw new Error(`JavaScript execution error: ${error.message}`);
|
||||
// Preserve captured logs and queries in the error
|
||||
logs.push({ type: 'error', message: error.message, timestamp: Date.now() });
|
||||
throw new ScriptExecutionError(`JavaScript execution error: ${error.message}`, logs, queries);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { aqlExecutor } from './AqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types';
|
||||
import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult, ScriptExecutionError } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
import { isolatedScriptExecutor } from './IsolatedScriptExecutor';
|
||||
|
||||
@@ -258,7 +258,8 @@ print(json.dumps(result))
|
||||
|
||||
python.on('close', (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
reject(new Error(`Python execution error: ${errorOutput}`));
|
||||
logs.push({ type: 'error', message: errorOutput, timestamp: Date.now() });
|
||||
reject(new ScriptExecutionError(`Python execution error: ${errorOutput}`, logs, queries));
|
||||
} else {
|
||||
try {
|
||||
// Последняя строка вывода - результат, остальные - логи
|
||||
@@ -276,7 +277,7 @@ print(json.dumps(result))
|
||||
const result = JSON.parse(resultLine);
|
||||
resolve({ result, logs, queries });
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse Python output: ${output}`));
|
||||
reject(new ScriptExecutionError(`Failed to parse Python output: ${output}`, logs, queries));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -284,7 +285,7 @@ print(json.dumps(result))
|
||||
// Таймаут 10 минут
|
||||
setTimeout(() => {
|
||||
python.kill();
|
||||
reject(new Error('Python script execution timeout (10min)'));
|
||||
reject(new ScriptExecutionError('Python script execution timeout (10min)', logs, queries));
|
||||
}, 600000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface Endpoint {
|
||||
aql_endpoint?: string;
|
||||
aql_body?: string;
|
||||
aql_query_params?: Record<string, string>;
|
||||
// Response schema for Swagger docs
|
||||
response_schema?: object | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -81,6 +83,7 @@ export interface EndpointParameter {
|
||||
required: boolean;
|
||||
default_value?: any;
|
||||
description?: string;
|
||||
example_value?: string;
|
||||
in: 'query' | 'body' | 'path';
|
||||
}
|
||||
|
||||
@@ -122,6 +125,18 @@ export interface IsolatedExecutionResult {
|
||||
queries: QueryExecution[];
|
||||
}
|
||||
|
||||
export class ScriptExecutionError extends Error {
|
||||
logs: LogEntry[];
|
||||
queries: QueryExecution[];
|
||||
|
||||
constructor(message: string, logs: LogEntry[], queries: QueryExecution[]) {
|
||||
super(message);
|
||||
this.name = 'ScriptExecutionError';
|
||||
this.logs = logs;
|
||||
this.queries = queries;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SwaggerEndpoint {
|
||||
tags: string[];
|
||||
summary: string;
|
||||
@@ -163,5 +178,7 @@ export interface ExportedEndpoint {
|
||||
is_public: boolean;
|
||||
enable_logging: boolean;
|
||||
detailed_response: boolean;
|
||||
response_schema: object | null;
|
||||
folder_name: string | null;
|
||||
}
|
||||
|
||||
|
||||
1
cli
Submodule
1
cli
Submodule
Submodule cli added at 5d6e7bbe56
@@ -9,6 +9,7 @@ import Sidebar from '@/components/Sidebar';
|
||||
import Login from '@/pages/Login';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Endpoints from '@/pages/Endpoints';
|
||||
import EndpointEditor from '@/pages/EndpointEditor';
|
||||
import ApiKeys from '@/pages/ApiKeys';
|
||||
import Folders from '@/pages/Folders';
|
||||
import Logs from '@/pages/Logs';
|
||||
@@ -97,6 +98,26 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/endpoints/new"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout>
|
||||
<EndpointEditor />
|
||||
</Layout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/endpoints/:id"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout>
|
||||
<EndpointEditor />
|
||||
</Layout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/endpoints"
|
||||
element={
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1405
frontend/src/pages/EndpointEditor.tsx
Normal file
1405
frontend/src/pages/EndpointEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,17 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Endpoint, ImportPreviewResponse } from '@/types';
|
||||
import { endpointsApi } from '@/services/api';
|
||||
import { 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() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
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);
|
||||
@@ -35,11 +34,6 @@ export default function Endpoints() {
|
||||
queryFn: () => endpointsApi.getAll(search).then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
@@ -61,16 +55,6 @@ export default function Endpoints() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingEndpoint(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleExport = async (endpointId: string, endpointName: string) => {
|
||||
try {
|
||||
const response = await endpointsApi.exportEndpoint(endpointId);
|
||||
@@ -129,7 +113,7 @@ export default function Endpoints() {
|
||||
<Upload size={20} />
|
||||
Импорт
|
||||
</button>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<button onClick={() => navigate('/endpoints/new')} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
@@ -203,7 +187,7 @@ export default function Endpoints() {
|
||||
<Download size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(endpoint)}
|
||||
onClick={() => navigate(`/endpoints/${endpoint.id}`)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
@@ -229,14 +213,6 @@ export default function Endpoints() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && importPreview && importFile && (
|
||||
<ImportEndpointModal
|
||||
preview={importPreview}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { foldersApi, endpointsApi, databasesApi } from '@/services/api';
|
||||
import { foldersApi, endpointsApi } from '@/services/api';
|
||||
import { Folder, Endpoint } from '@/types';
|
||||
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Folders() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [showFolderModal, setShowFolderModal] = useState(false);
|
||||
const [showEndpointModal, setShowEndpointModal] = useState(false);
|
||||
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -38,11 +38,6 @@ export default function Folders() {
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: (id: string) => foldersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
@@ -74,14 +69,11 @@ export default function Folders() {
|
||||
};
|
||||
|
||||
const handleCreateEndpoint = (folderId?: string) => {
|
||||
setSelectedFolderId(folderId || null);
|
||||
setEditingEndpoint(null);
|
||||
setShowEndpointModal(true);
|
||||
navigate(folderId ? `/endpoints/new?folder=${folderId}` : '/endpoints/new');
|
||||
};
|
||||
|
||||
const handleEditEndpoint = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowEndpointModal(true);
|
||||
navigate(`/endpoints/${endpoint.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (id: string) => {
|
||||
@@ -120,6 +112,16 @@ export default function Folders() {
|
||||
});
|
||||
};
|
||||
|
||||
const getAllFolderIds = (nodes: any[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
const collect = (ns: any[]) => ns.forEach(n => { ids.push(n.id); if (n.children?.length) collect(n.children); });
|
||||
collect(nodes);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const expandAll = () => setExpandedFolders(new Set(getAllFolderIds(tree)));
|
||||
const collapseAll = () => setExpandedFolders(new Set());
|
||||
|
||||
// Построение дерева папок
|
||||
const buildTree = () => {
|
||||
if (!folders || !endpoints) return [];
|
||||
@@ -152,6 +154,33 @@ export default function Folders() {
|
||||
|
||||
const tree = buildTree();
|
||||
|
||||
// Поиск
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchQuery.trim()) return null;
|
||||
const q = searchQuery.toLowerCase();
|
||||
|
||||
// Строим карту путей папок
|
||||
const folderPathMap = new Map<string, string>();
|
||||
const buildPaths = (nodes: any[], prefix = '') => {
|
||||
nodes.forEach(n => {
|
||||
const p = prefix ? `${prefix} / ${n.name}` : n.name;
|
||||
folderPathMap.set(n.id, p);
|
||||
if (n.children?.length) buildPaths(n.children, p);
|
||||
});
|
||||
};
|
||||
buildPaths(tree);
|
||||
|
||||
const matchedEndpoints = (endpoints || []).filter(e =>
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
e.path.toLowerCase().includes(q) ||
|
||||
e.method.toLowerCase().includes(q)
|
||||
).map(e => ({ ...e, folderPath: e.folder_id ? (folderPathMap.get(e.folder_id) || '') : '' }));
|
||||
|
||||
const matchedFolders = (folders || []).filter(f => f.name.toLowerCase().includes(q));
|
||||
|
||||
return { endpoints: matchedEndpoints, folders: matchedFolders, folderPathMap };
|
||||
}, [searchQuery, endpoints, folders, tree]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -178,40 +207,126 @@ export default function Folders() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6">
|
||||
{/* Toolbar: search + expand/collapse */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени, пути, методу..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input w-full pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
|
||||
title="Раскрыть все"
|
||||
>
|
||||
<ChevronsUpDown size={16} />
|
||||
Раскрыть все
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
|
||||
title="Свернуть все"
|
||||
>
|
||||
<ChevronsDownUp size={16} />
|
||||
Свернуть все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* Корневые папки */}
|
||||
{tree.map(folder => (
|
||||
<TreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={toggleFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onCreateSubfolder={handleCreateFolder}
|
||||
onCreateEndpoint={handleCreateEndpoint}
|
||||
onEditEndpoint={handleEditEndpoint}
|
||||
onDeleteEndpoint={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
{searchResults ? (
|
||||
/* Результаты поиска */
|
||||
<>
|
||||
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Ничего не найдено по запросу «{searchQuery}»</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchResults.folders.map((folder: any) => (
|
||||
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
|
||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||
<span className="text-xs text-gray-400">{searchResults.folderPathMap.get(folder.id)}</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button onClick={() => handleEditFolder(folder)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteFolder(folder.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{searchResults.endpoints.map((endpoint: any) => (
|
||||
<div key={endpoint.id} className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 border border-transparent">
|
||||
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-900">{endpoint.name}</span>
|
||||
{endpoint.folderPath && (
|
||||
<span className="ml-2 text-xs text-gray-400">{endpoint.folderPath}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{endpoint.method}</span>
|
||||
<code className="text-xs text-gray-600 hidden sm:block">{endpoint.path}</code>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleEditEndpoint(endpoint)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteEndpoint(endpoint.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Обычное дерево */
|
||||
<>
|
||||
{tree.map(folder => (
|
||||
<TreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={toggleFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onCreateSubfolder={handleCreateFolder}
|
||||
onCreateEndpoint={handleCreateEndpoint}
|
||||
onEditEndpoint={handleEditEndpoint}
|
||||
onDeleteEndpoint={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Корневые эндпоинты (без папки) */}
|
||||
{rootEndpoints.map(endpoint => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
onEdit={handleEditEndpoint}
|
||||
onDelete={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
{rootEndpoints.map(endpoint => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
onEdit={handleEditEndpoint}
|
||||
onDelete={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Нет папок и эндпоинтов.</p>
|
||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||
</div>
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Нет папок и эндпоинтов.</p>
|
||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,15 +341,6 @@ export default function Folders() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEndpointModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
folderId={selectedFolderId}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowEndpointModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface EndpointParameter {
|
||||
required: boolean;
|
||||
default_value?: any;
|
||||
description?: string;
|
||||
example_value?: string;
|
||||
in: 'query' | 'body' | 'path';
|
||||
}
|
||||
|
||||
@@ -80,6 +81,7 @@ export interface Endpoint {
|
||||
aql_query_params?: Record<string, string>;
|
||||
// Response format
|
||||
detailed_response?: boolean;
|
||||
response_schema?: object | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user