feat: implement dynamic database connection service with MySQL driver and API schema endpoints

This commit is contained in:
Ümit Tunç
2026-04-24 07:21:59 +03:00
parent 1a75c32469
commit ce67df1067
12 changed files with 417 additions and 78 deletions
@@ -14,6 +14,16 @@ interface DatabaseDriverInterface
*/
public function query(string $sql, array $bindings = []): array;
/**
* Get the table schema.
*/
public function getTableSchema(string $table): array;
/**
* Get table data.
*/
public function getTableData(string $table, int $limit = 100, int $offset = 0): array;
/**
* Get the underlying connection instance.
*/
@@ -60,4 +60,16 @@ class SchemaController extends Controller
return Response::json(['error' => $e->getMessage()], 400);
}
}
public function data(Request $request, $table)
{
try {
$this->initializeDriver($request);
$limit = $request->get('limit', 100);
$offset = $request->get('offset', 0);
return Response::json($this->databaseService->getTableData($table, $limit, $offset));
} catch (\Exception $e) {
return Response::json(['error' => $e->getMessage()], 400);
}
}
}
@@ -64,6 +64,11 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
return $this->query("DESCRIBE `{$table}`");
}
public function getTableData(string $table, int $limit = 100, int $offset = 0): array
{
return $this->query("SELECT * FROM `{$table}` LIMIT ? OFFSET ?", [$limit, $offset]);
}
public function getForeignKeys(string $table): array
{
$sql = "
+8
View File
@@ -74,4 +74,12 @@ class DatabaseService
}
throw new \Exception("Driver does not support schema discovery.");
}
/**
* Get table data.
*/
public function getTableData(string $table, int $limit = 100, int $offset = 0): array
{
return $this->getDriver()->getTableData($table, $limit, $offset);
}
}
+1
View File
@@ -12,4 +12,5 @@ Route::prefix('schema')->group(function () {
Route::get('/databases', [SchemaController::class, 'databases']);
Route::get('/tables/{database}', [SchemaController::class, 'tables']);
Route::get('/{table}', [SchemaController::class, 'schema']);
Route::get('/{table}/data', [SchemaController::class, 'data']);
});
+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Mariavel - Premium Database Manager</title>
<!-- DevExtreme CDN -->
<link rel="stylesheet" href="https://cdn3.devexpress.com/jslib/23.1.5/css/dx.light.css">
<script type="text/javascript" src="https://cdn3.devexpress.com/jslib/23.1.5/js/dx.all.js"></script>
</head>
<body>
<div id="root"></div>
+11 -1
View File
@@ -5,9 +5,10 @@ import { useAppStore } from './store/useAppStore';
import Sidebar from './components/Sidebar.tsx';
import MainContent from './components/MainContent.tsx';
import Header from './components/Header.tsx';
import Login from './components/Login.tsx';
const App: React.FC = () => {
const darkMode = useAppStore((state) => state.darkMode);
const { darkMode, connected } = useAppStore();
const theme = useMemo(() => getTheme(darkMode ? 'dark' : 'light'), [darkMode]);
useEffect(() => {
@@ -18,6 +19,15 @@ const App: React.FC = () => {
}
}, [darkMode]);
if (!connected) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Login />
</ThemeProvider>
);
}
return (
<ThemeProvider theme={theme}>
<CssBaseline />
+140
View File
@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { Box, Paper, TextField, Button, Typography, Stack, InputAdornment } from '@mui/material';
import { Storage, Person, Lock, Dns } from '@mui/icons-material';
import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api';
const Login: React.FC = () => {
const setConnection = useAppStore((state) => state.setConnection);
const [config, setConfig] = useState({
host: '127.0.0.1',
username: 'root',
password: '',
port: 3306,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// Temporarily set connection to test connectivity
// In a real app, we'd have a specific /connect endpoint
// Here we just try to fetch databases to verify
const tempConnection = { ...config };
setConnection(tempConnection);
await SchemaService.getDatabases();
// If success, we are already "connected" in the store
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to connect to database');
useAppStore.getState().clearConnection();
} finally {
setLoading(false);
}
};
return (
<Box sx={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)'
}}>
<Paper elevation={24} sx={{
p: 4,
width: '100%',
maxWidth: 400,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: 'white'
}}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#0061ff', mb: 1 }}>
MARIAVEL
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)' }}>
Connect to your MySQL Database
</Typography>
</Box>
<form onSubmit={handleConnect}>
<Stack spacing={3}>
<TextField
label="Host"
variant="outlined"
fullWidth
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value })}
InputProps={{
startAdornment: <InputAdornment position="start"><Dns sx={{ color: 'primary.main' }} /></InputAdornment>,
}}
sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }}
/>
<TextField
label="Port"
variant="outlined"
fullWidth
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) })}
sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }}
/>
<TextField
label="Username"
variant="outlined"
fullWidth
value={config.username}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
InputProps={{
startAdornment: <InputAdornment position="start"><Person sx={{ color: 'primary.main' }} /></InputAdornment>,
}}
sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }}
/>
<TextField
label="Password"
variant="outlined"
fullWidth
type="password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
InputProps={{
startAdornment: <InputAdornment position="start"><Lock sx={{ color: 'primary.main' }} /></InputAdornment>,
}}
sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }}
/>
{error && (
<Typography variant="caption" color="error" sx={{ textAlign: 'center' }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
size="large"
disabled={loading}
sx={{
py: 1.5,
fontWeight: 700,
background: 'linear-gradient(90deg, #0061ff 0%, #60a5fa 100%)',
boxShadow: '0 8px 16px rgba(0, 97, 255, 0.3)'
}}
>
{loading ? 'Connecting...' : 'Connect Now'}
</Button>
</Stack>
</form>
</Paper>
</Box>
);
};
export default Login;
+76 -32
View File
@@ -1,59 +1,103 @@
import React from 'react';
import { Box, Paper, Typography } from '@mui/material';
import React, { useEffect, useState } from 'react';
import { Box, Paper, Typography, CircularProgress } from '@mui/material';
import DataGrid, {
Column,
Editing,
Scrolling,
Paging,
FilterRow,
HeaderFilter
HeaderFilter,
SearchPanel,
GroupPanel,
Export
} from 'devextreme-react/data-grid';
import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api';
const MainContent: React.FC = () => {
const { activeDatabase } = useAppStore();
const { activeTable, activeDatabase } = useAppStore();
const [data, setData] = useState<any[]>([]);
const [columns, setColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// Mock data for initial UI
const dummyData = [
{ id: 1, name: 'Users', type: 'BASE TABLE', engine: 'InnoDB', rows: 1500, size: '256 KB' },
{ id: 2, name: 'Products', type: 'BASE TABLE', engine: 'InnoDB', rows: 54200, size: '12 MB' },
{ id: 3, name: 'Orders', type: 'BASE TABLE', engine: 'InnoDB', rows: 120400, size: '45 MB' },
{ id: 4, name: 'Order_Items', type: 'BASE TABLE', engine: 'InnoDB', rows: 450000, size: '120 MB' },
];
useEffect(() => {
const fetchTableData = async () => {
if (!activeTable || !activeDatabase) return;
setLoading(true);
try {
// Fetch schema for columns
const schemaRes = await SchemaService.getTableSchema(activeTable);
const cols = schemaRes.data.map((col: any) => ({
dataField: col.Field,
caption: col.Field,
dataType: mapSqlTypeToDxType(col.Type)
}));
setColumns(cols);
// Fetch actual data
const dataRes = await SchemaService.getTableData(activeTable, activeDatabase);
setData(dataRes.data);
} catch (error) {
console.error('Failed to fetch table data', error);
} finally {
setLoading(false);
}
};
fetchTableData();
}, [activeTable, activeDatabase]);
// Helper to map SQL types to DevExtreme types
const mapSqlTypeToDxType = (sqlType: string) => {
sqlType = sqlType.toLowerCase();
if (sqlType.includes('int') || sqlType.includes('decimal') || sqlType.includes('float')) return 'number';
if (sqlType.includes('date') || sqlType.includes('time')) return 'date';
if (sqlType.includes('bool')) return 'boolean';
return 'string';
};
if (!activeTable) {
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>
</Box>
);
}
return (
<Box sx={{ flexGrow: 1, p: 3, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Box sx={{ flexGrow: 1, p: 3, bgcolor: 'background.default', overflow: 'hidden' }}>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{activeDatabase ? `Tables in ${activeDatabase}` : 'Welcome to Mariavel'}
</Typography>
<Typography variant="body2" color="text.secondary">
Manage your database tables with high-performance tools.
{activeDatabase}.{activeTable}
</Typography>
</Box>
<Paper sx={{ flexGrow: 1, p: 0, overflow: 'hidden', borderRadius: 3 }}>
<Paper sx={{ height: 'calc(100vh - 180px)', borderRadius: 2, overflow: 'hidden' }}>
{loading ? (
<Box sx={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : (
<DataGrid
dataSource={dummyData}
keyExpr="id"
showBorders={false}
dataSource={data}
showBorders={true}
focusedRowEnabled={true}
columnAutoWidth={true}
allowColumnReordering={true}
rowAlternationEnabled={true}
height="100%"
width="100%"
>
<Paging enabled={false} />
<Scrolling mode="virtual" />
<FilterRow visible={true} />
<HeaderFilter visible={true} />
<Editing mode="cell" allowUpdating={true} allowDeleting={true} />
<SearchPanel visible={true} width={240} placeholder="Search..." />
<GroupPanel visible={true} />
<Export enabled={true} allowExportSelectedData={true} />
<Column dataField="id" width={50} />
<Column dataField="name" caption="Table Name" />
<Column dataField="type" />
<Column dataField="engine" width={100} />
<Column dataField="rows" dataType="number" format="fixedPoint" width={100} />
<Column dataField="size" width={100} />
{columns.map(col => (
<Column key={col.dataField} {...col} />
))}
</DataGrid>
)}
</Paper>
</Box>
);
+83 -23
View File
@@ -1,45 +1,105 @@
import React from 'react';
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Typography, Divider } from '@mui/material';
import { Storage, TableChart, Folder } from '@mui/icons-material';
import React, { useEffect, useState } from 'react';
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Typography, Divider, CircularProgress } from '@mui/material';
import { Storage } from '@mui/icons-material';
import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api';
const Sidebar: React.FC = () => {
const { activeDatabase, setActiveDatabase } = useAppStore();
const { activeDatabase, setActiveDatabase, setActiveTable, activeTable } = useAppStore();
const [databases, setDatabases] = useState<string[]>([]);
const [tables, setTables] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [tablesLoading, setTablesLoading] = useState(false);
// Mock data for initial UI
const databases = ['information_schema', 'mysql', 'performance_schema', 'sys', 'mariavel_db'];
useEffect(() => {
const fetchDatabases = async () => {
try {
const response = await SchemaService.getDatabases();
setDatabases(response.data);
} catch (error) {
console.error('Failed to fetch databases', error);
} finally {
setLoading(false);
}
};
fetchDatabases();
}, []);
const handleDatabaseClick = async (db: string) => {
if (activeDatabase === db) {
setActiveDatabase(null);
setTables([]);
return;
}
setActiveDatabase(db);
setTablesLoading(true);
try {
const response = await SchemaService.getTables(db);
setTables(response.data);
} catch (error) {
console.error('Failed to fetch tables', error);
} finally {
setTablesLoading(false);
}
};
return (
<Box sx={{ width: 260, borderRight: 1, borderColor: 'divider', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2 }}>
<Typography variant="overline" sx={{ fontWeight: 700, color: 'text.secondary' }}>
Databases
</Typography>
<Box sx={{ width: 260, borderRight: 1, borderColor: 'divider', display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Storage color="primary" />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.1rem' }}>Explorer</Typography>
</Box>
<List sx={{ flexGrow: 1, overflow: 'auto' }}>
<Divider />
<Box sx={{ flexGrow: 1, overflowY: 'auto' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress size={24} /></Box>
) : (
<List dense sx={{ p: 0 }}>
{databases.map((db) => (
<ListItem key={db} disablePadding>
<React.Fragment key={db}>
<ListItem disablePadding>
<ListItemButton
onClick={() => handleDatabaseClick(db)}
selected={activeDatabase === db}
onClick={() => setActiveDatabase(db)}
sx={{ py: 1 }}
>
<ListItemIcon>
<Storage color={activeDatabase === db ? 'primary' : 'inherit'} />
<ListItemIcon sx={{ minWidth: 36 }}>
<Storage fontSize="small" color={activeDatabase === db ? 'primary' : 'inherit'} />
</ListItemIcon>
<ListItemText
primary={
<Typography
variant="body2"
sx={{ fontWeight: activeDatabase === db ? 600 : 400 }}
primary={<Typography variant="body2" sx={{ fontWeight: activeDatabase === db ? 600 : 400 }}>{db}</Typography>}
/>
</ListItemButton>
</ListItem>
{activeDatabase === db && (
<List dense sx={{ pl: 4, bgcolor: 'rgba(0,0,0,0.02)' }}>
{tablesLoading ? (
<ListItem><CircularProgress size={16} /></ListItem>
) : tables.length === 0 ? (
<ListItem><Typography variant="caption" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>No tables found</Typography></ListItem>
) : tables.map((table) => (
<ListItem key={table} disablePadding>
<ListItemButton
selected={activeTable === table}
onClick={() => setActiveTable(table)}
sx={{ py: 0.5 }}
>
{db}
</Typography>
}
<ListItemText
primary={<Typography variant="caption" sx={{ fontWeight: activeTable === table ? 600 : 400 }}>{table}</Typography>}
/>
</ListItemButton>
</ListItem>
))}
</List>
)}
</React.Fragment>
))}
</List>
)}
</Box>
<Divider />
<Box sx={{ p: 2 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios';
import { useAppStore } from '../store/useAppStore';
const api = axios.create({
baseURL: 'http://localhost:8000/api',
});
api.interceptors.request.use((config) => {
const connection = useAppStore.getState().connection;
if (connection) {
config.params = {
...config.params,
host: connection.host,
username: connection.username,
password: connection.password,
port: connection.port,
database: connection.database || config.params?.database,
};
}
return config;
});
export default api;
export const SchemaService = {
getDatabases: () => api.get('/schema/databases'),
getTables: (db: string) => api.get(`/schema/tables/${db}`),
getTableSchema: (table: string) => api.get(`/schema/${table}`),
getTableData: (table: string, database?: string) => api.get(`/schema/${table}/data`, { params: { database } }),
};
+16
View File
@@ -1,10 +1,22 @@
import { create } from 'zustand';
interface ConnectionConfig {
host: string;
username: string;
password?: string;
port: number;
database?: string;
}
interface AppState {
darkMode: boolean;
activeDatabase: string | null;
activeTable: string | null;
connection: ConnectionConfig | null;
connected: boolean;
toggleDarkMode: () => void;
setConnection: (config: ConnectionConfig) => void;
clearConnection: () => void;
setActiveDatabase: (db: string | null) => void;
setActiveTable: (table: string | null) => void;
}
@@ -13,7 +25,11 @@ export const useAppStore = create<AppState>((set) => ({
darkMode: true,
activeDatabase: null,
activeTable: null,
connection: null,
connected: false,
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
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 }),
}));