From ce67df1067e64dbec84895fac1da336180f28b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Fri, 24 Apr 2026 07:21:59 +0300 Subject: [PATCH] feat: implement dynamic database connection service with MySQL driver and API schema endpoints --- .../app/Contracts/DatabaseDriverInterface.php | 10 ++ .../Http/Controllers/Api/SchemaController.php | 12 ++ backend/app/Services/Database/MySqlDriver.php | 5 + backend/app/Services/DatabaseService.php | 8 + backend/routes/api.php | 1 + frontend/index.html | 5 +- frontend/src/App.tsx | 12 +- frontend/src/components/Login.tsx | 140 ++++++++++++++++++ frontend/src/components/MainContent.tsx | 128 ++++++++++------ frontend/src/components/Sidebar.tsx | 128 +++++++++++----- frontend/src/services/api.ts | 30 ++++ frontend/src/store/useAppStore.ts | 16 ++ 12 files changed, 417 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/Login.tsx create mode 100644 frontend/src/services/api.ts diff --git a/backend/app/Contracts/DatabaseDriverInterface.php b/backend/app/Contracts/DatabaseDriverInterface.php index 1c5dbe9..1497538 100644 --- a/backend/app/Contracts/DatabaseDriverInterface.php +++ b/backend/app/Contracts/DatabaseDriverInterface.php @@ -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. */ diff --git a/backend/app/Http/Controllers/Api/SchemaController.php b/backend/app/Http/Controllers/Api/SchemaController.php index d67eef2..323bf2b 100644 --- a/backend/app/Http/Controllers/Api/SchemaController.php +++ b/backend/app/Http/Controllers/Api/SchemaController.php @@ -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); + } + } } diff --git a/backend/app/Services/Database/MySqlDriver.php b/backend/app/Services/Database/MySqlDriver.php index 18d2a9e..a72ad6b 100644 --- a/backend/app/Services/Database/MySqlDriver.php +++ b/backend/app/Services/Database/MySqlDriver.php @@ -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 = " diff --git a/backend/app/Services/DatabaseService.php b/backend/app/Services/DatabaseService.php index 3ab2f88..770cb81 100644 --- a/backend/app/Services/DatabaseService.php +++ b/backend/app/Services/DatabaseService.php @@ -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); + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d1c00a0..ebf2b23 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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']); }); diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..040fe1b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,10 @@ - frontend + Mariavel - Premium Database Manager + + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc357b5..2d83a72 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + + + + + ); + } + return ( diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx new file mode 100644 index 0000000..792d4d4 --- /dev/null +++ b/frontend/src/components/Login.tsx @@ -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 ( + + + + + MARIAVEL + + + Connect to your MySQL Database + + + +
+ + setConfig({ ...config, host: e.target.value })} + InputProps={{ + startAdornment: , + }} + sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }} + /> + setConfig({ ...config, port: parseInt(e.target.value) })} + sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }} + /> + setConfig({ ...config, username: e.target.value })} + InputProps={{ + startAdornment: , + }} + sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }} + /> + setConfig({ ...config, password: e.target.value })} + InputProps={{ + startAdornment: , + }} + sx={{ '& .MuiOutlinedInput-root': { color: 'white' } }} + /> + + {error && ( + + {error} + + )} + + + +
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/components/MainContent.tsx b/frontend/src/components/MainContent.tsx index 47caae8..401a545 100644 --- a/frontend/src/components/MainContent.tsx +++ b/frontend/src/components/MainContent.tsx @@ -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([]); + const [columns, setColumns] = useState([]); + 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 ( + + Select a table to view data + + ); + } return ( - - + + - {activeDatabase ? `Tables in ${activeDatabase}` : 'Welcome to Mariavel'} - - - Manage your database tables with high-performance tools. + {activeDatabase}.{activeTable} - - - - - - - - - - - - - - - - + + + {loading ? ( + + + + ) : ( + + + + + + + + + {columns.map(col => ( + + ))} + + )} ); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f20767f..a3ecccf 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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([]); + const [tables, setTables] = useState([]); + 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 ( - - - - Databases - + + + + Explorer - - {databases.map((db) => ( - - setActiveDatabase(db)} - > - - - - + + + {loading ? ( + + ) : ( + + {databases.map((db) => ( + + + handleDatabaseClick(db)} + selected={activeDatabase === db} + sx={{ py: 1 }} > - {db} - - } - /> - - - ))} - + + + + {db}} + /> + + + + {activeDatabase === db && ( + + {tablesLoading ? ( + + ) : tables.length === 0 ? ( + No tables found + ) : tables.map((table) => ( + + setActiveTable(table)} + sx={{ py: 0.5 }} + > + {table}} + /> + + + ))} + + )} + + ))} + + )} + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..9ad4a18 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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 } }), +}; diff --git a/frontend/src/store/useAppStore.ts b/frontend/src/store/useAppStore.ts index da0cf38..67c345b 100644 --- a/frontend/src/store/useAppStore.ts +++ b/frontend/src/store/useAppStore.ts @@ -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((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 }), }));