Переработано окно эндпоинта, добавлены элементы дебага, добавлена возможность сохранять и загружать конфигурацию эндпоинта, добавлено отображение ошибок при загрузке конфигурации. Исправлены мелкие баги.

This commit is contained in:
2026-03-01 16:00:26 +03:00
parent 7e2e0103fe
commit 6766cd81a1
15 changed files with 1677 additions and 172 deletions

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,11 +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.0.0",
"@dagrejs/dagre": "^1.1.4",
"@xyflow/react": "^12.10.0",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",

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, ChevronDown, ChevronUp } 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';
@@ -49,6 +49,8 @@ export default function EndpointModal({
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);
@@ -65,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: () => {
@@ -120,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');
},
});
@@ -689,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

@@ -11,6 +11,7 @@ interface SqlEditorProps {
onChange: (value: string) => void;
databaseId?: string;
height?: string;
tabId?: string;
}
// Cache for schema with 5-minute expiration
@@ -138,7 +139,7 @@ function getFkSuggestions(
return suggestions;
}
export default function SqlEditor({ value, onChange, databaseId, height }: SqlEditorProps) {
export default function SqlEditor({ value, onChange, databaseId, height, tabId }: SqlEditorProps) {
const editorRef = useRef<any>(null);
const monacoRef = useRef<Monaco | null>(null);
@@ -325,7 +326,8 @@ export default function SqlEditor({ value, onChange, databaseId, height }: SqlEd
<div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}>
<Editor
height="100%"
defaultLanguage="sql"
language="sql"
path={tabId}
value={value}
onChange={(value) => onChange(value || '')}
onMount={handleEditorDidMount}

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

@@ -326,6 +326,7 @@ export default function SqlInterface() {
value={activeTab?.query || ''}
onChange={(value) => updateTab(activeTab.id, { query: value })}
databaseId={activeTab?.databaseId}
tabId={activeTab?.id}
/>
</div>

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

View File

@@ -95,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;
}