diff --git a/backend/.env.example b/backend/.env.example index 2871f01..63f1ae2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,4 +15,4 @@ JWT_EXPIRES_IN=24h # API Rate Limiting RATE_LIMIT_WINDOW_MS=900000 -RATE_LIMIT_MAX_REQUESTS=100 \ No newline at end of file +RATE_LIMIT_MAX_REQUESTS=100 diff --git a/backend/src/controllers/sqlInterfaceController.ts b/backend/src/controllers/sqlInterfaceController.ts new file mode 100644 index 0000000..ca80321 --- /dev/null +++ b/backend/src/controllers/sqlInterfaceController.ts @@ -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, + }); + } +}; diff --git a/backend/src/routes/sqlInterface.ts b/backend/src/routes/sqlInterface.ts new file mode 100644 index 0000000..e0a0195 --- /dev/null +++ b/backend/src/routes/sqlInterface.ts @@ -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; diff --git a/backend/src/server.ts b/backend/src/server.ts index f68996f..61f71f9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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); diff --git a/docker-compose.external-db.yml b/docker-compose.external-db.yml index d392b0f..f503650 100644 --- a/docker-compose.external-db.yml +++ b/docker-compose.external-db.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be7dab1..70db25f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + } + /> `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(() => { + 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) => { + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Tabs bar */} +
+
+ {state.tabs.map(tab => ( + + + ))} +
+ +
+ + {/* Toolbar */} +
+ {/* Database selector */} +
+ + +
+ + {/* Execute button */} + + + {/* Tab name editor */} + 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="Название вкладки" + /> +
+ + {/* Main content area */} +
+ {/* SQL Editor */} +
+ updateTab(activeTab.id, { query: value })} + databaseId={activeTab?.databaseId} + height="100%" + /> +
+ + {/* Results panel */} +
+ {/* Result header */} + {activeTab?.result && ( +
+ {activeTab.result.success ? ( + <> +
+ + Успешно +
+
+ {activeTab.result.rowCount ?? 0} строк +
+ {activeTab.result.executionTime !== undefined && ( +
+ + {activeTab.result.executionTime} мс +
+ )} + {activeTab.result.command && ( + {activeTab.result.command} + )} + + ) : ( +
+ + Ошибка +
+ )} +
+ )} + + {/* Result content */} +
+ {!activeTab?.result && !activeTab?.isExecuting && ( +
+ Выполните запрос, чтобы увидеть результаты +
+ )} + + {activeTab?.isExecuting && ( +
+ +
+ )} + + {activeTab?.result && !activeTab.result.success && ( +
+
+

{activeTab.result.error}

+ {activeTab.result.detail && ( +

{activeTab.result.detail}

+ )} + {activeTab.result.hint && ( +

Подсказка: {activeTab.result.hint}

+ )} +
+
+ )} + + {activeTab?.result?.success && activeTab.result.data && ( + + )} +
+
+
+
+ ); +} + +interface ResultTableProps { + data: any[]; + fields: { name: string; dataTypeID: number }[]; +} + +function ResultTable({ data, fields }: ResultTableProps) { + if (data.length === 0) { + return ( +
+ Запрос выполнен успешно, но не вернул данных +
+ ); + } + + const columns = fields.length > 0 + ? fields.map(f => f.name) + : Object.keys(data[0] || {}); + + return ( +
+ + + + + {columns.map(col => ( + + ))} + + + + {data.map((row, rowIndex) => ( + + + {columns.map(col => ( + + ))} + + ))} + +
+ # + + {col} +
+ {rowIndex + 1} + + {formatCellValue(row[col])} +
+
+ ); +} + +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); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b5f6588..39d131c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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('/sql/execute', { database_id: databaseId, query }), +}; + export default api;