Files
api_builder/frontend/src/pages/SqlInterface.tsx

482 lines
16 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, 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);
}