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:
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 userRoutes from './routes/users';
|
||||
import logsRoutes from './routes/logs';
|
||||
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||
import dynamicRoutes from './routes/dynamic';
|
||||
|
||||
const app: Express = express();
|
||||
@@ -87,6 +88,7 @@ app.use('/api/databases', databaseRoutes);
|
||||
app.use('/api/db-management', databaseManagementRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
app.use('/api/sql', sqlInterfaceRoutes);
|
||||
|
||||
// Dynamic API routes (user-created endpoints)
|
||||
app.use('/api/v1', dynamicRoutes);
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: kis-api-builder-app
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
environment:
|
||||
|
||||
@@ -13,6 +13,7 @@ import ApiKeys from '@/pages/ApiKeys';
|
||||
import Folders from '@/pages/Folders';
|
||||
import Logs from '@/pages/Logs';
|
||||
import Settings from '@/pages/Settings';
|
||||
import SqlInterface from '@/pages/SqlInterface';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -75,6 +76,16 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sql"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout>
|
||||
<SqlInterface />
|
||||
</Layout>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/endpoints"
|
||||
element={
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Home, label: 'Главная' },
|
||||
{ to: '/sql', icon: Database, label: 'SQL интерфейс' },
|
||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||
{ 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`),
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user