Files
api_builder/frontend/src/pages/SqlInterface.tsx
eshmeshek 767307857e 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
2026-01-27 23:13:44 +03:00

433 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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