new file: .gitignore new file: backend/.env.example new file: backend/.gitignore new file: backend/ecosystem.config.js new file: backend/nodemon.json new file: backend/package-lock.json new file: backend/package.json new file: backend/src/config/database.ts new file: backend/src/config/dynamicSwagger.ts new file: backend/src/config/environment.ts new file: backend/src/config/swagger.ts new file: backend/src/controllers/apiKeyController.ts new file: backend/src/controllers/authController.ts new file: backend/src/controllers/databaseController.ts new file: backend/src/controllers/databaseManagementController.ts new file: backend/src/controllers/dynamicApiController.ts new file: backend/src/controllers/endpointController.ts new file: backend/src/controllers/folderController.ts new file: backend/src/controllers/logsController.ts new file: backend/src/controllers/userController.ts new file: backend/src/middleware/apiKey.ts new file: backend/src/middleware/auth.ts new file: backend/src/middleware/logging.ts new file: backend/src/migrations/001_initial_schema.sql new file: backend/src/migrations/002_add_logging.sql new file: backend/src/migrations/003_add_scripting.sql new file: backend/src/migrations/004_add_superadmin.sql new file: backend/src/migrations/run.ts new file: backend/src/migrations/seed.ts new file: backend/src/routes/apiKeys.ts new file: backend/src/routes/auth.ts new file: backend/src/routes/databaseManagement.ts new file: backend/src/routes/databases.ts new file: backend/src/routes/dynamic.ts new file: backend/src/routes/endpoints.ts new file: backend/src/routes/folders.ts new file: backend/src/routes/logs.ts new file: backend/src/routes/users.ts new file: backend/src/server.ts new file: backend/src/services/DatabasePoolManager.ts new file: backend/src/services/ScriptExecutor.ts new file: backend/src/services/SqlExecutor.ts new file: backend/src/types/index.ts new file: backend/tsconfig.json new file: frontend/.gitignore new file: frontend/index.html new file: frontend/nginx.conf new file: frontend/package-lock.json new file: frontend/package.json new file: frontend/postcss.config.js new file: frontend/src/App.tsx new file: frontend/src/components/CodeEditor.tsx
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { logsApi, endpointsApi, apiKeysApi } from '@/services/api';
|
||
import { Trash2, Eye, Filter, X } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import { format } from 'date-fns';
|
||
import Dialog from '@/components/Dialog';
|
||
|
||
export default function Logs() {
|
||
const queryClient = useQueryClient();
|
||
const [selectedLog, setSelectedLog] = useState<any | null>(null);
|
||
const [filters, setFilters] = useState({
|
||
endpoint_id: '',
|
||
api_key_id: '',
|
||
});
|
||
const [dialog, setDialog] = useState<{
|
||
isOpen: boolean;
|
||
title: string;
|
||
message: string;
|
||
type: 'alert' | 'confirm';
|
||
onConfirm?: () => void;
|
||
}>({
|
||
isOpen: false,
|
||
title: '',
|
||
message: '',
|
||
type: 'alert',
|
||
});
|
||
|
||
const { data: logs, isLoading } = useQuery({
|
||
queryKey: ['logs', filters],
|
||
queryFn: () => logsApi.getAll(filters).then(res => res.data),
|
||
});
|
||
|
||
const { data: endpoints } = useQuery({
|
||
queryKey: ['endpoints'],
|
||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||
});
|
||
|
||
const { data: apiKeys } = useQuery({
|
||
queryKey: ['apiKeys'],
|
||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => logsApi.delete(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||
toast.success('Лог удален');
|
||
},
|
||
onError: () => toast.error('Ошибка удаления лога'),
|
||
});
|
||
|
||
const clearMutation = useMutation({
|
||
mutationFn: (data: any) => logsApi.clear(data),
|
||
onSuccess: (response) => {
|
||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||
toast.success(`Удалено ${response.data.deleted} лог(ов)`);
|
||
},
|
||
onError: () => toast.error('Ошибка очистки логов'),
|
||
});
|
||
|
||
const handleClearAll = () => {
|
||
setDialog({
|
||
isOpen: true,
|
||
title: 'Подтверждение',
|
||
message: 'Вы уверены, что хотите очистить все логи?',
|
||
type: 'confirm',
|
||
onConfirm: () => {
|
||
clearMutation.mutate({});
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleClearFiltered = () => {
|
||
setDialog({
|
||
isOpen: true,
|
||
title: 'Подтверждение',
|
||
message: 'Вы уверены, что хотите очистить логи с текущими фильтрами?',
|
||
type: 'confirm',
|
||
onConfirm: () => {
|
||
clearMutation.mutate(filters);
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Логи запросов</h1>
|
||
<p className="text-gray-600">История запросов к API эндпоинтам</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{Object.values(filters).some(v => v) && (
|
||
<button onClick={handleClearFiltered} className="btn btn-secondary">
|
||
Очистить отфильтрованные
|
||
</button>
|
||
)}
|
||
<button onClick={handleClearAll} className="btn btn-danger">
|
||
Очистить все логи
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card p-4 mb-6">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<Filter size={20} className="text-gray-400" />
|
||
<h3 className="font-semibold">Фильтры</h3>
|
||
{Object.values(filters).some(v => v) && (
|
||
<button
|
||
onClick={() => setFilters({ endpoint_id: '', api_key_id: '' })}
|
||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||
>
|
||
<X size={16} />
|
||
Сбросить
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Эндпоинт</label>
|
||
<select
|
||
value={filters.endpoint_id}
|
||
onChange={(e) => setFilters({ ...filters, endpoint_id: e.target.value })}
|
||
className="input w-full"
|
||
>
|
||
<option value="">Все эндпоинты</option>
|
||
{endpoints?.map((ep) => (
|
||
<option key={ep.id} value={ep.id}>{ep.name} ({ep.method} {ep.path})</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">API Ключ</label>
|
||
<select
|
||
value={filters.api_key_id}
|
||
onChange={(e) => setFilters({ ...filters, api_key_id: e.target.value })}
|
||
className="input w-full"
|
||
>
|
||
<option value="">Все ключи</option>
|
||
{apiKeys?.map((key) => (
|
||
<option key={key.id} value={key.id}>{key.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="text-center py-12">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{logs?.map((log: any) => (
|
||
<div key={log.id} className="card p-4 hover:shadow-md transition-shadow">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<span className={`px-2 py-1 text-xs font-semibold rounded ${
|
||
log.response_status >= 200 && log.response_status < 300
|
||
? 'bg-green-100 text-green-700'
|
||
: log.response_status >= 400
|
||
? 'bg-red-100 text-red-700'
|
||
: 'bg-yellow-100 text-yellow-700'
|
||
}`}>
|
||
{log.response_status}
|
||
</span>
|
||
<span className="font-semibold text-gray-900">{log.method}</span>
|
||
<span className="text-gray-600">{log.path}</span>
|
||
{log.endpoint_name && (
|
||
<span className="text-sm text-gray-500">→ {log.endpoint_name}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||
<span>⏱ {log.execution_time}мс</span>
|
||
<span>📅 {format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</span>
|
||
{log.api_key_name && (
|
||
<span>🔑 {log.api_key_name}</span>
|
||
)}
|
||
{log.ip_address && log.ip_address !== 'unknown' && (
|
||
<span>📍 {log.ip_address}</span>
|
||
)}
|
||
</div>
|
||
{log.error_message && (
|
||
<div className="mt-2 p-2 bg-red-50 rounded text-sm text-red-700">
|
||
❌ {log.error_message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setSelectedLog(log)}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
title="Детали"
|
||
>
|
||
<Eye size={18} className="text-gray-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setDialog({
|
||
isOpen: true,
|
||
title: 'Подтверждение',
|
||
message: 'Удалить этот лог?',
|
||
type: 'confirm',
|
||
onConfirm: () => {
|
||
deleteMutation.mutate(log.id);
|
||
},
|
||
});
|
||
}}
|
||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 size={18} className="text-red-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{logs?.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Логи не найдены</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedLog && (
|
||
<LogDetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||
)}
|
||
|
||
<Dialog
|
||
isOpen={dialog.isOpen}
|
||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||
title={dialog.title}
|
||
message={dialog.message}
|
||
type={dialog.type}
|
||
onConfirm={dialog.onConfirm}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
||
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-4xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||
<h2 className="text-2xl font-bold text-gray-900">Детали лога</h2>
|
||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded">
|
||
<X size={24} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Метод</label>
|
||
<p className="text-gray-900">{log.method}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Путь</label>
|
||
<p className="text-gray-900">{log.path}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Статус</label>
|
||
<p className="text-gray-900">{log.response_status}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Время выполнения</label>
|
||
<p className="text-gray-900">{log.execution_time}мс</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Эндпоинт</label>
|
||
<p className="text-gray-900">{log.endpoint_name || 'N/A'}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">API Ключ</label>
|
||
<p className="text-gray-900">{log.api_key_name || 'Без ключа'}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">IP адрес</label>
|
||
<p className="text-gray-900">{log.ip_address}</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">Дата/время</label>
|
||
<p className="text-gray-900">{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{log.request_params && Object.keys(log.request_params).length > 0 && (
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700 mb-2 block">Параметры запроса</label>
|
||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||
{JSON.stringify(log.request_params, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{log.request_body && Object.keys(log.request_body).length > 0 && (
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700 mb-2 block">Тело запроса</label>
|
||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||
{JSON.stringify(log.request_body, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700 mb-2 block">Ответ</label>
|
||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||
{JSON.stringify(log.response_data, null, 2)}
|
||
</pre>
|
||
</div>
|
||
|
||
{log.error_message && (
|
||
<div>
|
||
<label className="text-sm font-medium text-red-700 mb-2 block">Ошибка</label>
|
||
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
||
{log.error_message}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{log.user_agent && log.user_agent !== 'unknown' && (
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700">User Agent</label>
|
||
<p className="text-sm text-gray-600">{log.user_agent}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-gray-200">
|
||
<button onClick={onClose} className="btn btn-secondary">
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|