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:
2026-01-27 23:13:44 +03:00
parent 35f81a1663
commit 767307857e
9 changed files with 526 additions and 2 deletions

View 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,
});
}
};

View 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;

View File

@@ -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);

View File

@@ -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:

View File

@@ -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={

View File

@@ -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 Ключи' },

View 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);
}

View File

@@ -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;