feat: implement MySQL driver for database management, schema discovery, and CRUD operations
This commit is contained in:
@@ -177,6 +177,67 @@ class SchemaController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function drop(Request $request, $table)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->initializeDriver($request);
|
||||||
|
$this->databaseService->dropTable($table);
|
||||||
|
return Response::json(['message' => "Table '{$table}' dropped successfully"]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function optimize(Request $request, $table)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->initializeDriver($request);
|
||||||
|
$this->databaseService->optimizeTable($table);
|
||||||
|
return Response::json(['message' => "Table '{$table}' optimized successfully"]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkAction(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'tables' => 'required|array',
|
||||||
|
'action' => 'required|string|in:truncate,drop,optimize',
|
||||||
|
'database' => 'required|string'
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->initializeDriver($request);
|
||||||
|
$tables = $request->tables;
|
||||||
|
$action = $request->action;
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
case 'truncate':
|
||||||
|
$this->databaseService->truncateTable($table);
|
||||||
|
break;
|
||||||
|
case 'drop':
|
||||||
|
$this->databaseService->dropTable($table);
|
||||||
|
break;
|
||||||
|
case 'optimize':
|
||||||
|
$this->databaseService->optimizeTable($table);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$results[$table] = 'success';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[$table] = 'error: ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(['message' => 'Bulk operation completed', 'results' => $results]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Response::json(['error' => $e->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function tablesMetadata(Request $request, $database)
|
public function tablesMetadata(Request $request, $database)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -265,6 +265,18 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dropTable(string $table): bool
|
||||||
|
{
|
||||||
|
DB::connection($this->connectionName)->statement("DROP TABLE `{$table}`");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function optimizeTable(string $table): bool
|
||||||
|
{
|
||||||
|
DB::connection($this->connectionName)->statement("OPTIMIZE TABLE `{$table}`");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTablesMetadata(string $database): array
|
public function getTablesMetadata(string $database): array
|
||||||
{
|
{
|
||||||
$sql = "
|
$sql = "
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ class DatabaseService
|
|||||||
return $this->getDriver()->truncateTable($table);
|
return $this->getDriver()->truncateTable($table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dropTable(string $table): bool
|
||||||
|
{
|
||||||
|
return $this->getDriver()->dropTable($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function optimizeTable(string $table): bool
|
||||||
|
{
|
||||||
|
return $this->getDriver()->optimizeTable($table);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get metadata for all tables in a database.
|
* Get metadata for all tables in a database.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Route::prefix('schema')->group(function () {
|
|||||||
Route::get('/metadata/{database}/tables', [SchemaController::class, 'tablesMetadata']);
|
Route::get('/metadata/{database}/tables', [SchemaController::class, 'tablesMetadata']);
|
||||||
Route::get('/metadata/{database}/{table}', [SchemaController::class, 'tableMetadata']);
|
Route::get('/metadata/{database}/{table}', [SchemaController::class, 'tableMetadata']);
|
||||||
Route::post('/truncate/{table}', [SchemaController::class, 'truncate']);
|
Route::post('/truncate/{table}', [SchemaController::class, 'truncate']);
|
||||||
|
Route::post('/drop/{table}', [SchemaController::class, 'drop']);
|
||||||
|
Route::post('/optimize/{table}', [SchemaController::class, 'optimize']);
|
||||||
|
Route::post('/bulk-action', [SchemaController::class, 'bulkAction']);
|
||||||
Route::get('/{table}', [SchemaController::class, 'schema']);
|
Route::get('/{table}', [SchemaController::class, 'schema']);
|
||||||
Route::get('/{table}/data', [SchemaController::class, 'data']);
|
Route::get('/{table}/data', [SchemaController::class, 'data']);
|
||||||
Route::post('/execute', [SchemaController::class, 'execute']);
|
Route::post('/execute', [SchemaController::class, 'execute']);
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab
|
Tab,
|
||||||
|
Stack,
|
||||||
|
Checkbox
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
PlayArrow,
|
PlayArrow,
|
||||||
@@ -34,6 +36,302 @@ import { SchemaService } from '../services/api';
|
|||||||
import TransferContent from './TransferContent';
|
import TransferContent from './TransferContent';
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
interface MainNotification {
|
||||||
|
open: boolean;
|
||||||
|
message: string;
|
||||||
|
title: string;
|
||||||
|
severity: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DatabaseTablesGrid = ({ database, setNotification }: { database: string, setNotification: (n: MainNotification) => void }) => {
|
||||||
|
const [tableRows, setTableRows] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectionModel, setSelectionModel] = useState<string[]>([]);
|
||||||
|
const { setActiveTable } = useAppStore();
|
||||||
|
|
||||||
|
const fetchTablesMeta = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await SchemaService.getTablesMetadata(database);
|
||||||
|
setTableRows(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [database]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTablesMeta();
|
||||||
|
}, [fetchTablesMeta]);
|
||||||
|
|
||||||
|
const handleBulkAction = async (action: 'truncate' | 'drop' | 'optimize') => {
|
||||||
|
if (selectionModel.length === 0) return;
|
||||||
|
|
||||||
|
const confirmMsg = `Are you sure you want to ${action} ${selectionModel.length} selected tables? This action is irreversible!`;
|
||||||
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await SchemaService.bulkAction({
|
||||||
|
tables: selectionModel,
|
||||||
|
action,
|
||||||
|
database
|
||||||
|
});
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Bulk Action Success',
|
||||||
|
message: `Successfully performed ${action} on ${selectionModel.length} tables.`,
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
fetchTablesMeta();
|
||||||
|
setSelectionModel([]);
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Bulk Action Error',
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (name: string) => {
|
||||||
|
setSelectionModel(prev =>
|
||||||
|
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selectionModel.length === tableRows.length) {
|
||||||
|
setSelectionModel([]);
|
||||||
|
} else {
|
||||||
|
setSelectionModel(tableRows.map(r => r.name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: '__check__',
|
||||||
|
headerName: '',
|
||||||
|
width: 50,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
renderHeader: () => (
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
indeterminate={selectionModel.length > 0 && selectionModel.length < tableRows.length}
|
||||||
|
checked={tableRows.length > 0 && selectionModel.length === tableRows.length}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selectionModel.includes(params.row.name)}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleRow(params.row.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
headerName: 'Table Name',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<TableChart fontSize="small" />}
|
||||||
|
onClick={() => setActiveTable(params.value)}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 600, color: '#ffc107', '&:hover': { color: '#ffca28' } }}
|
||||||
|
>
|
||||||
|
{params.value}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ field: 'engine', headerName: 'Engine', width: 120 },
|
||||||
|
{ field: 'rows', headerName: 'Rows', type: 'number', width: 120 },
|
||||||
|
{
|
||||||
|
field: 'data_length',
|
||||||
|
headerName: 'Data Size',
|
||||||
|
width: 130,
|
||||||
|
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'index_length',
|
||||||
|
headerName: 'Index Size',
|
||||||
|
width: 130,
|
||||||
|
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
},
|
||||||
|
{ field: 'collation', headerName: 'Collation', width: 180 },
|
||||||
|
{ field: 'comment', headerName: 'Comment', flex: 1, minWidth: 150 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{selectionModel.length > 0 && (
|
||||||
|
<Paper sx={{ p: 1.5, borderRadius: 2, display: 'flex', alignItems: 'center', gap: 2, bgcolor: 'rgba(255, 193, 7, 0.05)', border: '1px dashed #ffc107' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#ffc107' }}>
|
||||||
|
{selectionModel.length} tables selected:
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" color="warning" variant="outlined" startIcon={<CleaningServices />} onClick={() => handleBulkAction('truncate')}>Truncate</Button>
|
||||||
|
<Button size="small" color="error" variant="outlined" startIcon={<Close />} onClick={() => handleBulkAction('drop')}>Drop</Button>
|
||||||
|
<Button size="small" color="info" variant="outlined" startIcon={<Save />} onClick={() => handleBulkAction('optimize')}>Optimize</Button>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Button size="small" onClick={() => setSelectionModel([])}>Clear Selection</Button>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
<Paper sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||||
|
}}>
|
||||||
|
<DataGrid
|
||||||
|
rows={tableRows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
getRowId={(row: any) => row.name}
|
||||||
|
density="comfortable"
|
||||||
|
sx={{ border: 'none' }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TechnicalOverview = ({ database, table, setNotification }: { database: string, table: string | null, setNotification: (n: MainNotification) => void }) => {
|
||||||
|
const [meta, setMeta] = useState<any>(null);
|
||||||
|
const [loadingMeta, setLoadingMeta] = useState(true);
|
||||||
|
const [truncating, setTruncating] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const fetchMeta = useCallback(async () => {
|
||||||
|
setLoadingMeta(true);
|
||||||
|
try {
|
||||||
|
const res = table
|
||||||
|
? await SchemaService.getTableMetadata(database, table)
|
||||||
|
: await SchemaService.getDatabaseMetadata(database);
|
||||||
|
setMeta(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoadingMeta(false);
|
||||||
|
}
|
||||||
|
}, [database, table]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMeta();
|
||||||
|
}, [fetchMeta]);
|
||||||
|
|
||||||
|
const handleTruncate = async () => {
|
||||||
|
setTruncating(true);
|
||||||
|
setShowConfirm(false);
|
||||||
|
try {
|
||||||
|
await SchemaService.truncateTable(table!);
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Success',
|
||||||
|
message: `Table "${table}" has been truncated.`,
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
fetchMeta();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
title: 'Truncate Error',
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTruncating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = table ? [
|
||||||
|
{ label: 'Engine', value: meta.engine, icon: <Terminal /> },
|
||||||
|
{ label: 'Rows', value: meta.rows, icon: <TableChart /> },
|
||||||
|
{ label: 'Collation', value: meta.collation, icon: <CleaningServices /> },
|
||||||
|
{ label: 'Data Size', value: `${(meta.data_length / 1024 / 1024).toFixed(2)} MB`, icon: <Save /> },
|
||||||
|
{ label: 'Index Size', value: `${(meta.index_length / 1024 / 1024).toFixed(2)} MB`, icon: <History /> },
|
||||||
|
{ label: 'Created', value: meta.create_time, icon: <Info /> },
|
||||||
|
] : [
|
||||||
|
{ 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 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, letterSpacing: -0.5 }}>
|
||||||
|
Technical Specifications: {table ? `${database}.${table}` : database}
|
||||||
|
</Typography>
|
||||||
|
{table && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={truncating ? <CircularProgress size={16} color="inherit" /> : <CleaningServices />}
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
disabled={truncating}
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
Truncate Table
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfirm}
|
||||||
|
onClose={() => setShowConfirm(false)}
|
||||||
|
onConfirm={handleTruncate}
|
||||||
|
title="Truncate Table"
|
||||||
|
message={`Are you sure you want to truncate table "${table}"? This action will permanently delete all ${meta?.rows || ''} records. This cannot be undone.`}
|
||||||
|
confirmLabel="Truncate Now"
|
||||||
|
loading={truncating}
|
||||||
|
/>
|
||||||
|
<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 || 'N/A'}</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MainContent: React.FC = () => {
|
const MainContent: React.FC = () => {
|
||||||
const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore();
|
const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore();
|
||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||||
@@ -49,12 +347,7 @@ const MainContent: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Custom Notification State
|
// Custom Notification State
|
||||||
const [notification, setNotification] = useState<{
|
const [notification, setNotification] = useState<MainNotification>({
|
||||||
open: boolean;
|
|
||||||
message: string;
|
|
||||||
title: string;
|
|
||||||
severity: 'success' | 'error' | 'info' | 'warning'
|
|
||||||
}>({
|
|
||||||
open: false,
|
open: false,
|
||||||
message: '',
|
message: '',
|
||||||
title: '',
|
title: '',
|
||||||
@@ -204,204 +497,6 @@ const MainContent: React.FC = () => {
|
|||||||
setDbTab(newValue);
|
setDbTab(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TechnicalOverview = ({ database, table }: { database: string, table: string | null }) => {
|
|
||||||
const [meta, setMeta] = useState<any>(null);
|
|
||||||
const [loadingMeta, setLoadingMeta] = useState(true);
|
|
||||||
const [truncating, setTruncating] = useState(false);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
|
|
||||||
const fetchMeta = useCallback(async () => {
|
|
||||||
setLoadingMeta(true);
|
|
||||||
try {
|
|
||||||
const res = table
|
|
||||||
? await SchemaService.getTableMetadata(database, table)
|
|
||||||
: await SchemaService.getDatabaseMetadata(database);
|
|
||||||
setMeta(res.data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoadingMeta(false);
|
|
||||||
}
|
|
||||||
}, [database, table]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMeta();
|
|
||||||
}, [fetchMeta]);
|
|
||||||
|
|
||||||
const handleTruncate = async () => {
|
|
||||||
setTruncating(true);
|
|
||||||
setShowConfirm(false);
|
|
||||||
try {
|
|
||||||
await SchemaService.truncateTable(table!);
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
title: 'Success',
|
|
||||||
message: `Table "${table}" has been truncated.`,
|
|
||||||
severity: 'success'
|
|
||||||
});
|
|
||||||
fetchMeta();
|
|
||||||
} catch (error: any) {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
title: 'Truncate Error',
|
|
||||||
message: error.response?.data?.error || error.message,
|
|
||||||
severity: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTruncating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = table ? [
|
|
||||||
{ label: 'Engine', value: meta.engine, icon: <Terminal /> },
|
|
||||||
{ label: 'Rows', value: meta.rows, icon: <TableChart /> },
|
|
||||||
{ label: 'Collation', value: meta.collation, icon: <CleaningServices /> },
|
|
||||||
{ label: 'Data Size', value: `${(meta.data_length / 1024 / 1024).toFixed(2)} MB`, icon: <Save /> },
|
|
||||||
{ label: 'Index Size', value: `${(meta.index_length / 1024 / 1024).toFixed(2)} MB`, icon: <History /> },
|
|
||||||
{ label: 'Created', value: meta.create_time, icon: <Info /> },
|
|
||||||
] : [
|
|
||||||
{ 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 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800, letterSpacing: -0.5 }}>
|
|
||||||
Technical Specifications: {table ? `${database}.${table}` : database}
|
|
||||||
</Typography>
|
|
||||||
{table && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
startIcon={truncating ? <CircularProgress size={16} color="inherit" /> : <CleaningServices />}
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={truncating}
|
|
||||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
Truncate Table
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={showConfirm}
|
|
||||||
onClose={() => setShowConfirm(false)}
|
|
||||||
onConfirm={handleTruncate}
|
|
||||||
title="Truncate Table"
|
|
||||||
message={`Are you sure you want to truncate table "${table}"? This action will permanently delete all ${meta?.rows || ''} records. This cannot be undone.`}
|
|
||||||
confirmLabel="Truncate Now"
|
|
||||||
loading={truncating}
|
|
||||||
/>
|
|
||||||
<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 || 'N/A'}</Typography>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DatabaseTablesGrid = ({ database }: { database: string }) => {
|
|
||||||
const [tableRows, setTableRows] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const { setActiveTable } = useAppStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTablesMeta = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await SchemaService.getTablesMetadata(database);
|
|
||||||
setTableRows(res.data.map((r: any, i: number) => ({ id: r.name || i, ...r })));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchTablesMeta();
|
|
||||||
}, [database]);
|
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Table Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 200,
|
|
||||||
renderCell: (params) => (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<TableChart fontSize="small" />}
|
|
||||||
onClick={() => setActiveTable(params.value)}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600, color: '#ffc107', '&:hover': { color: '#ffca28' } }}
|
|
||||||
>
|
|
||||||
{params.value}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ field: 'engine', headerName: 'Engine', width: 120 },
|
|
||||||
{ field: 'rows', headerName: 'Rows', type: 'number', width: 120 },
|
|
||||||
{
|
|
||||||
field: 'data_length',
|
|
||||||
headerName: 'Data Size',
|
|
||||||
width: 130,
|
|
||||||
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'index_length',
|
|
||||||
headerName: 'Index Size',
|
|
||||||
width: 130,
|
|
||||||
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
|
|
||||||
},
|
|
||||||
{ field: 'collation', headerName: 'Collation', width: 180 },
|
|
||||||
{ field: 'comment', headerName: 'Comment', flex: 1, minWidth: 150 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
borderRadius: 3,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
border: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
|
||||||
}}>
|
|
||||||
<DataGrid
|
|
||||||
rows={tableRows}
|
|
||||||
columns={columns}
|
|
||||||
loading={loading}
|
|
||||||
density="comfortable"
|
|
||||||
sx={{ border: 'none' }}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden', gap: 2 }}>
|
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden', gap: 2 }}>
|
||||||
{/* Database Navigation Tabs */}
|
{/* Database Navigation Tabs */}
|
||||||
@@ -444,7 +539,7 @@ const MainContent: React.FC = () => {
|
|||||||
{!activeTable ? (
|
{!activeTable ? (
|
||||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>Database Tables: {activeDatabase}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 800 }}>Database Tables: {activeDatabase}</Typography>
|
||||||
<DatabaseTablesGrid database={activeDatabase} />
|
<DatabaseTablesGrid database={activeDatabase} setNotification={setNotification} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -546,7 +641,7 @@ const MainContent: React.FC = () => {
|
|||||||
{dbTab === 'export' && <TransferContent mode="export" />}
|
{dbTab === 'export' && <TransferContent mode="export" />}
|
||||||
|
|
||||||
{/* Technical Info View */}
|
{/* Technical Info View */}
|
||||||
{dbTab === 'info' && <TechnicalOverview database={activeDatabase} table={activeTable} />}
|
{dbTab === 'info' && <TechnicalOverview database={activeDatabase} table={activeTable} setNotification={setNotification} />}
|
||||||
|
|
||||||
{/* Custom Notification (Snackbar) */}
|
{/* Custom Notification (Snackbar) */}
|
||||||
<Snackbar open={notification.open} autoHideDuration={6000} onClose={handleCloseNotification} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
<Snackbar open={notification.open} autoHideDuration={6000} onClose={handleCloseNotification} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export const SchemaService = {
|
|||||||
getTableMetadata: (db: string, table: string) => api.get(`/schema/metadata/${db}/${table}`, { params: { database: db } }),
|
getTableMetadata: (db: string, table: string) => api.get(`/schema/metadata/${db}/${table}`, { params: { database: db } }),
|
||||||
getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }),
|
getTableSchema: (table: string, database?: string) => api.get(`/schema/${table}`, { params: { database } }),
|
||||||
getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }),
|
getTableData: (table: string, params: any) => api.get(`/schema/${table}/data`, { params }),
|
||||||
truncateTable: (table: string) => api.post(`/schema/truncate/${table}`),
|
truncateTable: (table: string, database?: string) => api.post(`/schema/truncate/${table}`, { database }),
|
||||||
|
dropTable: (table: string, database?: string) => api.post(`/schema/drop/${table}`, { database }),
|
||||||
|
optimizeTable: (table: string, database?: string) => api.post(`/schema/optimize/${table}`, { database }),
|
||||||
|
bulkAction: (data: { tables: string[], action: string, database: string }) => api.post('/schema/bulk-action', data),
|
||||||
executeQuery: (query: string) => api.post('/schema/execute', { query }),
|
executeQuery: (query: string) => api.post('/schema/execute', { query }),
|
||||||
exportDatabase: (database?: string, table?: string) => api.post('/schema/export', { database, table }, { responseType: 'blob' }),
|
exportDatabase: (database?: string, table?: string) => api.post('/schema/export', { database, table }, { responseType: 'blob' }),
|
||||||
importDatabase: (formData: FormData) => api.post('/schema/import', formData, {
|
importDatabase: (formData: FormData) => api.post('/schema/import', formData, {
|
||||||
|
|||||||
Reference in New Issue
Block a user