feat: implement MySQL driver and API schema services for database management

This commit is contained in:
Ümit Tunç
2026-04-24 08:29:43 +03:00
parent c433630e33
commit 60cd2fe051
10 changed files with 668 additions and 154 deletions
@@ -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] ?? []);
}
}
+24
View File
@@ -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);
}
}
+3
View File
@@ -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']);
});
+2 -1
View File
@@ -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>
+211 -149
View File
@@ -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,
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>
+233
View File
@@ -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;
+5
View File
@@ -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' }
}),
};
+8 -3
View File
@@ -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
}),
}
)