feat: implement MySQL driver and API schema services for database management
This commit is contained in:
@@ -33,4 +33,19 @@ interface DatabaseDriverInterface
|
||||
* Get the underlying connection instance.
|
||||
*/
|
||||
public function getConnection();
|
||||
|
||||
/**
|
||||
* Export the database.
|
||||
*/
|
||||
public function export(array $config): string;
|
||||
|
||||
/**
|
||||
* Import the database.
|
||||
*/
|
||||
public function import(array $config, string $filePath): bool;
|
||||
|
||||
/**
|
||||
* Get database metadata (charset, collation, size, etc.)
|
||||
*/
|
||||
public function getDatabaseMetadata(string $database): array;
|
||||
}
|
||||
|
||||
@@ -104,4 +104,54 @@ class SchemaController extends Controller
|
||||
return Response::json(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
try {
|
||||
$this->initializeDriver($request);
|
||||
$config = $request->only(['host', 'username', 'password', 'database', 'port']);
|
||||
$filePath = $this->databaseService->export($config);
|
||||
|
||||
return Response::download($filePath)->deleteFileAfterSend(true);
|
||||
} catch (\Exception $e) {
|
||||
return Response::json(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
try {
|
||||
if (!$request->hasFile('file')) {
|
||||
return Response::json(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
$this->initializeDriver($request);
|
||||
$config = $request->only(['host', 'username', 'password', 'database', 'port']);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->storeAs('temp', $file->getClientOriginalName());
|
||||
$fullPath = storage_path('app/' . $tempPath);
|
||||
|
||||
$this->databaseService->import($config, $fullPath);
|
||||
|
||||
if (file_exists($fullPath)) {
|
||||
unlink($fullPath);
|
||||
}
|
||||
|
||||
return Response::json(['message' => 'Database imported successfully']);
|
||||
} catch (\Exception $e) {
|
||||
return Response::json(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function metadata(Request $request, $database)
|
||||
{
|
||||
try {
|
||||
$request->merge(['database' => $database]);
|
||||
$this->initializeDriver($request);
|
||||
return Response::json($this->databaseService->getDatabaseMetadata($database));
|
||||
} catch (\Exception $e) {
|
||||
return Response::json(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,120 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
|
||||
$dbName = DB::connection($this->connectionName)->getDatabaseName();
|
||||
return $this->query($sql, [$table, $dbName]);
|
||||
}
|
||||
|
||||
public function export(array $config): string
|
||||
{
|
||||
$filename = 'backup-' . ($config['database'] ?? 'all') . '-' . date('Y-m-d-H-i-s') . '.sql';
|
||||
$directory = storage_path('app/backups');
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $filename;
|
||||
$errorPath = $directory . DIRECTORY_SEPARATOR . $filename . '.err';
|
||||
|
||||
// Ensure we have a username
|
||||
$username = $config['username'] ?? 'root';
|
||||
$password = $config['password'] ?? '';
|
||||
$host = $config['host'] ?? '127.0.0.1';
|
||||
$port = $config['port'] ?? '3306';
|
||||
$database = $config['database'] ?? '';
|
||||
|
||||
// Build command
|
||||
$passwordPart = !empty($password) ? "-p" . escapeshellarg($password) : "";
|
||||
$dbPart = !empty($database) ? escapeshellarg($database) : "--all-databases";
|
||||
|
||||
// On Windows, we might need to handle double quotes in escapeshellarg
|
||||
// Let's use a more robust way to execute and capture errors
|
||||
$command = sprintf(
|
||||
'mysqldump -u %s %s -h %s -P %s %s > %s 2> %s',
|
||||
escapeshellarg($username),
|
||||
$passwordPart,
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
$dbPart,
|
||||
escapeshellarg($path),
|
||||
escapeshellarg($errorPath)
|
||||
);
|
||||
|
||||
shell_exec($command);
|
||||
|
||||
if (file_exists($errorPath)) {
|
||||
$errors = file_get_contents($errorPath);
|
||||
unlink($errorPath);
|
||||
if (!empty(trim($errors))) {
|
||||
// If the file is 0 bytes and there are errors, it definitely failed
|
||||
if (!file_exists($path) || filesize($path) === 0) {
|
||||
throw new \Exception("Export failed: " . $errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!file_exists($path) || filesize($path) === 0) {
|
||||
throw new \Exception("Export failed: Resulting file is empty. Ensure mysqldump is installed and in the system PATH.");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function import(array $config, string $filePath): bool
|
||||
{
|
||||
$directory = storage_path('app/backups');
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
$errorPath = $directory . DIRECTORY_SEPARATOR . 'import_error.err';
|
||||
|
||||
$username = $config['username'] ?? 'root';
|
||||
$password = $config['password'] ?? '';
|
||||
$host = $config['host'] ?? '127.0.0.1';
|
||||
$port = $config['port'] ?? '3306';
|
||||
$database = $config['database'] ?? '';
|
||||
|
||||
$passwordPart = !empty($password) ? "-p" . escapeshellarg($password) : "";
|
||||
$dbPart = !empty($database) ? escapeshellarg($database) : "";
|
||||
|
||||
$command = sprintf(
|
||||
'mysql -u %s %s -h %s -P %s %s < %s 2> %s',
|
||||
escapeshellarg($username),
|
||||
$passwordPart,
|
||||
escapeshellarg($host),
|
||||
escapeshellarg($port),
|
||||
$dbPart,
|
||||
escapeshellarg($filePath),
|
||||
escapeshellarg($errorPath)
|
||||
);
|
||||
|
||||
shell_exec($command);
|
||||
|
||||
if (file_exists($errorPath)) {
|
||||
$errors = file_get_contents($errorPath);
|
||||
unlink($errorPath);
|
||||
if (!empty(trim($errors))) {
|
||||
throw new \Exception("Import failed: " . $errors);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getDatabaseMetadata(string $database): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
s.DEFAULT_CHARACTER_SET_NAME as charset,
|
||||
s.DEFAULT_COLLATION_NAME as collation,
|
||||
(SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = s.SCHEMA_NAME) as size_bytes,
|
||||
(SELECT COUNT(*) FROM information_schema.TABLES WHERE table_schema = s.SCHEMA_NAME) as table_count,
|
||||
(SELECT COUNT(*) FROM information_schema.VIEWS WHERE table_schema = s.SCHEMA_NAME) as view_count
|
||||
FROM
|
||||
information_schema.SCHEMATA s
|
||||
WHERE
|
||||
s.SCHEMA_NAME = ?
|
||||
";
|
||||
|
||||
$results = $this->query($sql, [$database]);
|
||||
return (array) ($results[0] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,4 +98,28 @@ class DatabaseService
|
||||
{
|
||||
return $this->getDriver()->query($sql, $bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the database.
|
||||
*/
|
||||
public function export(array $config): string
|
||||
{
|
||||
return $this->getDriver()->export($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the database.
|
||||
*/
|
||||
public function import(array $config, string $filePath): bool
|
||||
{
|
||||
return $this->getDriver()->import($config, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database metadata.
|
||||
*/
|
||||
public function getDatabaseMetadata(string $database): array
|
||||
{
|
||||
return $this->getDriver()->getDatabaseMetadata($database);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ Route::get('/user', function (Request $request) {
|
||||
Route::prefix('schema')->group(function () {
|
||||
Route::get('/databases', [SchemaController::class, 'databases']);
|
||||
Route::get('/tables/{database}', [SchemaController::class, 'tables']);
|
||||
Route::get('/metadata/{database}', [SchemaController::class, 'metadata']);
|
||||
Route::get('/{table}', [SchemaController::class, 'schema']);
|
||||
Route::get('/{table}/data', [SchemaController::class, 'data']);
|
||||
Route::post('/execute', [SchemaController::class, 'execute']);
|
||||
Route::post('/export', [SchemaController::class, 'export']);
|
||||
Route::post('/import', [SchemaController::class, 'import']);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import MainContent from './components/MainContent.tsx';
|
||||
import Header from './components/Header.tsx';
|
||||
import Login from './components/Login.tsx';
|
||||
import NavigationRail from './components/NavigationRail.tsx';
|
||||
import TransferContent from './components/TransferContent.tsx';
|
||||
|
||||
// Create emotion cache to handle the :first-child warning and ensure proper style injection
|
||||
const cache = createCache({
|
||||
@@ -48,7 +49,7 @@ const App: React.FC = () => {
|
||||
{activeTab === 'explorer' && <Sidebar />}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
<Header />
|
||||
<MainContent />
|
||||
{activeTab === 'transfer' ? <TransferContent /> : <MainContent />}
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -10,17 +10,31 @@ import {
|
||||
Tooltip,
|
||||
Snackbar,
|
||||
Alert,
|
||||
AlertTitle
|
||||
AlertTitle,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, History, Save, CleaningServices, Close } from '@mui/icons-material';
|
||||
import {
|
||||
PlayArrow,
|
||||
History,
|
||||
Save,
|
||||
CleaningServices,
|
||||
Close,
|
||||
TableChart,
|
||||
Terminal,
|
||||
CloudDownload,
|
||||
CloudUpload,
|
||||
Info
|
||||
} from '@mui/icons-material';
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import type { GridColDef, GridPaginationModel } from '@mui/x-data-grid';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { SchemaService } from '../services/api';
|
||||
import TransferContent from './TransferContent';
|
||||
|
||||
const MainContent: React.FC = () => {
|
||||
const { activeTable, activeDatabase, darkMode } = useAppStore();
|
||||
const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore();
|
||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
@@ -170,180 +184,228 @@ const MainContent: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeTable) {
|
||||
if (!activeDatabase) {
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, p: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default' }}>
|
||||
<Typography variant="h6" color="text.secondary">Select a table to view data</Typography>
|
||||
<Typography variant="h6" color="text.secondary">Select a database to start</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTabChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||
setDbTab(newValue);
|
||||
};
|
||||
|
||||
const DatabaseOverview = ({ database }: { database: string }) => {
|
||||
const [meta, setMeta] = useState<any>(null);
|
||||
const [loadingMeta, setLoadingMeta] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMeta = async () => {
|
||||
setLoadingMeta(true);
|
||||
try {
|
||||
const res = await SchemaService.getDatabaseMetadata(database);
|
||||
setMeta(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoadingMeta(false);
|
||||
}
|
||||
};
|
||||
fetchMeta();
|
||||
}, [database]);
|
||||
|
||||
if (loadingMeta) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 8 }}><CircularProgress /></Box>;
|
||||
if (!meta) return <Alert severity="error">Could not load metadata</Alert>;
|
||||
|
||||
const stats = [
|
||||
{ label: 'Character Set', value: meta.charset, icon: <History /> },
|
||||
{ label: 'Collation', value: meta.collation, icon: <CleaningServices /> },
|
||||
{ label: 'Tables', value: meta.table_count, icon: <TableChart /> },
|
||||
{ label: 'Views', value: meta.view_count, icon: <History /> },
|
||||
{ label: 'Total Size', value: `${(meta.size_bytes / 1024 / 1024).toFixed(2)} MB`, icon: <Save /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, mb: 4, letterSpacing: -0.5 }}>Technical Specifications: {database}</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 3 }}>
|
||||
{stats.map((stat, i) => (
|
||||
<Paper key={i} sx={{
|
||||
p: 3,
|
||||
borderRadius: 4,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': { borderColor: 'primary.main', transform: 'translateY(-2px)' }
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 1 }}>{stat.label}</Typography>
|
||||
<Box sx={{ color: 'primary.main', opacity: 0.5 }}>{stat.icon}</Box>
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>{stat.value}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden', gap: 2 }}>
|
||||
{/* SQL Editor Section */}
|
||||
{/* Database Navigation Tabs */}
|
||||
<Paper elevation={0} sx={{
|
||||
flexShrink: 0,
|
||||
flexShrink: 0,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: darkMode ? '#111827' : '#ffffff',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.1)'
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Box sx={{ px: 3, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'rgba(0,0,0,0.02)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, opacity: 0.8, letterSpacing: 1 }}>SQL EDITOR</Typography>
|
||||
<Divider orientation="vertical" flexItem sx={{ height: 16, my: 'auto' }} />
|
||||
<Typography variant="caption" sx={{ opacity: 0.5, fontWeight: 500 }}>
|
||||
{isCustomQuery ? 'Custom Query' : `${activeDatabase}.${activeTable}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Clear Editor">
|
||||
<IconButton size="small" onClick={() => setSqlQuery('')}><CleaningServices fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Save Query">
|
||||
<IconButton size="small"><Save fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Query History">
|
||||
<IconButton size="small"><History fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleExecute}
|
||||
disabled={loadingData}
|
||||
startIcon={loadingData ? <CircularProgress size={16} color="inherit" /> : <PlayArrow />}
|
||||
sx={{ ml: 1, px: 3, borderRadius: 2, textTransform: 'none', fontWeight: 700, boxShadow: '0 4px 12px rgba(0, 97, 255, 0.3)' }}
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Editor
|
||||
height="220px"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? 'vs-dark' : 'light'}
|
||||
value={sqlQuery}
|
||||
onChange={(value) => setSqlQuery(value || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: "'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
padding: { top: 16, bottom: 16 },
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
smoothScrolling: true
|
||||
<Tabs
|
||||
value={dbTab}
|
||||
onChange={handleTabChange}
|
||||
sx={{
|
||||
px: 2,
|
||||
minHeight: 56,
|
||||
'& .MuiTabs-indicator': { height: 3, borderRadius: '3px 3px 0 0' },
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
minHeight: 56,
|
||||
minWidth: 120,
|
||||
gap: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Tab value="tables" icon={<TableChart sx={{ fontSize: 18 }} />} iconPosition="start" label="Data Explorer" />
|
||||
<Tab value="sql" icon={<Terminal sx={{ fontSize: 18 }} />} iconPosition="start" label="SQL Editor" />
|
||||
<Tab value="import" icon={<CloudUpload sx={{ fontSize: 18 }} />} iconPosition="start" label="Import" />
|
||||
<Tab value="export" icon={<CloudDownload sx={{ fontSize: 18 }} />} iconPosition="start" label="Export" />
|
||||
<Tab value="info" icon={<Info sx={{ fontSize: 18 }} />} iconPosition="start" label="Technical Info" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Data Section */}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
<Box sx={{ mb: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{isCustomQuery ? 'Query Results' : 'Table Results'}
|
||||
</Typography>
|
||||
<Box sx={{ px: 1.5, py: 0.5, borderRadius: 10, bgcolor: 'rgba(0, 97, 255, 0.1)', color: 'primary.main' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{rowCount} rows found</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isCustomQuery && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setIsCustomQuery(false)}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
Back to Table View
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper sx={{
|
||||
flexGrow: 1,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
{(loadingSchema && !isCustomQuery) ? (
|
||||
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
{/* Tables View */}
|
||||
{dbTab === 'tables' && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
{!activeTable ? (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2, opacity: 0.5 }}>
|
||||
<TableChart sx={{ fontSize: 64 }} />
|
||||
<Typography variant="h6">Select a table from the explorer to view data</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ mb: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>Table Results: {activeTable}</Typography>
|
||||
<Box sx={{ px: 1.5, py: 0.5, borderRadius: 10, bgcolor: 'rgba(0, 97, 255, 0.1)', color: 'primary.main' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{rowCount} rows found</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{
|
||||
flexGrow: 1,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
{loadingSchema ? (
|
||||
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowCount={rowCount}
|
||||
loading={loadingData}
|
||||
paginationMode="server"
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-row:hover': { bgcolor: 'rgba(0, 97, 255, 0.04)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* SQL View */}
|
||||
{dbTab === 'sql' && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden' }}>
|
||||
<Paper elevation={0} sx={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: darkMode ? '#111827' : '#ffffff',
|
||||
}}>
|
||||
<Box sx={{ px: 3, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'rgba(0,0,0,0.02)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>SQL CONSOLE</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleExecute}
|
||||
disabled={loadingData}
|
||||
startIcon={loadingData ? <CircularProgress size={16} color="inherit" /> : <PlayArrow />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
Execute Query
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Editor
|
||||
height="200px"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? 'vs-dark' : 'light'}
|
||||
value={sqlQuery}
|
||||
onChange={(value) => setSqlQuery(value || '')}
|
||||
options={{ minimap: { enabled: false }, fontSize: 14, automaticLayout: true }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ flexGrow: 1, borderRadius: 3, overflow: 'hidden', border: 1, borderColor: 'divider' }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowCount={rowCount}
|
||||
loading={loadingData}
|
||||
paginationMode={isCustomQuery ? 'client' : 'server'}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
sx={{
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||
'& .MuiDataGrid-row:nth-of-type(even)': {
|
||||
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.02)' : 'rgba(255, 255, 255, 0.02)',
|
||||
},
|
||||
'& .MuiDataGrid-row:hover': {
|
||||
bgcolor: (theme) => theme.palette.mode === 'light' ? 'rgba(0, 97, 255, 0.04)' : 'rgba(0, 97, 255, 0.08)',
|
||||
},
|
||||
'& .MuiDataGrid-columnHeaderTitle': {
|
||||
fontWeight: 700,
|
||||
opacity: 0.8
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
loadingOverlay: {
|
||||
variant: 'linear-progress',
|
||||
noRowsVariant: 'linear-progress',
|
||||
}
|
||||
}}
|
||||
sx={{ border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Import/Export Views */}
|
||||
{dbTab === 'import' && <TransferContent mode="import" />}
|
||||
{dbTab === 'export' && <TransferContent mode="export" />}
|
||||
|
||||
{/* Technical Info View */}
|
||||
{dbTab === 'info' && <DatabaseOverview database={activeDatabase} />}
|
||||
|
||||
{/* Custom Alert (Snackbar) */}
|
||||
<Snackbar
|
||||
open={errorInfo.open}
|
||||
autoHideDuration={10000}
|
||||
onClose={handleCloseError}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseError}
|
||||
severity="error"
|
||||
variant="filled"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
'& .MuiAlert-message': { wordBreak: 'break-word' }
|
||||
}}
|
||||
action={
|
||||
<IconButton size="small" color="inherit" onClick={handleCloseError}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Snackbar open={errorInfo.open} autoHideDuration={10000} onClose={handleCloseError} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||
<Alert onClose={handleCloseError} severity="error" variant="filled" sx={{ width: '100%', borderRadius: 2 }}>
|
||||
<AlertTitle sx={{ fontWeight: 800 }}>{errorInfo.title}</AlertTitle>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{errorInfo.message}
|
||||
</Typography>
|
||||
<Typography variant="body2">{errorInfo.message}</Typography>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Divider,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudDownload,
|
||||
CloudUpload,
|
||||
Storage,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { SchemaService } from '../services/api';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
interface TransferContentProps {
|
||||
mode?: 'import' | 'export' | 'both';
|
||||
}
|
||||
|
||||
const TransferContent: React.FC<TransferContentProps> = ({ mode = 'both' }) => {
|
||||
const { activeDatabase, darkMode } = useAppStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const response = await SchemaService.exportDatabase(activeDatabase || undefined);
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const filename = `backup-${activeDatabase || 'all'}-${new Date().toISOString().split('T')[0]}.sql`;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
setSuccess('Database exported successfully!');
|
||||
} catch (err: any) {
|
||||
setError('Export failed: ' + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (activeDatabase) formData.append('database', activeDatabase);
|
||||
|
||||
await SchemaService.importDatabase(formData);
|
||||
setSuccess('Database imported successfully!');
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
setError('Import failed: ' + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, p: 4, bgcolor: 'background.default', display: 'flex', justifyContent: 'center' }}>
|
||||
<Stack spacing={4} sx={{ width: '100%', maxWidth: 800 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, mb: 1 }}>Database Transfer</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Export your database to a SQL file or import an existing SQL dump using <strong>mysqldump</strong>.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{(success || error) && (
|
||||
<Alert
|
||||
severity={success ? "success" : "error"}
|
||||
icon={success ? <CheckCircle /> : <ErrorIcon />}
|
||||
action={
|
||||
<IconButton size="small" onClick={() => { setSuccess(null); setError(null); }}>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
sx={{ borderRadius: 2, boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }}
|
||||
>
|
||||
{success || error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>
|
||||
{/* Export Card */}
|
||||
{(mode === 'export' || mode === 'both') && (
|
||||
<Paper sx={{
|
||||
flex: 1,
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 12px 24px rgba(0,0,0,0.1)' }
|
||||
}}>
|
||||
<Box sx={{ p: 2, borderRadius: '50%', bgcolor: 'primary.main', color: 'white', mb: 1 }}>
|
||||
<CloudDownload sx={{ fontSize: 40 }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Export Database</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create a full backup of the current database: <strong>{activeDatabase || 'All Databases'}</strong>
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="large"
|
||||
startIcon={<CloudDownload />}
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
sx={{ borderRadius: 2, py: 1.5, fontWeight: 700 }}
|
||||
>
|
||||
Start Export
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Import Card */}
|
||||
{(mode === 'import' || mode === 'both') && (
|
||||
<Paper sx={{
|
||||
flex: 1,
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 12px 24px rgba(0,0,0,0.1)' }
|
||||
}}>
|
||||
<Box sx={{ p: 2, borderRadius: '50%', bgcolor: 'secondary.main', color: 'white', mb: 1 }}>
|
||||
<CloudUpload sx={{ fontSize: 40 }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Import Database</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a .sql file to restore or migrate your data.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
p: 2,
|
||||
border: '2px dashed',
|
||||
borderColor: file ? 'secondary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(0,0,0,0.02)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".sql"
|
||||
onChange={handleFileChange}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{file ? file.name : "Click or drag .sql file here"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="large"
|
||||
color="secondary"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={handleImport}
|
||||
disabled={loading || !file}
|
||||
sx={{ borderRadius: 2, py: 1.5, fontWeight: 700 }}
|
||||
>
|
||||
Start Import
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="caption" sx={{ mb: 1, display: 'block', textAlign: 'center' }}>
|
||||
Processing... This may take a while for large databases.
|
||||
</Typography>
|
||||
<LinearProgress sx={{ borderRadius: 5, height: 8 }} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ p: 3, borderRadius: 3, bgcolor: 'rgba(0, 0, 0, 0.03)', border: 1, borderColor: 'divider' }}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Storage color="primary" />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>System Requirements</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
This system requires <code>mysqldump</code> and <code>mysql</code> clients to be installed and available in the server's PATH.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferContent;
|
||||
@@ -25,7 +25,12 @@ export default api;
|
||||
export const SchemaService = {
|
||||
getDatabases: () => api.get('/schema/databases'),
|
||||
getTables: (db: string) => api.get(`/schema/tables/${db}`, { params: { database: db } }),
|
||||
getDatabaseMetadata: (db: string) => api.get(`/schema/metadata/${db}`, { params: { database: db } }),
|
||||
getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }),
|
||||
getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }),
|
||||
executeQuery: (query: string) => api.post('/schema/execute', { query }),
|
||||
exportDatabase: (database?: string) => api.post('/schema/export', { database }, { responseType: 'blob' }),
|
||||
importDatabase: (formData: FormData) => api.post('/schema/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14,10 +14,12 @@ interface AppState {
|
||||
activeTab: string;
|
||||
activeDatabase: string | null;
|
||||
activeTable: string | null;
|
||||
dbTab: string; // 'tables', 'sql', 'import', 'export'
|
||||
connection: ConnectionConfig | null;
|
||||
connected: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
setActiveTab: (tab: string) => void;
|
||||
setDbTab: (tab: string) => void;
|
||||
setConnection: (config: ConnectionConfig) => void;
|
||||
clearConnection: () => void;
|
||||
setActiveDatabase: (db: string | null) => void;
|
||||
@@ -31,14 +33,16 @@ export const useAppStore = create<AppState>()(
|
||||
activeTab: 'explorer',
|
||||
activeDatabase: null,
|
||||
activeTable: null,
|
||||
dbTab: 'tables',
|
||||
connection: null,
|
||||
connected: false,
|
||||
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setDbTab: (tab) => set({ dbTab: tab }),
|
||||
setConnection: (config) => set({ connection: config, connected: true }),
|
||||
clearConnection: () => set({ connection: null, connected: false, activeDatabase: null, activeTable: null }),
|
||||
setActiveDatabase: (db) => set({ activeDatabase: db, activeTable: null }),
|
||||
setActiveTable: (table) => set({ activeTable: table }),
|
||||
setActiveDatabase: (db) => set({ activeDatabase: db, activeTable: null, dbTab: 'tables' }),
|
||||
setActiveTable: (table) => set({ activeTable: table, dbTab: 'tables' }),
|
||||
}),
|
||||
{
|
||||
name: 'mariavel-storage',
|
||||
@@ -50,7 +54,8 @@ export const useAppStore = create<AppState>()(
|
||||
darkMode: state.darkMode,
|
||||
activeDatabase: state.activeDatabase,
|
||||
activeTable: state.activeTable,
|
||||
activeTab: state.activeTab
|
||||
activeTab: state.activeTab,
|
||||
dbTab: state.dbTab
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user