Files
mariavel/frontend/src/components/MainContent.tsx
T

662 lines
23 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import {
Box,
Paper,
Typography,
CircularProgress,
Button,
Divider,
IconButton,
Tooltip,
Snackbar,
Alert,
AlertTitle,
Tabs,
Tab,
Stack,
Checkbox
} from '@mui/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';
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 { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore();
const [columns, setColumns] = useState<GridColDef[]>([]);
const [rows, setRows] = useState<any[]>([]);
const [rowCount, setRowCount] = useState(0);
const [loadingSchema, setLoadingSchema] = useState(false);
const [loadingData, setLoadingData] = useState(false);
const [sqlQuery, setSqlQuery] = useState('');
const [isCustomQuery, setIsCustomQuery] = useState(false);
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 100,
});
// Custom Notification State
const [notification, setNotification] = useState<MainNotification>({
open: false,
message: '',
title: '',
severity: 'error'
});
const handleCloseNotification = () => setNotification({ ...notification, open: false });
// Helper to map SQL types to MUI X Data Grid types
const mapSqlTypeToMuiType = (sqlType: string): 'string' | 'number' | 'date' | 'dateTime' | 'boolean' => {
sqlType = sqlType.toLowerCase();
if (sqlType.includes('int') || sqlType.includes('decimal') || sqlType.includes('float') || sqlType.includes('double')) return 'number';
if (sqlType.includes('datetime') || sqlType.includes('timestamp')) return 'dateTime';
if (sqlType.includes('date')) return 'date';
if (sqlType.includes('bool') || sqlType.includes('tinyint(1)')) return 'boolean';
return 'string';
};
// Set initial SQL query when table changes
useEffect(() => {
if (activeTable && activeDatabase) {
setSqlQuery(`SELECT * FROM ${activeDatabase}.${activeTable} LIMIT 1000;`);
setIsCustomQuery(false);
}
}, [activeTable, activeDatabase]);
// Fetch Schema
useEffect(() => {
const fetchSchema = async () => {
if (!activeTable || !activeDatabase || isCustomQuery) return;
setLoadingSchema(true);
try {
const schemaRes = await SchemaService.getTableSchema(activeTable, activeDatabase);
const cols: GridColDef[] = schemaRes.data.map((col: any) => {
const type = mapSqlTypeToMuiType(col.Type);
return {
field: col.Field,
headerName: col.Field,
type: type,
width: 200,
minWidth: 100,
valueGetter: (value: any) => {
if ((type === 'date' || type === 'dateTime') && value && typeof value === 'string') {
return new Date(value);
}
return value;
},
};
});
setColumns(cols);
setPaginationModel({ page: 0, pageSize: 100 });
} catch (error) {
console.error('Failed to fetch table schema', error);
} finally {
setLoadingSchema(false);
}
};
fetchSchema();
}, [activeTable, activeDatabase, isCustomQuery]);
// Fetch Data
const fetchData = useCallback(async () => {
if (!activeTable || !activeDatabase || isCustomQuery) return;
setLoadingData(true);
try {
const params = {
skip: paginationModel.page * paginationModel.pageSize,
take: paginationModel.pageSize,
requireTotalCount: true,
database: activeDatabase,
};
const response = await SchemaService.getTableData(activeTable, params);
const dataWithIds = response.data.data.map((row: any, index: number) => ({
id: row.id || row.ID || `row-${index}-${paginationModel.page}`,
...row,
}));
setRows(dataWithIds);
setRowCount(response.data.totalCount || 0);
} catch (error) {
console.error('Data loading error', error);
} finally {
setLoadingData(false);
}
}, [activeTable, activeDatabase, paginationModel, isCustomQuery]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleExecute = async () => {
if (!sqlQuery) return;
setLoadingData(true);
setIsCustomQuery(true);
try {
const response = await SchemaService.executeQuery(sqlQuery);
const rawData = response.data.data;
if (rawData && rawData.length > 0) {
const fields = Object.keys(rawData[0]);
const newCols: GridColDef[] = fields.map(field => ({
field,
headerName: field,
width: 150,
flex: fields.length < 6 ? 1 : 0
}));
setColumns(newCols);
const dataWithIds = rawData.map((row: any, index: number) => ({
id: row.id || row.ID || `row-${index}`,
...row,
}));
setRows(dataWithIds);
setRowCount(response.data.count);
} else {
setRows([]);
setRowCount(0);
}
} catch (error: any) {
console.error('Execution error', error);
const msg = error.response?.data?.error || 'An unexpected error occurred during SQL execution.';
setNotification({
open: true,
title: 'SQL Execution Error',
message: msg,
severity: 'error'
});
} finally {
setLoadingData(false);
}
};
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 database to start</Typography>
</Box>
);
}
const handleTabChange = (_: React.SyntheticEvent, newValue: string) => {
setDbTab(newValue);
};
return (
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, overflow: 'hidden', gap: 2 }}>
{/* Database Navigation Tabs */}
<Paper elevation={0} sx={{
flexShrink: 0,
borderRadius: 3,
border: 1,
borderColor: 'divider',
bgcolor: darkMode ? '#111827' : '#ffffff',
overflow: 'hidden'
}}>
<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>
{/* 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', gap: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>Database Tables: {activeDatabase}</Typography>
<DatabaseTablesGrid database={activeDatabase} setNotification={setNotification} />
</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}
loading={loadingData}
sx={{ border: 'none' }}
/>
</Paper>
</Box>
)}
{/* Import/Export Views */}
{dbTab === 'import' && <TransferContent mode="import" />}
{dbTab === 'export' && <TransferContent mode="export" />}
{/* Technical Info View */}
{dbTab === 'info' && <TechnicalOverview database={activeDatabase} table={activeTable} setNotification={setNotification} />}
{/* Custom Notification (Snackbar) */}
<Snackbar open={notification.open} autoHideDuration={6000} onClose={handleCloseNotification} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
<Alert onClose={handleCloseNotification} severity={notification.severity} variant="filled" sx={{
width: '100%',
borderRadius: 2,
boxShadow: notification.severity === 'success' ? '0 8px 24px rgba(46, 125, 50, 0.25)' : '0 8px 24px rgba(211, 47, 47, 0.25)'
}}>
<AlertTitle sx={{ fontWeight: 800 }}>{notification.title}</AlertTitle>
<Typography variant="body2">{notification.message}</Typography>
</Alert>
</Snackbar>
</Box>
);
};
export default MainContent;