modified: backend/.env.example
new file: backend/src/controllers/sqlInterfaceController.ts new file: backend/src/routes/sqlInterface.ts modified: backend/src/server.ts modified: docker-compose.external-db.yml modified: frontend/src/App.tsx modified: frontend/src/components/Sidebar.tsx new file: frontend/src/pages/SqlInterface.tsx modified: frontend/src/services/api.ts
This commit is contained in:
@@ -15,4 +15,4 @@ 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
|
||||||
|
|||||||
46
backend/src/controllers/sqlInterfaceController.ts
Normal file
46
backend/src/controllers/sqlInterfaceController.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||||
|
|
||||||
|
export const executeQuery = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { database_id, query } = req.body;
|
||||||
|
|
||||||
|
if (!database_id) {
|
||||||
|
return res.status(400).json({ error: 'Database ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string' || query.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Query is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = databasePoolManager.getPool(database_id);
|
||||||
|
if (!pool) {
|
||||||
|
return res.status(404).json({ error: 'Database not found or not active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await pool.query(query);
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
fields: result.fields?.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
dataTypeID: f.dataTypeID,
|
||||||
|
})),
|
||||||
|
executionTime,
|
||||||
|
command: result.command,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('SQL execution error:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Query execution failed',
|
||||||
|
position: error.position,
|
||||||
|
detail: error.detail,
|
||||||
|
hint: error.hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
11
backend/src/routes/sqlInterface.ts
Normal file
11
backend/src/routes/sqlInterface.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import { executeQuery } from '../controllers/sqlInterfaceController';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.post('/execute', executeQuery);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -18,6 +18,7 @@ import databaseRoutes from './routes/databases';
|
|||||||
import databaseManagementRoutes from './routes/databaseManagement';
|
import databaseManagementRoutes from './routes/databaseManagement';
|
||||||
import userRoutes from './routes/users';
|
import userRoutes from './routes/users';
|
||||||
import logsRoutes from './routes/logs';
|
import logsRoutes from './routes/logs';
|
||||||
|
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||||
import dynamicRoutes from './routes/dynamic';
|
import dynamicRoutes from './routes/dynamic';
|
||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
@@ -87,6 +88,7 @@ app.use('/api/databases', databaseRoutes);
|
|||||||
app.use('/api/db-management', databaseManagementRoutes);
|
app.use('/api/db-management', databaseManagementRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/logs', logsRoutes);
|
app.use('/api/logs', logsRoutes);
|
||||||
|
app.use('/api/sql', sqlInterfaceRoutes);
|
||||||
|
|
||||||
// Dynamic API routes (user-created endpoints)
|
// Dynamic API routes (user-created endpoints)
|
||||||
app.use('/api/v1', dynamicRoutes);
|
app.use('/api/v1', dynamicRoutes);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: kis-api-builder-app
|
container_name: kis-api-builder-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ApiKeys from '@/pages/ApiKeys';
|
|||||||
import Folders from '@/pages/Folders';
|
import Folders from '@/pages/Folders';
|
||||||
import Logs from '@/pages/Logs';
|
import Logs from '@/pages/Logs';
|
||||||
import Settings from '@/pages/Settings';
|
import Settings from '@/pages/Settings';
|
||||||
|
import SqlInterface from '@/pages/SqlInterface';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -75,6 +76,16 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/sql"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout>
|
||||||
|
<SqlInterface />
|
||||||
|
</Layout>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/endpoints"
|
path="/endpoints"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink } from 'lucide-react';
|
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database } from 'lucide-react';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Home, label: 'Главная' },
|
{ to: '/', icon: Home, label: 'Главная' },
|
||||||
|
{ to: '/sql', icon: Database, label: 'SQL интерфейс' },
|
||||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||||
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
||||||
|
|||||||
432
frontend/src/pages/SqlInterface.tsx
Normal file
432
frontend/src/pages/SqlInterface.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api';
|
||||||
|
import { Database } from '@/types';
|
||||||
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
|
|
||||||
|
interface SqlTab {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
databaseId: string;
|
||||||
|
result: SqlQueryResult | null;
|
||||||
|
isExecuting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SqlInterfaceState {
|
||||||
|
tabs: SqlTab[];
|
||||||
|
activeTabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sql_interface_state';
|
||||||
|
|
||||||
|
const generateTabId = () => `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const createNewTab = (databaseId: string = '', name?: string): SqlTab => ({
|
||||||
|
id: generateTabId(),
|
||||||
|
name: name || 'Новый запрос',
|
||||||
|
query: '',
|
||||||
|
databaseId,
|
||||||
|
result: null,
|
||||||
|
isExecuting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadState = (): SqlInterfaceState | null => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load SQL interface state:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveState = (state: SqlInterfaceState) => {
|
||||||
|
try {
|
||||||
|
// Don't save isExecuting state and large results
|
||||||
|
const stateToSave = {
|
||||||
|
...state,
|
||||||
|
tabs: state.tabs.map(tab => ({
|
||||||
|
...tab,
|
||||||
|
isExecuting: false,
|
||||||
|
result: tab.result ? {
|
||||||
|
...tab.result,
|
||||||
|
data: tab.result.data?.slice(0, 100), // Limit saved results
|
||||||
|
} : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save SQL interface state:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SqlInterface() {
|
||||||
|
const { data: databases = [], isLoading: isLoadingDatabases } = useQuery({
|
||||||
|
queryKey: ['databases'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await databasesApi.getAll();
|
||||||
|
// Filter only SQL databases (not AQL)
|
||||||
|
return data.filter((db: Database) => db.type !== 'aql');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [state, setState] = useState<SqlInterfaceState>(() => {
|
||||||
|
const saved = loadState();
|
||||||
|
if (saved && saved.tabs.length > 0) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
const initialTab = createNewTab();
|
||||||
|
return {
|
||||||
|
tabs: [initialTab],
|
||||||
|
activeTabId: initialTab.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save state to localStorage on changes
|
||||||
|
useEffect(() => {
|
||||||
|
saveState(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Set default database for new tabs when databases load
|
||||||
|
useEffect(() => {
|
||||||
|
if (databases.length > 0) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tabs: prev.tabs.map(tab =>
|
||||||
|
tab.databaseId === '' ? { ...tab, databaseId: databases[0].id } : tab
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [databases]);
|
||||||
|
|
||||||
|
const activeTab = state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
|
||||||
|
|
||||||
|
const updateTab = useCallback((tabId: string, updates: Partial<SqlTab>) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tabs: prev.tabs.map(tab =>
|
||||||
|
tab.id === tabId ? { ...tab, ...updates } : tab
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTab = useCallback(() => {
|
||||||
|
const defaultDbId = databases.length > 0 ? databases[0].id : '';
|
||||||
|
const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`);
|
||||||
|
setState(prev => ({
|
||||||
|
tabs: [...prev.tabs, newTab],
|
||||||
|
activeTabId: newTab.id,
|
||||||
|
}));
|
||||||
|
}, [databases, state.tabs.length]);
|
||||||
|
|
||||||
|
const closeTab = useCallback((tabId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setState(prev => {
|
||||||
|
if (prev.tabs.length === 1) {
|
||||||
|
// Don't close the last tab, just reset it
|
||||||
|
const defaultDbId = databases.length > 0 ? databases[0].id : '';
|
||||||
|
const newTab = createNewTab(defaultDbId);
|
||||||
|
return {
|
||||||
|
tabs: [newTab],
|
||||||
|
activeTabId: newTab.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTabs = prev.tabs.filter(t => t.id !== tabId);
|
||||||
|
const newActiveId = prev.activeTabId === tabId
|
||||||
|
? newTabs[Math.max(0, prev.tabs.findIndex(t => t.id === tabId) - 1)].id
|
||||||
|
: prev.activeTabId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs: newTabs,
|
||||||
|
activeTabId: newActiveId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [databases]);
|
||||||
|
|
||||||
|
const setActiveTab = useCallback((tabId: string) => {
|
||||||
|
setState(prev => ({ ...prev, activeTabId: tabId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeQuery = useCallback(async () => {
|
||||||
|
if (!activeTab || !activeTab.databaseId || !activeTab.query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTab(activeTab.id, { isExecuting: true, result: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await sqlInterfaceApi.execute(activeTab.databaseId, activeTab.query);
|
||||||
|
updateTab(activeTab.id, { isExecuting: false, result: data });
|
||||||
|
} catch (error: any) {
|
||||||
|
updateTab(activeTab.id, {
|
||||||
|
isExecuting: false,
|
||||||
|
result: {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error || error.message || 'Query execution failed',
|
||||||
|
detail: error.response?.data?.detail,
|
||||||
|
hint: error.response?.data?.hint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeTab, updateTab]);
|
||||||
|
|
||||||
|
// Keyboard shortcut for execute (Ctrl+Enter or Cmd+Enter)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeQuery();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [executeQuery]);
|
||||||
|
|
||||||
|
const selectedDatabase = databases.find((db: Database) => db.id === activeTab?.databaseId);
|
||||||
|
|
||||||
|
if (isLoadingDatabases) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 64px)' }}>
|
||||||
|
{/* Tabs bar */}
|
||||||
|
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2">
|
||||||
|
<div className="flex-1 flex items-center overflow-x-auto">
|
||||||
|
{state.tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200
|
||||||
|
${tab.id === state.activeTabId
|
||||||
|
? 'bg-white text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="max-w-32 truncate">{tab.name}</span>
|
||||||
|
{tab.isExecuting && <Loader2 className="animate-spin" size={14} />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => closeTab(tab.id, e)}
|
||||||
|
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addTab}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded"
|
||||||
|
title="Новая вкладка"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Database selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon size={18} className="text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={activeTab?.databaseId || ''}
|
||||||
|
onChange={(e) => updateTab(activeTab.id, { databaseId: e.target.value })}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-48"
|
||||||
|
>
|
||||||
|
<option value="">Выберите базу данных</option>
|
||||||
|
{databases.map((db: Database) => (
|
||||||
|
<option key={db.id} value={db.id}>
|
||||||
|
{db.name} ({db.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execute button */}
|
||||||
|
<button
|
||||||
|
onClick={executeQuery}
|
||||||
|
disabled={!activeTab?.databaseId || !activeTab?.query.trim() || activeTab?.isExecuting}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||||
|
>
|
||||||
|
{activeTab?.isExecuting ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Play size={16} />
|
||||||
|
)}
|
||||||
|
Выполнить
|
||||||
|
<span className="text-xs opacity-75 ml-1">(Ctrl+Enter)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tab name editor */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeTab?.name || ''}
|
||||||
|
onChange={(e) => updateTab(activeTab.id, { name: e.target.value })}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 w-48"
|
||||||
|
placeholder="Название вкладки"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* SQL Editor */}
|
||||||
|
<div className="h-1/2 min-h-48 border-b border-gray-200">
|
||||||
|
<SqlEditor
|
||||||
|
value={activeTab?.query || ''}
|
||||||
|
onChange={(value) => updateTab(activeTab.id, { query: value })}
|
||||||
|
databaseId={activeTab?.databaseId}
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results panel */}
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col bg-white">
|
||||||
|
{/* Result header */}
|
||||||
|
{activeTab?.result && (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm">
|
||||||
|
{activeTab.result.success ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
<span>Успешно</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-gray-600">
|
||||||
|
<span>{activeTab.result.rowCount ?? 0} строк</span>
|
||||||
|
</div>
|
||||||
|
{activeTab.result.executionTime !== undefined && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>{activeTab.result.executionTime} мс</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab.result.command && (
|
||||||
|
<span className="text-gray-400">{activeTab.result.command}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-red-600">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<span>Ошибка</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{!activeTab?.result && !activeTab?.isExecuting && (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Выполните запрос, чтобы увидеть результаты
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.isExecuting && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="animate-spin text-primary-600" size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.result && !activeTab.result.success && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700 font-medium">{activeTab.result.error}</p>
|
||||||
|
{activeTab.result.detail && (
|
||||||
|
<p className="text-red-600 mt-2 text-sm">{activeTab.result.detail}</p>
|
||||||
|
)}
|
||||||
|
{activeTab.result.hint && (
|
||||||
|
<p className="text-red-500 mt-2 text-sm italic">Подсказка: {activeTab.result.hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab?.result?.success && activeTab.result.data && (
|
||||||
|
<ResultTable
|
||||||
|
data={activeTab.result.data}
|
||||||
|
fields={activeTab.result.fields || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultTableProps {
|
||||||
|
data: any[];
|
||||||
|
fields: { name: string; dataTypeID: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultTable({ data, fields }: ResultTableProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Запрос выполнен успешно, но не вернул данных
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = fields.length > 0
|
||||||
|
? fields.map(f => f.name)
|
||||||
|
: Object.keys(data[0] || {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto h-full">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 border-r border-b border-gray-200 w-12">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-gray-600 border-r border-b border-gray-200 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<td className="px-3 py-1.5 text-gray-400 border-r border-gray-100 text-xs">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="px-3 py-1.5 border-r border-gray-100 max-w-md truncate"
|
||||||
|
title={formatCellValue(row[col])}
|
||||||
|
>
|
||||||
|
{formatCellValue(row[col])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellValue(value: any): string {
|
||||||
|
if (value === null) return 'NULL';
|
||||||
|
if (value === undefined) return '';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -168,4 +168,23 @@ export const databasesApi = {
|
|||||||
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
|
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SQL Interface API
|
||||||
|
export interface SqlQueryResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: any[];
|
||||||
|
rowCount?: number;
|
||||||
|
fields?: { name: string; dataTypeID: number }[];
|
||||||
|
executionTime?: number;
|
||||||
|
command?: string;
|
||||||
|
error?: string;
|
||||||
|
position?: number;
|
||||||
|
detail?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sqlInterfaceApi = {
|
||||||
|
execute: (databaseId: string, query: string) =>
|
||||||
|
api.post<SqlQueryResult>('/sql/execute', { database_id: databaseId, query }),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user