482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2, GripHorizontal } 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;
|
||
splitPosition: number; // percentage
|
||
}
|
||
|
||
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 {
|
||
const stateToSave = {
|
||
...state,
|
||
tabs: state.tabs.map(tab => ({
|
||
...tab,
|
||
isExecuting: false,
|
||
result: tab.result ? {
|
||
...tab.result,
|
||
data: tab.result.data?.slice(0, 100),
|
||
} : 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();
|
||
return data.filter((db: Database) => db.type !== 'aql');
|
||
},
|
||
});
|
||
|
||
const [state, setState] = useState<SqlInterfaceState>(() => {
|
||
const saved = loadState();
|
||
if (saved && saved.tabs.length > 0) {
|
||
return {
|
||
...saved,
|
||
splitPosition: saved.splitPosition || 50,
|
||
};
|
||
}
|
||
const initialTab = createNewTab();
|
||
return {
|
||
tabs: [initialTab],
|
||
activeTabId: initialTab.id,
|
||
splitPosition: 50,
|
||
};
|
||
});
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const isDragging = useRef(false);
|
||
|
||
useEffect(() => {
|
||
saveState(state);
|
||
}, [state]);
|
||
|
||
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 => ({
|
||
...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) {
|
||
const defaultDbId = databases.length > 0 ? databases[0].id : '';
|
||
const newTab = createNewTab(defaultDbId);
|
||
return {
|
||
...prev,
|
||
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 {
|
||
...prev,
|
||
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]);
|
||
|
||
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]);
|
||
|
||
// Drag to resize
|
||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
isDragging.current = true;
|
||
document.body.style.cursor = 'row-resize';
|
||
document.body.style.userSelect = 'none';
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!isDragging.current || !containerRef.current) return;
|
||
|
||
const container = containerRef.current;
|
||
const rect = container.getBoundingClientRect();
|
||
const y = e.clientY - rect.top;
|
||
const percentage = (y / rect.height) * 100;
|
||
const clamped = Math.min(Math.max(percentage, 20), 80);
|
||
|
||
setState(prev => ({ ...prev, splitPosition: clamped }));
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
if (isDragging.current) {
|
||
isDragging.current = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
}
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, []);
|
||
|
||
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 overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
|
||
{/* Tabs bar */}
|
||
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2 flex-shrink-0 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 flex-shrink-0
|
||
${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>
|
||
))}
|
||
<button
|
||
onClick={addTab}
|
||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded flex-shrink-0"
|
||
title="Новая вкладка"
|
||
>
|
||
<Plus size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Toolbar */}
|
||
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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 with resizable split */}
|
||
<div ref={containerRef} className="flex-1 flex flex-col min-h-0 relative">
|
||
{/* SQL Editor */}
|
||
<div
|
||
className="min-h-0"
|
||
style={{ height: `${state.splitPosition}%` }}
|
||
>
|
||
<SqlEditor
|
||
value={activeTab?.query || ''}
|
||
onChange={(value) => updateTab(activeTab.id, { query: value })}
|
||
databaseId={activeTab?.databaseId}
|
||
/>
|
||
</div>
|
||
|
||
{/* Resize handle */}
|
||
<div
|
||
onMouseDown={handleMouseDown}
|
||
className="h-2 bg-gray-200 hover:bg-primary-300 cursor-row-resize flex items-center justify-center border-y border-gray-300 flex-shrink-0 transition-colors"
|
||
>
|
||
<GripHorizontal size={16} className="text-gray-400" />
|
||
</div>
|
||
|
||
{/* Results panel */}
|
||
<div
|
||
className="flex flex-col bg-white overflow-hidden"
|
||
style={{ height: `calc(${100 - state.splitPosition}% - 8px)` }}
|
||
>
|
||
{/* 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 flex-shrink-0">
|
||
{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);
|
||
}
|