diff --git a/backend/app/Contracts/DatabaseDriverInterface.php b/backend/app/Contracts/DatabaseDriverInterface.php index 023443c..3b3c4f5 100644 --- a/backend/app/Contracts/DatabaseDriverInterface.php +++ b/backend/app/Contracts/DatabaseDriverInterface.php @@ -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; } diff --git a/backend/app/Http/Controllers/Api/SchemaController.php b/backend/app/Http/Controllers/Api/SchemaController.php index cf50323..a8f6e7b 100644 --- a/backend/app/Http/Controllers/Api/SchemaController.php +++ b/backend/app/Http/Controllers/Api/SchemaController.php @@ -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); + } + } } diff --git a/backend/app/Services/Database/MySqlDriver.php b/backend/app/Services/Database/MySqlDriver.php index 6000db0..d35ec20 100644 --- a/backend/app/Services/Database/MySqlDriver.php +++ b/backend/app/Services/Database/MySqlDriver.php @@ -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] ?? []); + } } diff --git a/backend/app/Services/DatabaseService.php b/backend/app/Services/DatabaseService.php index 97d4197..7c2c66a 100644 --- a/backend/app/Services/DatabaseService.php +++ b/backend/app/Services/DatabaseService.php @@ -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); + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d6d07ed..fc47972 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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']); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2eabf1..77ec8d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' && }
- + {activeTab === 'transfer' ? : } diff --git a/frontend/src/components/MainContent.tsx b/frontend/src/components/MainContent.tsx index 6969f2e..57e1c5c 100644 --- a/frontend/src/components/MainContent.tsx +++ b/frontend/src/components/MainContent.tsx @@ -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([]); const [rows, setRows] = useState([]); const [rowCount, setRowCount] = useState(0); @@ -170,180 +184,228 @@ const MainContent: React.FC = () => { } }; - if (!activeTable) { + if (!activeDatabase) { return ( - Select a table to view data + Select a database to start ); } + const handleTabChange = (_: React.SyntheticEvent, newValue: string) => { + setDbTab(newValue); + }; + + const DatabaseOverview = ({ database }: { database: string }) => { + const [meta, setMeta] = useState(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 ; + if (!meta) return Could not load metadata; + + const stats = [ + { label: 'Character Set', value: meta.charset, icon: }, + { label: 'Collation', value: meta.collation, icon: }, + { label: 'Tables', value: meta.table_count, icon: }, + { label: 'Views', value: meta.view_count, icon: }, + { label: 'Total Size', value: `${(meta.size_bytes / 1024 / 1024).toFixed(2)} MB`, icon: }, + ]; + + return ( + + Technical Specifications: {database} + + {stats.map((stat, i) => ( + 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)' } + }}> + + {stat.label} + {stat.icon} + + {stat.value} + + ))} + + + ); + }; + return ( - {/* SQL Editor Section */} + {/* Database Navigation Tabs */} - - - SQL EDITOR - - - {isCustomQuery ? 'Custom Query' : `${activeDatabase}.${activeTable}`} - - - - - setSqlQuery('')}> - - - - - - - - - - - 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 + + > + } iconPosition="start" label="Data Explorer" /> + } iconPosition="start" label="SQL Editor" /> + } iconPosition="start" label="Import" /> + } iconPosition="start" label="Export" /> + } iconPosition="start" label="Technical Info" /> + - {/* Data Section */} - - - - - {isCustomQuery ? 'Query Results' : 'Table Results'} - - - {rowCount} rows found - - - {isCustomQuery && ( - - )} - - - - {(loadingSchema && !isCustomQuery) ? ( - - + {/* Tables View */} + {dbTab === 'tables' && ( + + {!activeTable ? ( + + + Select a table from the explorer to view data ) : ( + <> + + + Table Results: {activeTable} + + {rowCount} rows found + + + + + + {loadingSchema ? ( + + + + ) : ( + + )} + + + )} + + )} + + {/* SQL View */} + {dbTab === 'sql' && ( + + + + SQL CONSOLE + + + + + setSqlQuery(value || '')} + options={{ minimap: { enabled: false }, fontSize: 14, automaticLayout: true }} + /> + + + 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' }} /> - )} - - + + + )} + + {/* Import/Export Views */} + {dbTab === 'import' && } + {dbTab === 'export' && } + + {/* Technical Info View */} + {dbTab === 'info' && } {/* Custom Alert (Snackbar) */} - - - - - } - > + + {errorInfo.title} - - {errorInfo.message} - + {errorInfo.message} diff --git a/frontend/src/components/TransferContent.tsx b/frontend/src/components/TransferContent.tsx new file mode 100644 index 0000000..00b949c --- /dev/null +++ b/frontend/src/components/TransferContent.tsx @@ -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 = ({ mode = 'both' }) => { + const { activeDatabase, darkMode } = useAppStore(); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + const [file, setFile] = useState(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) => { + 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 ( + + + + Database Transfer + + Export your database to a SQL file or import an existing SQL dump using mysqldump. + + + + {(success || error) && ( + : } + action={ + { setSuccess(null); setError(null); }}> + + + } + sx={{ borderRadius: 2, boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }} + > + {success || error} + + )} + + + {/* Export Card */} + {(mode === 'export' || mode === 'both') && ( + + + + + Export Database + + Create a full backup of the current database: {activeDatabase || 'All Databases'} + + + + + )} + + {/* Import Card */} + {(mode === 'import' || mode === 'both') && ( + + + + + Import Database + + Upload a .sql file to restore or migrate your data. + + + + + + {file ? file.name : "Click or drag .sql file here"} + + + + + + + )} + + + {loading && ( + + + Processing... This may take a while for large databases. + + + + )} + + + + + + System Requirements + + This system requires mysqldump and mysql clients to be installed and available in the server's PATH. + + + + + + + ); +}; + +export default TransferContent; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 86d3b28..6f04640 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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' } + }), }; diff --git a/frontend/src/store/useAppStore.ts b/frontend/src/store/useAppStore.ts index c28f5ae..defd94e 100644 --- a/frontend/src/store/useAppStore.ts +++ b/frontend/src/store/useAppStore.ts @@ -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()( 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()( darkMode: state.darkMode, activeDatabase: state.activeDatabase, activeTable: state.activeTable, - activeTab: state.activeTab + activeTab: state.activeTab, + dbTab: state.dbTab }), } )