Compare commits

..

10 Commits

15 changed files with 1067 additions and 309 deletions
+64 -38
View File
@@ -1,58 +1,84 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # Mariavel Backend - Database Management API
<p align="center"> This is the backend API for Mariavel, built with Laravel 11. It provides a secure and robust abstraction layer for interacting with MariaDB/MySQL databases, managing schema metadata, and performing administrative operations.
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## 🛠️ Core Responsibilities
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - **Schema Discovery**: Dynamically fetches databases, tables, and column metadata using system catalogs (`information_schema`).
- **SQL Execution**: Executes raw SQL queries and returns structured data with row counts and execution metrics.
- **Administrative Operations**: Handles bulk table maintenance (Truncate, Drop, Optimize) and technical specifications.
- **Database Backups**: Integrates with system tools like `mysqldump` to generate full database exports.
- **Import Management**: Processes SQL files and ZIP archives, handling file extraction and sequence execution.
- **Data Transformation**: Maps database-specific types to frontend-friendly formats.
- [Simple, fast routing engine](https://laravel.com/docs/routing). ## 🏗️ Architecture & Structure
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. The backend follows a service-oriented architecture to ensure separation of concerns and database-agnostic potential:
## Learning Laravel ### Controller Layer
- `app/Http/Controllers/Api/SchemaController.php`: Exposes REST endpoints for the frontend, handling request validation and response formatting.
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. ### Service Layer (Abstraction)
- `app/Services/DatabaseService.php`: The main entry point for database operations. It orchestrates high-level tasks and manages driver interactions.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ### Driver Layer (Implementation)
- `app/Services/Database/MySqlDriver.php`: Contains the concrete logic for MySQL/MariaDB. It encapsulates raw SQL generation and system command execution.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. ## 📂 Directory Structure
## Agentic Development ```
app/
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: ├── Http/
│ └── Controllers/
```bash │ └── Api/ # API Endpoints (SchemaController)
composer require laravel/boost --dev ├── Services/
│ ├── Database/ # Driver implementations (MySqlDriver)
php artisan boost:install │ └── DatabaseService.php # Main database abstraction service
├── Models/ # Eloquent models (if applicable)
└── Providers/ # Service providers for dependency injection
routes/
└── api.php # API route definitions
storage/
└── app/backups/ # Temporary storage for export/import files
``` ```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. ## 🚥 Getting Started
## Contributing ### Prerequisites
- PHP 8.2+
- Composer
- MySQL/MariaDB server
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). ### Installation
## Code of Conduct 1. Install dependencies:
```bash
composer install
```
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 2. Configure environment:
```bash
cp .env.example .env
php artisan key:generate
```
## Security Vulnerabilities 3. Update `.env` with your database credentials:
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database
DB_USERNAME=your_username
DB_PASSWORD=your_password
```
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. 4. Run the development server:
```bash
php artisan serve
```
## License ## 🔒 Security
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). - All raw SQL execution is handled with caution.
- File operations are restricted to specific storage directories.
- Ensure the API is protected by authentication in production environments.
@@ -22,12 +22,12 @@ interface DatabaseDriverInterface
/** /**
* Get table data. * Get table data.
*/ */
public function getTableData(string $table, int $limit = 100, int $offset = 0): array; public function getTableData(string $table, int $limit = 100, int $offset = 0, array $filters = []): array;
/** /**
* Get the count of rows in a table. * Get the count of rows in a table.
*/ */
public function getTableCount(string $table): int; public function getTableCount(string $table, array $filters = []): int;
/** /**
* Get the underlying connection instance. * Get the underlying connection instance.
@@ -37,7 +37,7 @@ interface DatabaseDriverInterface
/** /**
* Export the database. * Export the database.
*/ */
public function export(array $config): string; public function export(array $config, array $filters = []): string;
/** /**
* Import the database. * Import the database.
@@ -48,4 +48,34 @@ interface DatabaseDriverInterface
* Get database metadata (charset, collation, size, etc.) * Get database metadata (charset, collation, size, etc.)
*/ */
public function getDatabaseMetadata(string $database): array; public function getDatabaseMetadata(string $database): array;
/**
* Get table metadata.
*/
public function getTableMetadata(string $database, string $table): array;
/**
* Get metadata for all tables in a database.
*/
public function getTablesMetadata(string $database): array;
/**
* Truncate a table.
*/
public function truncateTable(string $table): bool;
/**
* Drop a table.
*/
public function dropTable(string $table): bool;
/**
* Optimize a table.
*/
public function optimizeTable(string $table): bool;
/**
* Perform batch update on a table.
*/
public function batchUpdate(string $table, array $changes): bool;
} }
@@ -68,15 +68,16 @@ class SchemaController extends Controller
$skip = $request->get('skip', 0); $skip = $request->get('skip', 0);
$take = $request->get('take', 100); $take = $request->get('take', 100);
$filters = json_decode($request->get('filters', '[]'), true);
$data = $this->databaseService->getTableData($table, $take, $skip); $data = $this->databaseService->getTableData($table, $take, $skip, $filters);
$response = [ $response = [
'data' => $data, 'data' => $data,
]; ];
if ($request->get('requireTotalCount') === 'true') { if ($request->get('requireTotalCount') === 'true') {
$response['totalCount'] = $this->databaseService->getTableCount($table); $response['totalCount'] = $this->databaseService->getTableCount($table, $filters);
} }
return Response::json($response); return Response::json($response);
@@ -110,7 +111,9 @@ class SchemaController extends Controller
try { try {
$this->initializeDriver($request); $this->initializeDriver($request);
$config = $request->only(['host', 'username', 'password', 'database', 'port', 'table']); $config = $request->only(['host', 'username', 'password', 'database', 'port', 'table']);
$filePath = $this->databaseService->export($config); $filters = json_decode($request->get('filters', '[]'), true);
$filePath = $this->databaseService->export($config, $filters);
return Response::download($filePath)->deleteFileAfterSend(true); return Response::download($filePath)->deleteFileAfterSend(true);
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -248,4 +251,19 @@ class SchemaController extends Controller
return Response::json(['error' => $e->getMessage()], 400); return Response::json(['error' => $e->getMessage()], 400);
} }
} }
public function batchUpdate(Request $request, $table)
{
$request->validate([
'changes' => 'required|array',
]);
try {
$this->initializeDriver($request);
$this->databaseService->batchUpdate($table, $request->changes);
return Response::json(['message' => 'Batch update successful']);
} catch (\Exception $e) {
return Response::json(['error' => $e->getMessage()], 400);
}
}
} }
+152 -6
View File
@@ -64,15 +64,68 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
return $this->query("DESCRIBE `{$table}`"); return $this->query("DESCRIBE `{$table}`");
} }
public function getTableData(string $table, int $limit = 100, int $offset = 0): array public function getTableData(string $table, int $limit = 100, int $offset = 0, array $filters = []): array
{ {
return $this->query("SELECT * FROM `{$table}` LIMIT ? OFFSET ?", [$limit, $offset]); $query = DB::connection($this->connectionName)->table($table);
$this->applyFilters($query, $filters);
return $query->limit($limit)->offset($offset)->get()->all();
} }
public function getTableCount(string $table): int public function getTableCount(string $table, array $filters = []): int
{ {
$result = $this->query("SELECT COUNT(*) as count FROM `{$table}`"); $query = DB::connection($this->connectionName)->table($table);
return (int) ($result[0]->count ?? 0); $this->applyFilters($query, $filters);
return $query->count();
}
protected function applyFilters($query, array $filters)
{
if (empty($filters)) return $query;
foreach ($filters as $filter) {
$field = $filter['field'] ?? ($filter['columnField'] ?? null);
$operator = $filter['operator'] ?? ($filter['operatorValue'] ?? null);
$value = $filter['value'] ?? null;
if (!$field) continue;
switch ($operator) {
case 'contains':
$query->where($field, 'LIKE', "%{$value}%");
break;
case 'equals':
case '=':
$query->where($field, '=', $value);
break;
case 'startsWith':
$query->where($field, 'LIKE', "{$value}%");
break;
case 'endsWith':
$query->where($field, 'LIKE', "%{$value}");
break;
case 'isEmpty':
$query->where(function($q) use ($field) {
$q->whereNull($field)->orWhere($field, '');
});
break;
case 'isNotEmpty':
$query->whereNotNull($field)->where($field, '!=', '');
break;
case 'isAnyOf':
if (is_array($value)) {
$query->whereIn($field, $value);
}
break;
case '>':
case '<':
case '>=':
case '<=':
case '!=':
$query->where($field, $operator, $value);
break;
}
}
return $query;
} }
public function getForeignKeys(string $table): array public function getForeignKeys(string $table): array
@@ -94,11 +147,16 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
return $this->query($sql, [$table, $dbName]); return $this->query($sql, [$table, $dbName]);
} }
public function export(array $config): string public function export(array $config, array $filters = []): string
{ {
$database = $config['database'] ?? ''; $database = $config['database'] ?? '';
$table = $config['table'] ?? ''; $table = $config['table'] ?? '';
// If filters are provided, we do a manual export for the filtered rows
if (!empty($filters) && !empty($table)) {
return $this->exportFilteredTable($database, $table, $filters);
}
$filename = !empty($table) $filename = !empty($table)
? "{$table}-" . date('Y-m-d') . ".sql" ? "{$table}-" . date('Y-m-d') . ".sql"
: "backup-" . ($database ?: 'all') . "-" . date('Y-m-d') . ".sql"; : "backup-" . ($database ?: 'all') . "-" . date('Y-m-d') . ".sql";
@@ -302,4 +360,92 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface
return $this->query($sql, [$database]); return $this->query($sql, [$database]);
} }
public function batchUpdate(string $table, array $changes): bool
{
$connection = $this->getConnection();
// Find primary key
$schema = $this->getTableSchema($table);
$primaryKey = 'id'; // default
foreach ($schema as $col) {
if (($col->Key ?? '') === 'PRI') {
$primaryKey = $col->Field;
break;
}
}
$connection->beginTransaction();
try {
foreach ($changes as $change) {
if (!isset($change[$primaryKey])) {
continue;
}
$id = $change[$primaryKey];
$updateData = $change;
unset($updateData[$primaryKey]);
if (empty($updateData)) continue;
$connection->table($table)->where($primaryKey, $id)->update($updateData);
}
$connection->commit();
return true;
} catch (\Exception $e) {
$connection->rollBack();
throw $e;
}
}
protected function exportFilteredTable(string $database, string $table, array $filters): string
{
$query = DB::connection($this->connectionName)->table($table);
$this->applyFilters($query, $filters);
$rows = $query->get();
$filename = "{$table}-filtered-" . date('Y-m-d-His') . ".sql";
$directory = storage_path('app/backups');
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
$path = $directory . DIRECTORY_SEPARATOR . $filename;
$handle = fopen($path, 'w');
fwrite($handle, "-- Mariavel Filtered SQL Export\n");
fwrite($handle, "-- Database: {$database}\n");
fwrite($handle, "-- Table: {$table}\n");
fwrite($handle, "-- Date: " . date('Y-m-d H:i:s') . "\n\n");
$createTableResults = $this->query("SHOW CREATE TABLE `{$table}`");
if (!empty($createTableResults)) {
$createTable = $createTableResults[0];
$createSql = $createTable->{'Create Table'} ?? '';
if ($createSql) {
fwrite($handle, "DROP TABLE IF EXISTS `{$table}`;\n");
fwrite($handle, $createSql . ";\n\n");
}
}
if ($rows->count() > 0) {
foreach ($rows as $row) {
$fields = [];
$values = [];
foreach ((array)$row as $field => $value) {
$fields[] = "`{$field}`";
if (is_null($value)) {
$values[] = "NULL";
} else {
$values[] = DB::connection($this->connectionName)->getPdo()->quote($value);
}
}
$sql = "INSERT INTO `{$table}` (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $values) . ");\n";
fwrite($handle, $sql);
}
}
fclose($handle);
return $path;
}
} }
+14 -6
View File
@@ -78,17 +78,17 @@ class DatabaseService
/** /**
* Get table data. * Get table data.
*/ */
public function getTableData(string $table, int $limit = 100, int $offset = 0): array public function getTableData(string $table, int $limit = 100, int $offset = 0, array $filters = []): array
{ {
return $this->getDriver()->getTableData($table, $limit, $offset); return $this->getDriver()->getTableData($table, $limit, $offset, $filters);
} }
/** /**
* Get table row count. * Get table row count.
*/ */
public function getTableCount(string $table): int public function getTableCount(string $table, array $filters = []): int
{ {
return $this->getDriver()->getTableCount($table); return $this->getDriver()->getTableCount($table, $filters);
} }
/** /**
@@ -102,9 +102,9 @@ class DatabaseService
/** /**
* Export the database. * Export the database.
*/ */
public function export(array $config): string public function export(array $config, array $filters = []): string
{ {
return $this->getDriver()->export($config); return $this->getDriver()->export($config, $filters);
} }
/** /**
@@ -156,4 +156,12 @@ class DatabaseService
{ {
return $this->getDriver()->getTablesMetadata($database); return $this->getDriver()->getTablesMetadata($database);
} }
/**
* Perform batch update on a table.
*/
public function batchUpdate(string $table, array $changes): bool
{
return $this->getDriver()->batchUpdate($table, $changes);
}
} }
+1
View File
@@ -20,6 +20,7 @@ Route::prefix('schema')->group(function () {
Route::post('/bulk-action', [SchemaController::class, 'bulkAction']); 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('/{table}/batch-update', [SchemaController::class, 'batchUpdate']);
Route::post('/execute', [SchemaController::class, 'execute']); Route::post('/execute', [SchemaController::class, 'execute']);
Route::post('/export', [SchemaController::class, 'export']); Route::post('/export', [SchemaController::class, 'export']);
Route::post('/import', [SchemaController::class, 'import']); Route::post('/import', [SchemaController::class, 'import']);
+56 -60
View File
@@ -1,73 +1,69 @@
# React + TypeScript + Vite # Mariavel - Modern Database Management Tool (Frontend)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Mariavel is a premium, glassmorphic database management interface built for MariaDB/MySQL. It provides an IDE-like experience for managing databases, tables, and executing complex SQL queries.
Currently, two official plugins are available: ## 🚀 Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - **Data Explorer**: High-performance data viewing using MUI X Data Grid with server-side pagination.
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - **SQL Console**: Interactive SQL editor powered by Monaco Editor (the core of VS Code) with syntax highlighting and direct execution.
- **Technical Metadata**: Detailed table and database specifications including engine, collation, and storage sizes.
- **Bulk Actions**: Perform maintenance tasks like Truncate, Drop, and Optimize on multiple tables simultaneously.
- **Transfer Tools**: Robust import/export capabilities supporting SQL files and compressed archives.
- **Responsive Design**: Modern, glassmorphic UI with dark mode support and a fluid layout.
## React Compiler ## 🏗️ Architecture & SOLID Principles
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). The application has been recently refactored to follow **SOLID** principles, ensuring a maintainable and scalable codebase:
## Expanding the ESLint configuration ### Single Responsibility Principle (SRP)
Components have been decoupled into specialized units:
- `DatabaseTablesGrid`: Dedicated to table listing and bulk operations.
- `SqlConsole`: Isolated SQL editing and result rendering.
- `TechnicalOverview`: Focused on metadata presentation.
- `MainContent`: Orchestrates high-level layout and navigation.
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: ### Logic Separation (Custom Hooks)
Business logic and data fetching are extracted into custom hooks:
- `useTableData`: Manages paginated data fetching and row mapping.
- `useTableSchema`: Handles column definitions and type mapping from SQL to UI.
```js ### Type Safety
export default defineConfig([ - **Verbatim Module Syntax**: Configured for strict type-only imports (`import type`), improving build performance and code clarity.
globalIgnores(['dist']), - **Centralized Types**: Shared interfaces are located in `src/types/database.ts`.
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this ## 🛠️ Technology Stack
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs... - **Framework**: [React](https://reactjs.org/) + [Vite](https://vitejs.dev/)
], - **Styling**: [Material UI (MUI)](https://mui.com/) with a custom glassmorphic theme.
languageOptions: { - **Editor**: [@monaco-editor/react](https://github.com/suren-atoyan/monaco-react)
parserOptions: { - **Data Grid**: [MUI X Data Grid](https://mui.com/x/react-data-grid/)
project: ['./tsconfig.node.json', './tsconfig.app.json'], - **State Management**: [Zustand](https://github.com/pmndrs/zustand)
tsconfigRootDir: import.meta.dirname, - **API Client**: Axios
},
// other options... ## 📂 Directory Structure
},
}, ```
]) src/
├── components/ # Specialized UI components (DatabaseTablesGrid, SqlConsole, etc.)
├── hooks/ # Custom hooks for logic separation (useTableData, useTableSchema)
├── services/ # API service layers
├── store/ # Zustand global state management
├── types/ # Shared TypeScript interfaces
├── theme/ # MUI theme configuration
└── App.tsx # Main application entry point
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ## 🚥 Getting Started
```js 1. Install dependencies:
// eslint.config.js ```bash
import reactX from 'eslint-plugin-react-x' npm install
import reactDom from 'eslint-plugin-react-dom' ```
2. Start development server:
export default defineConfig([ ```bash
globalIgnores(['dist']), npm run dev
{ ```
files: ['**/*.{ts,tsx}'], 3. Build for production:
extends: [ ```bash
// Other configs... npm run build
// Enable lint rules for React ```
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+55 -38
View File
@@ -1,17 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Paper, Paper,
Typography, Typography,
Button, Button,
Stack, Stack,
Checkbox Checkbox
} from '@mui/material'; } from '@mui/material';
import { import {
TableChart, TableChart,
CleaningServices, CleaningServices,
Close, Close,
Save Save
} from '@mui/icons-material'; } from '@mui/icons-material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid'; import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { SchemaService } from '../services/api'; import { SchemaService } from '../services/api';
@@ -47,7 +47,7 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
const handleBulkAction = async (action: 'truncate' | 'drop' | 'optimize') => { const handleBulkAction = async (action: 'truncate' | 'drop' | 'optimize') => {
if (selectionModel.length === 0) return; if (selectionModel.length === 0) return;
const confirmMsg = `Are you sure you want to ${action} ${selectionModel.length} selected tables? This action is irreversible!`; const confirmMsg = `Are you sure you want to ${action} ${selectionModel.length} selected tables? This action is irreversible!`;
if (!window.confirm(confirmMsg)) return; if (!window.confirm(confirmMsg)) return;
@@ -79,7 +79,7 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
}; };
const toggleRow = (name: string) => { const toggleRow = (name: string) => {
setSelectionModel(prev => setSelectionModel(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name] prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
); );
}; };
@@ -100,16 +100,16 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
sortable: false, sortable: false,
filterable: false, filterable: false,
renderHeader: () => ( renderHeader: () => (
<Checkbox <Checkbox
size="small" size="small"
indeterminate={selectionModel.length > 0 && selectionModel.length < tableRows.length} indeterminate={selectionModel.length > 0 && selectionModel.length < tableRows.length}
checked={tableRows.length > 0 && selectionModel.length === tableRows.length} checked={tableRows.length > 0 && selectionModel.length === tableRows.length}
onChange={toggleAll} onChange={toggleAll}
/> />
), ),
renderCell: (params) => ( renderCell: (params) => (
<Checkbox <Checkbox
size="small" size="small"
checked={selectionModel.includes(params.row.name)} checked={selectionModel.includes(params.row.name)}
onChange={(e) => { onChange={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -118,14 +118,14 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
/> />
) )
}, },
{ {
field: 'name', field: 'name',
headerName: 'Table Name', headerName: 'Table Name',
flex: 1, flex: 1,
minWidth: 200, minWidth: 200,
renderCell: (params) => ( renderCell: (params) => (
<Button <Button
size="small" size="small"
startIcon={<TableChart fontSize="small" />} startIcon={<TableChart fontSize="small" />}
onClick={() => setActiveTable(params.value)} onClick={() => setActiveTable(params.value)}
sx={{ textTransform: 'none', fontWeight: 600, color: '#ffc107', '&:hover': { color: '#ffca28' } }} sx={{ textTransform: 'none', fontWeight: 600, color: '#ffc107', '&:hover': { color: '#ffca28' } }}
@@ -136,15 +136,15 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
}, },
{ field: 'engine', headerName: 'Engine', width: 120 }, { field: 'engine', headerName: 'Engine', width: 120 },
{ field: 'rows', headerName: 'Rows', type: 'number', width: 120 }, { field: 'rows', headerName: 'Rows', type: 'number', width: 120 },
{ {
field: 'data_length', field: 'data_length',
headerName: 'Data Size', headerName: 'Data Size',
width: 130, width: 130,
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
}, },
{ {
field: 'index_length', field: 'index_length',
headerName: 'Index Size', headerName: 'Index Size',
width: 130, width: 130,
valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB` valueFormatter: (value) => `${(Number(value) / 1024 / 1024).toFixed(2)} MB`
}, },
@@ -168,13 +168,13 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
<Button size="small" onClick={() => setSelectionModel([])}>Clear Selection</Button> <Button size="small" onClick={() => setSelectionModel([])}>Clear Selection</Button>
</Paper> </Paper>
)} )}
<Paper sx={{ <Paper sx={{
flexGrow: 1, flexGrow: 1,
borderRadius: 3, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
border: 1, border: 1,
borderColor: 'divider', borderColor: 'divider',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)' boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}> }}>
@@ -184,7 +184,24 @@ const DatabaseTablesGrid: React.FC<DatabaseTablesGridProps> = ({ database, setNo
loading={loading} loading={loading}
getRowId={(row: any) => row.name} getRowId={(row: any) => row.name}
density="comfortable" density="comfortable"
sx={{ border: 'none' }} sx={{
border: 'none',
'& .MuiDataGrid-row:nth-of-type(even)': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
},
'& .MuiDataGrid-cell': {
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeader': {
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeaders': {
borderBottom: '1px solid',
borderColor: 'divider',
}
}}
/> />
</Paper> </Paper>
</Box> </Box>
+471 -7
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Paper, Paper,
@@ -8,17 +8,27 @@ import {
Alert, Alert,
AlertTitle, AlertTitle,
Tabs, Tabs,
Tab Tab,
Button
} from '@mui/material'; } from '@mui/material';
import { import {
TableChart, TableChart,
Terminal, Terminal,
CloudDownload, CloudDownload,
CloudUpload, CloudUpload,
Info Info,
Save,
Undo
} from '@mui/icons-material'; } from '@mui/icons-material';
import { DataGrid, type GridPaginationModel } from '@mui/x-data-grid'; import {
DataGrid,
useGridApiRef,
type GridPaginationModel,
type GridRowModel,
type GridFilterModel
} from '@mui/x-data-grid';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { SchemaService } from '../services/api';
import TransferContent from './TransferContent'; import TransferContent from './TransferContent';
import DatabaseTablesGrid from './DatabaseTablesGrid'; import DatabaseTablesGrid from './DatabaseTablesGrid';
import TechnicalOverview from './TechnicalOverview'; import TechnicalOverview from './TechnicalOverview';
@@ -29,11 +39,13 @@ import type { MainNotification } from '../types/database';
const MainContent: React.FC = () => { const MainContent: React.FC = () => {
const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore(); const { activeTable, activeDatabase, darkMode, dbTab, setDbTab } = useAppStore();
const apiRef = useGridApiRef();
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({ const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0, page: 0,
pageSize: 100, pageSize: 100,
}); });
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const [notification, setNotification] = useState<MainNotification>({ const [notification, setNotification] = useState<MainNotification>({
open: false, open: false,
@@ -42,11 +54,321 @@ const MainContent: React.FC = () => {
severity: 'error' severity: 'error'
}); });
const { columns, loading: loadingSchema } = useTableSchema(activeTable, activeDatabase); const [pendingChanges, setPendingChanges] = useState<Record<string, any>>({});
const { rows, rowCount, loading: loadingData } = useTableData(activeTable, activeDatabase, paginationModel); const [isSaving, setIsSaving] = useState(false);
const [cellFocus, setCellFocus] = useState<{ id: string | number, field: string } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragType, setDragType] = useState<'fill' | 'select' | null>(null);
const [dragStart, setDragStart] = useState<{ id: string | number, field: string } | null>(null);
const [dragCurrent, setDragCurrent] = useState<{ id: string | number, field: string } | null>(null);
const [selectionStart, setSelectionStart] = useState<{ id: string | number, field: string } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ id: string | number, field: string } | null>(null);
const { columns, loading: loadingSchema, primaryKey } = useTableSchema(activeTable, activeDatabase);
const { rows, rowCount, loading: loadingData, refetch } = useTableData(activeTable, activeDatabase, paginationModel, filterModel);
const handleRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => {
const hasChanged = Object.keys(newRow).some(key => newRow[key] !== oldRow[key]);
if (hasChanged) {
const rowId = newRow[primaryKey];
setPendingChanges(prev => ({
...prev,
[rowId]: {
...(prev[rowId] || {}),
...newRow
}
}));
}
return newRow;
};
const handleBatchSave = async () => {
if (!activeTable) return;
setIsSaving(true);
try {
const changes = Object.values(pendingChanges);
await SchemaService.batchUpdate(activeTable, changes);
setNotification({
open: true,
title: 'Batch Update Success',
message: `Successfully updated ${changes.length} records.`,
severity: 'success'
});
setPendingChanges({});
refetch(); // Refresh data from server to be sure
} catch (error: any) {
setNotification({
open: true,
title: 'Update Failed',
message: error.response?.data?.error || error.message,
severity: 'error'
});
} finally {
setIsSaving(false);
}
};
const handleExport = async (format: 'sql' | 'csv') => {
if (!activeTable || !activeDatabase) return;
try {
setNotification({ open: true, title: 'Exporting', message: 'Generating export file...', severity: 'success' });
// For now, we'll use the existing SQL export for 'sql'
// and implement a client-side CSV export for 'csv' since we have the rows
if (format === 'sql') {
const response = await SchemaService.exportDatabase(activeDatabase, activeTable, filterModel.items);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${activeTable}_${new Date().toISOString().split('T')[0]}.sql`);
document.body.appendChild(link);
link.click();
link.remove();
} else {
// Simple CSV Export
const headers = columns.map(c => c.headerName || c.field).join(',');
const csvRows = rows.map(row =>
columns.map(c => {
const val = row[c.field];
return typeof val === 'string' ? `"${val.replace(/"/g, '""')}"` : val;
}).join(',')
);
const csvContent = [headers, ...csvRows].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${activeTable}_export.csv`);
document.body.appendChild(link);
link.click();
link.remove();
}
} catch (error: any) {
setNotification({
open: true,
title: 'Export Failed',
message: error.message,
severity: 'error'
});
}
};
const handlePaste = useCallback((event: React.ClipboardEvent) => {
if (!cellFocus || !activeTable) return;
const pasteData = event.clipboardData.getData('text');
const rows_data = pasteData.split(/\r?\n/).filter(line => line.length > 0).map(line => line.split('\t'));
if (rows_data.length === 0) return;
const updatedChanges = { ...pendingChanges };
const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id);
const focusedColIndex = columns.findIndex(c => c.field === cellFocus.field);
if (focusedRowIndex === -1 || focusedColIndex === -1) return;
rows_data.forEach((rowData, rIdx) => {
const targetRowIndex = focusedRowIndex + rIdx;
if (targetRowIndex >= rows.length) return;
const targetRow = rows[targetRowIndex];
const targetRowId = targetRow[primaryKey];
const newRowData = { ...(updatedChanges[targetRowId] || {}), [primaryKey]: targetRowId };
rowData.forEach((cellData, cIdx) => {
const targetColIndex = focusedColIndex + cIdx;
if (targetColIndex >= columns.length) return;
const targetCol = columns[targetColIndex];
if (targetCol.editable !== false && targetCol.field !== primaryKey) {
newRowData[targetCol.field] = cellData;
}
});
updatedChanges[targetRowId] = newRowData;
});
setPendingChanges(updatedChanges);
event.preventDefault();
}, [cellFocus, rows, columns, primaryKey, pendingChanges, activeTable]);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
// Excel Ctrl+D support (Fill Down)
if (event.ctrlKey && event.key === 'd') {
if (!cellFocus || !activeTable) return;
const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id);
if (focusedRowIndex === -1 || focusedRowIndex + 1 >= rows.length) return;
const valueToCopy = (pendingChanges[cellFocus.id]?.[cellFocus.field]) ?? rows[focusedRowIndex][cellFocus.field];
const nextRow = rows[focusedRowIndex + 1];
const nextRowId = nextRow[primaryKey];
setPendingChanges(prev => ({
...prev,
[nextRowId]: {
...(prev[nextRowId] || {}),
[primaryKey]: nextRowId,
[cellFocus.field]: valueToCopy
}
}));
event.preventDefault();
}
// Delete key to clear selection or focused cell
if (event.key === 'Delete') {
const updatedChanges = { ...pendingChanges };
let changed = false;
if (selectionStart && selectionEnd) {
const startRowIdx = rows.findIndex(r => r[primaryKey] === selectionStart.id);
const endRowIdx = rows.findIndex(r => r[primaryKey] === selectionEnd.id);
const startColIdx = columns.findIndex(c => c.field === selectionStart.field);
const endColIdx = columns.findIndex(c => c.field === selectionEnd.field);
if (startRowIdx !== -1 && endRowIdx !== -1 && startColIdx !== -1 && endColIdx !== -1) {
const rStart = Math.min(startRowIdx, endRowIdx);
const rEnd = Math.max(startRowIdx, endRowIdx);
const cStart = Math.min(startColIdx, endColIdx);
const cEnd = Math.max(startColIdx, endColIdx);
for (let r = rStart; r <= rEnd; r++) {
const rowId = rows[r][primaryKey];
for (let c = cStart; c <= cEnd; c++) {
const field = columns[c].field;
if (columns[c].editable !== false && field !== primaryKey) {
updatedChanges[rowId] = {
...(updatedChanges[rowId] || {}),
[primaryKey]: rowId,
[field]: ''
};
changed = true;
}
}
}
}
} else if (cellFocus) {
const col = columns.find(c => c.field === cellFocus.field);
if (col && col.editable !== false && col.field !== primaryKey) {
updatedChanges[cellFocus.id] = {
...(updatedChanges[cellFocus.id] || {}),
[primaryKey]: cellFocus.id,
[cellFocus.field]: ''
};
changed = true;
}
}
if (changed) {
setPendingChanges(updatedChanges);
}
}
}, [cellFocus, selectionStart, selectionEnd, rows, columns, pendingChanges, primaryKey, activeTable]);
const handleCellMouseDown = useCallback((params: any, event: React.MouseEvent) => {
const cellElement = (event.target as HTMLElement).closest('.MuiDataGrid-cell');
if (!cellElement) return;
const rect = cellElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Check if click is in the bottom-right corner (approx 12x12px fill handle)
if (x > rect.width - 15 && y > rect.height - 15) {
setIsDragging(true);
setDragType('fill');
setDragStart({ id: params.id, field: params.field });
setDragCurrent({ id: params.id, field: params.field });
event.stopPropagation();
event.preventDefault();
} else {
// Start range selection
setIsDragging(true);
setDragType('select');
setSelectionStart({ id: params.id, field: params.field });
setSelectionEnd({ id: params.id, field: params.field });
// Reset dragCurrent to avoid visual confusion
setDragCurrent(null);
}
}, []);
const handleCellMouseEnter = useCallback((params: any) => {
if (isDragging) {
if (dragType === 'fill') {
setDragCurrent({ id: params.id, field: params.field });
} else if (dragType === 'select') {
setSelectionEnd({ id: params.id, field: params.field });
}
}
}, [isDragging, dragType]);
const handleMouseUp = useCallback(() => {
if (isDragging) {
if (dragType === 'fill' && dragStart && dragCurrent) {
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const valueToFill = (pendingChanges[dragStart.id]?.[dragStart.field]) ?? rows[startIndex][dragStart.field];
const updatedChanges = { ...pendingChanges };
for (let i = start; i <= end; i++) {
const targetRowId = rows[i][primaryKey];
const targetCol = columns.find(c => c.field === dragStart.field);
if (targetCol && targetCol.editable !== false && targetCol.field !== primaryKey) {
updatedChanges[targetRowId] = {
...(updatedChanges[targetRowId] || {}),
[primaryKey]: targetRowId,
[dragStart.field]: valueToFill
};
}
}
setPendingChanges(updatedChanges);
}
}
// Selection stays until another click or clear
}
setIsDragging(false);
setDragType(null);
setDragStart(null);
setDragCurrent(null);
}, [isDragging, dragType, dragStart, dragCurrent, rows, columns, primaryKey, pendingChanges]);
useEffect(() => {
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseUp]);
useEffect(() => {
const api = apiRef.current;
if (!api || !api.subscribeEvent) return;
const unsubs = [
api.subscribeEvent('cellFocusIn', (params) => {
setCellFocus({ id: params.id, field: params.field });
// Single click should reset selection unless dragging
if (!isDragging) {
setSelectionStart(null);
setSelectionEnd(null);
}
}),
api.subscribeEvent('cellMouseDown', handleCellMouseDown),
api.subscribeEvent('cellMouseOver', handleCellMouseEnter)
];
return () => unsubs.forEach(unsub => unsub?.());
}, [apiRef, activeTable, handleCellMouseDown, handleCellMouseEnter]);
useEffect(() => { useEffect(() => {
setPaginationModel({ page: 0, pageSize: 100 }); setPaginationModel({ page: 0, pageSize: 100 });
setPendingChanges({});
setSelectionStart(null);
setSelectionEnd(null);
}, [activeTable]); }, [activeTable]);
const handleCloseNotification = () => setNotification({ ...notification, open: false }); const handleCloseNotification = () => setNotification({ ...notification, open: false });
@@ -115,7 +437,38 @@ const MainContent: React.FC = () => {
<Box sx={{ px: 1.5, py: 0.5, borderRadius: 10, bgcolor: 'rgba(0, 97, 255, 0.1)', color: 'primary.main' }}> <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> <Typography variant="caption" sx={{ fontWeight: 700 }}>{rowCount} rows found</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" variant="outlined" startIcon={<CloudDownload />} onClick={() => handleExport('sql')} sx={{ borderRadius: 2, fontSize: '0.7rem' }}>SQL Export</Button>
<Button size="small" variant="outlined" startIcon={<TableChart />} onClick={() => handleExport('csv')} sx={{ borderRadius: 2, fontSize: '0.7rem' }}>Excel/CSV</Button>
</Box>
</Box> </Box>
{Object.keys(pendingChanges).length > 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
color="success"
size="small"
startIcon={<Save />}
onClick={handleBatchSave}
disabled={isSaving}
sx={{ borderRadius: 2, fontWeight: 700 }}
>
{isSaving ? <CircularProgress size={20} color="inherit" /> : `Save ${Object.keys(pendingChanges).length} Changes`}
</Button>
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={<Undo />}
onClick={() => setPendingChanges({})}
disabled={isSaving}
sx={{ borderRadius: 2 }}
>
Discard
</Button>
</Box>
)}
</Box> </Box>
<Paper sx={{ <Paper sx={{
@@ -135,17 +488,128 @@ const MainContent: React.FC = () => {
</Box> </Box>
) : ( ) : (
<DataGrid <DataGrid
rows={rows} apiRef={apiRef}
rows={rows.map(row => ({
...row,
...(pendingChanges[row[primaryKey]] || {})
}))}
getRowId={(row) => row[primaryKey]}
columns={columns} columns={columns}
rowCount={rowCount} rowCount={rowCount}
loading={loadingData} loading={loadingData}
paginationMode="server" paginationMode="server"
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}
filterMode="server"
filterModel={filterModel}
onFilterModelChange={setFilterModel}
pageSizeOptions={[25, 50, 100]} pageSizeOptions={[25, 50, 100]}
processRowUpdate={handleRowUpdate}
onProcessRowUpdateError={(error) => {
console.error('Row update error:', error);
}}
slotProps={{
root: {
onPaste: handlePaste,
onKeyDown: handleKeyDown,
}
}}
density="compact"
sx={{ sx={{
border: 'none', border: 'none',
'& .MuiDataGrid-columnHeaderTitle': {
fontWeight: 800,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
'& .MuiDataGrid-cell': {
fontSize: '0.8rem',
px: 1,
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-row': {
minHeight: '32px !important',
maxHeight: '32px !important',
},
'& .MuiDataGrid-row:nth-of-type(even)': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
},
'& .MuiDataGrid-cell--editable': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0, 255, 0, 0.02)' : 'rgba(0, 255, 0, 0.01)',
},
'& .MuiDataGrid-columnHeader': {
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeaders': {
borderBottom: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-row:hover': { bgcolor: 'rgba(0, 97, 255, 0.04)' }, '& .MuiDataGrid-row:hover': { bgcolor: 'rgba(0, 97, 255, 0.04)' },
'& .MuiDataGrid-cell:focus-within': {
outline: '2px solid #0061ff',
outlineOffset: '-2px',
position: 'relative',
'&::after': {
content: '""',
position: 'absolute',
bottom: -1,
right: -1,
width: 8,
height: 8,
bgcolor: '#0061ff',
border: '1px solid white',
cursor: 'crosshair',
zIndex: 10,
}
},
'& .MuiDataGrid-cell--dragging': {
bgcolor: 'rgba(0, 97, 255, 0.15) !important',
border: '1px dashed #0061ff !important',
},
'& .MuiDataGrid-cell--selected': {
bgcolor: 'rgba(0, 97, 255, 0.08) !important',
border: '1px double rgba(0, 97, 255, 0.3) !important',
}
}}
getCellClassName={(params) => {
const classNames: string[] = [];
// Filling preview
if (isDragging && dragType === 'fill' && dragStart && dragCurrent && params.field === dragStart.field) {
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
const currentIndex = rows.findIndex(r => r[primaryKey] === params.id);
if (currentIndex >= Math.min(startIndex, endIndex) && currentIndex <= Math.max(startIndex, endIndex)) {
classNames.push('MuiDataGrid-cell--dragging');
}
}
// Selection range
if (selectionStart && selectionEnd) {
const startRowIdx = rows.findIndex(r => r[primaryKey] === selectionStart.id);
const endRowIdx = rows.findIndex(r => r[primaryKey] === selectionEnd.id);
const startColIdx = columns.findIndex(c => c.field === selectionStart.field);
const endColIdx = columns.findIndex(c => c.field === selectionEnd.field);
const currentRowIdx = rows.findIndex(r => r[primaryKey] === params.id);
const currentColIdx = columns.findIndex(c => c.field === params.field);
if (currentRowIdx !== -1 && endRowIdx !== -1 && startColIdx !== -1 && endColIdx !== -1) {
if (
currentRowIdx >= Math.min(startRowIdx, endRowIdx) &&
currentRowIdx <= Math.max(startRowIdx, endRowIdx) &&
currentColIdx >= Math.min(startColIdx, endColIdx) &&
currentColIdx <= Math.max(startColIdx, endColIdx)
) {
classNames.push('MuiDataGrid-cell--selected');
}
}
}
return classNames.join(' ');
}} }}
/> />
)} )}
+18 -1
View File
@@ -121,7 +121,24 @@ const SqlConsole: React.FC<SqlConsoleProps> = ({ initialQuery, setNotification }
columns={columns} columns={columns}
rowCount={rowCount} rowCount={rowCount}
loading={loading} loading={loading}
sx={{ border: 'none' }} sx={{
border: 'none',
'& .MuiDataGrid-row:nth-of-type(even)': {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
},
'& .MuiDataGrid-cell': {
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeader': {
borderRight: '1px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-columnHeaders': {
borderBottom: '1px solid',
borderColor: 'divider',
}
}}
/> />
</Paper> </Paper>
</Box> </Box>
+58 -41
View File
@@ -1,19 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Paper, Paper,
Typography, Typography,
Button, Button,
CircularProgress, CircularProgress,
Alert Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Avatar
} from '@mui/material'; } from '@mui/material';
import { import {
Terminal, Terminal,
TableChart, TableChart,
CleaningServices, CleaningServices,
Save, Save,
History, History,
Info Info
} from '@mui/icons-material'; } from '@mui/icons-material';
import { SchemaService } from '../services/api'; import { SchemaService } from '../services/api';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
@@ -34,7 +40,7 @@ const TechnicalOverview: React.FC<TechnicalOverviewProps> = ({ database, table,
const fetchMeta = useCallback(async () => { const fetchMeta = useCallback(async () => {
setLoadingMeta(true); setLoadingMeta(true);
try { try {
const res = table const res = table
? await SchemaService.getTableMetadata(database, table) ? await SchemaService.getTableMetadata(database, table)
: await SchemaService.getDatabaseMetadata(database); : await SchemaService.getDatabaseMetadata(database);
setMeta(res.data); setMeta(res.data);
@@ -98,9 +104,9 @@ const TechnicalOverview: React.FC<TechnicalOverviewProps> = ({ database, table,
Technical Specifications: {table ? `${database}.${table}` : database} Technical Specifications: {table ? `${database}.${table}` : database}
</Typography> </Typography>
{table && ( {table && (
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
startIcon={truncating ? <CircularProgress size={16} color="inherit" /> : <CleaningServices />} startIcon={truncating ? <CircularProgress size={16} color="inherit" /> : <CleaningServices />}
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
disabled={truncating} disabled={truncating}
@@ -111,7 +117,7 @@ const TechnicalOverview: React.FC<TechnicalOverviewProps> = ({ database, table,
)} )}
</Box> </Box>
<ConfirmDialog <ConfirmDialog
open={showConfirm} open={showConfirm}
onClose={() => setShowConfirm(false)} onClose={() => setShowConfirm(false)}
onConfirm={handleTruncate} onConfirm={handleTruncate}
@@ -120,28 +126,39 @@ const TechnicalOverview: React.FC<TechnicalOverviewProps> = ({ database, table,
confirmLabel="Truncate Now" confirmLabel="Truncate Now"
loading={truncating} loading={truncating}
/> />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 3 }}> <TableContainer component={Paper} sx={{
{stats.map((stat, i) => ( borderRadius: 1,
<Paper key={i} sx={{ border: 1,
p: 3, borderColor: 'divider',
borderRadius: 4, bgcolor: 'background.paper',
border: 1, overflow: 'hidden',
borderColor: 'divider', boxShadow: 'none'
display: 'flex', }}>
flexDirection: 'column', <Table>
gap: 1, <TableBody>
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', {stats.map((stat, i) => (
transition: 'all 0.2s', <TableRow key={i} sx={{ '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { bgcolor: 'rgba(255,255,255,0.02)' } }}>
'&:hover': { borderColor: 'primary.main', transform: 'translateY(-2px)' } <TableCell sx={{ width: 64 }}>
}}> <Avatar sx={{
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> bgcolor: 'rgba(0,123,255,0.1)',
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 1 }}>{stat.label}</Typography> color: 'primary.main',
<Box sx={{ color: 'primary.main', opacity: 0.5 }}>{stat.icon}</Box> width: 40,
</Box> height: 40
<Typography variant="h5" sx={{ fontWeight: 800 }}>{stat.value || 'N/A'}</Typography> }}>
</Paper> {stat.icon}
))} </Avatar>
</Box> </TableCell>
<TableCell sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.75rem' }}>
{stat.label}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, fontSize: '1.1rem' }}>
{stat.value || 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box> </Box>
); );
}; };
+103 -100
View File
@@ -8,7 +8,13 @@ import {
Divider, Divider,
Alert, Alert,
LinearProgress, LinearProgress,
IconButton IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Avatar
} from '@mui/material'; } from '@mui/material';
import { import {
CloudDownload, CloudDownload,
@@ -111,106 +117,103 @@ const TransferContent: React.FC<TransferContentProps> = ({ mode = 'both' }) => {
</Alert> </Alert>
)} )}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3}> <TableContainer component={Paper} sx={{
{/* Export Card */} borderRadius: 4,
{(mode === 'export' || mode === 'both') && ( border: 1,
<Paper sx={{ borderColor: 'divider',
flex: 1, bgcolor: 'background.paper',
p: 4, overflow: 'hidden',
borderRadius: 4, boxShadow: 'none'
border: 1, }}>
borderColor: 'divider', <Table>
display: 'flex', <TableBody>
flexDirection: 'column', {/* Export Row */}
alignItems: 'center', {(mode === 'export' || mode === 'both') && (
textAlign: 'center', <TableRow sx={{ '&:hover': { bgcolor: 'rgba(255,255,255,0.02)' } }}>
gap: 2, <TableCell sx={{ width: 80, verticalAlign: 'top', pt: 3 }}>
transition: 'all 0.3s ease', <Avatar sx={{ bgcolor: 'primary.main', color: 'white', width: 48, height: 48 }}>
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 12px 24px rgba(0,0,0,0.1)' } <CloudDownload />
}}> </Avatar>
<Box sx={{ p: 2, borderRadius: '50%', bgcolor: 'primary.main', color: 'white', mb: 1 }}> </TableCell>
<CloudDownload sx={{ fontSize: 40 }} /> <TableCell sx={{ py: 3 }}>
</Box> <Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>Export {activeTable ? 'Table' : 'Database'}</Typography>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Export {activeTable ? 'Table' : 'Database'}</Typography> <Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary"> Create a full backup of the current {activeTable ? 'table' : 'database'}: <br/>
Create a full backup of the current {activeTable ? 'table' : 'database'}: <strong>{activeTable ? `${activeDatabase}.${activeTable}` : (activeDatabase || 'All Databases')}</strong> <Box component="span" sx={{ color: 'primary.main', fontWeight: 600 }}>
</Typography> {activeTable ? `${activeDatabase}.${activeTable}` : (activeDatabase || 'All Databases')}
<Box sx={{ flexGrow: 1 }} /> </Box>
<Button </Typography>
variant="contained" </TableCell>
fullWidth <TableCell align="right" sx={{ py: 3 }}>
size="large" <Button
startIcon={<CloudDownload />} variant="contained"
onClick={handleExport} startIcon={<CloudDownload />}
disabled={loading} onClick={handleExport}
sx={{ borderRadius: 2, py: 1.5, fontWeight: 700 }} disabled={loading}
> sx={{ borderRadius: 2, px: 3, py: 1, fontWeight: 700, minWidth: 160 }}
Start Export >
</Button> Start Export
</Paper> </Button>
)} </TableCell>
</TableRow>
)}
{/* Import Card */} {/* Import Row */}
{(mode === 'import' || mode === 'both') && ( {(mode === 'import' || mode === 'both') && (
<Paper sx={{ <TableRow sx={{ '&:hover': { bgcolor: 'rgba(255,255,255,0.02)' } }}>
flex: 1, <TableCell sx={{ width: 80, verticalAlign: 'top', pt: 3 }}>
p: 4, <Avatar sx={{ bgcolor: 'secondary.main', color: 'white', width: 48, height: 48 }}>
borderRadius: 4, <CloudUpload />
border: 1, </Avatar>
borderColor: 'divider', </TableCell>
display: 'flex', <TableCell sx={{ py: 3 }}>
flexDirection: 'column', <Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>Import {activeTable ? 'Table' : 'Database'}</Typography>
alignItems: 'center', <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
textAlign: 'center', Upload a .sql file to restore or migrate your {activeTable ? 'table' : 'database'}.
gap: 2, </Typography>
transition: 'all 0.3s ease',
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 12px 24px rgba(0,0,0,0.1)' } <Box sx={{
}}> maxWidth: 400,
<Box sx={{ p: 2, borderRadius: '50%', bgcolor: 'secondary.main', color: 'white', mb: 1 }}> p: 1.5,
<CloudUpload sx={{ fontSize: 40 }} /> border: '2px dashed',
</Box> borderColor: file ? 'secondary.main' : 'divider',
<Typography variant="h6" sx={{ fontWeight: 700 }}>Import {activeTable ? 'Table' : 'Database'}</Typography> borderRadius: 2,
<Typography variant="body2" color="text.secondary"> bgcolor: 'rgba(0,0,0,0.02)',
Upload a .sql file to restore or migrate your {activeTable ? 'table' : 'database'}. cursor: 'pointer',
</Typography> position: 'relative',
display: 'flex',
<Box sx={{ alignItems: 'center',
width: '100%', gap: 1
p: 2, }}>
border: '2px dashed', <input
borderColor: file ? 'secondary.main' : 'divider', type="file"
borderRadius: 2, accept=".sql"
bgcolor: 'rgba(0,0,0,0.02)', onChange={handleFileChange}
cursor: 'pointer', style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', zIndex: 1 }}
position: 'relative' />
}}> <CloudUpload fontSize="small" color={file ? "secondary" : "disabled"} />
<input <Typography variant="caption" sx={{ fontWeight: 600 }}>
type="file" {file ? file.name : "Select or drag .sql file here"}
accept=".sql" </Typography>
onChange={handleFileChange} </Box>
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }} </TableCell>
/> <TableCell align="right" sx={{ py: 3 }}>
<Typography variant="caption" color="text.secondary"> <Button
{file ? file.name : "Click or drag .sql file here"} variant="outlined"
</Typography> color="secondary"
</Box> startIcon={<CloudUpload />}
onClick={handleImport}
<Box sx={{ flexGrow: 1 }} /> disabled={loading || !file}
<Button sx={{ borderRadius: 2, px: 3, py: 1, fontWeight: 700, minWidth: 160 }}
variant="outlined" >
fullWidth Start Import
size="large" </Button>
color="secondary" </TableCell>
startIcon={<CloudUpload />} </TableRow>
onClick={handleImport} )}
disabled={loading || !file} </TableBody>
sx={{ borderRadius: 2, py: 1.5, fontWeight: 700 }} </Table>
> </TableContainer>
Start Import
</Button>
</Paper>
)}
</Stack>
{loading && ( {loading && (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
+9 -4
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { GridPaginationModel } from '@mui/x-data-grid'; import type { GridPaginationModel, GridFilterModel } from '@mui/x-data-grid';
import { SchemaService } from '../services/api'; import { SchemaService } from '../services/api';
export const useTableData = ( export const useTableData = (
activeTable: string | null, activeTable: string | null,
activeDatabase: string | null, activeDatabase: string | null,
paginationModel: GridPaginationModel paginationModel: GridPaginationModel,
filterModel?: GridFilterModel
) => { ) => {
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [rowCount, setRowCount] = useState(0); const [rowCount, setRowCount] = useState(0);
@@ -16,13 +17,17 @@ export const useTableData = (
setLoading(true); setLoading(true);
try { try {
const params = { const params: any = {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
take: paginationModel.pageSize, take: paginationModel.pageSize,
requireTotalCount: true, requireTotalCount: true,
database: activeDatabase, database: activeDatabase,
}; };
if (filterModel && filterModel.items.length > 0) {
params.filters = JSON.stringify(filterModel.items);
}
const response = await SchemaService.getTableData(activeTable, params); const response = await SchemaService.getTableData(activeTable, params);
const dataWithIds = response.data.data.map((row: any, index: number) => ({ const dataWithIds = response.data.data.map((row: any, index: number) => ({
@@ -37,7 +42,7 @@ export const useTableData = (
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [activeTable, activeDatabase, paginationModel]); }, [activeTable, activeDatabase, paginationModel, filterModel]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
+6 -1
View File
@@ -14,6 +14,7 @@ const mapSqlTypeToMuiType = (sqlType: string): 'string' | 'number' | 'date' | 'd
export const useTableSchema = (activeTable: string | null, activeDatabase: string | null) => { export const useTableSchema = (activeTable: string | null, activeDatabase: string | null) => {
const [columns, setColumns] = useState<GridColDef[]>([]); const [columns, setColumns] = useState<GridColDef[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [primaryKey, setPrimaryKey] = useState<string>('id');
useEffect(() => { useEffect(() => {
const fetchSchema = async () => { const fetchSchema = async () => {
@@ -22,7 +23,9 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
setLoading(true); setLoading(true);
try { try {
const schemaRes = await SchemaService.getTableSchema(activeTable, activeDatabase); const schemaRes = await SchemaService.getTableSchema(activeTable, activeDatabase);
let pk = 'id';
const cols: GridColDef[] = schemaRes.data.map((col: any) => { const cols: GridColDef[] = schemaRes.data.map((col: any) => {
if (col.Key === 'PRI') pk = col.Field;
const type = mapSqlTypeToMuiType(col.Type); const type = mapSqlTypeToMuiType(col.Type);
return { return {
field: col.Field, field: col.Field,
@@ -30,6 +33,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
type: type, type: type,
width: 200, width: 200,
minWidth: 100, minWidth: 100,
editable: col.Key !== 'PRI', // Enable editing if not primary key
valueGetter: (value: any) => { valueGetter: (value: any) => {
if ((type === 'date' || type === 'dateTime') && value && typeof value === 'string') { if ((type === 'date' || type === 'dateTime') && value && typeof value === 'string') {
return new Date(value); return new Date(value);
@@ -39,6 +43,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
}; };
}); });
setColumns(cols); setColumns(cols);
setPrimaryKey(pk);
} catch (error) { } catch (error) {
console.error('Failed to fetch table schema', error); console.error('Failed to fetch table schema', error);
} finally { } finally {
@@ -49,5 +54,5 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin
fetchSchema(); fetchSchema();
}, [activeTable, activeDatabase]); }, [activeTable, activeDatabase]);
return { columns, loading }; return { columns, loading, primaryKey };
}; };
+6 -1
View File
@@ -34,8 +34,13 @@ export const SchemaService = {
dropTable: (table: string, database?: string) => api.post(`/schema/drop/${table}`, { database }), dropTable: (table: string, database?: string) => api.post(`/schema/drop/${table}`, { database }),
optimizeTable: (table: string, database?: string) => api.post(`/schema/optimize/${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), bulkAction: (data: { tables: string[], action: string, database: string }) => api.post('/schema/bulk-action', data),
batchUpdate: (table: string, changes: any[]) => api.post(`/schema/${table}/batch-update`, { changes }),
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, filters?: any) => api.post('/schema/export', {
database,
table,
filters: filters ? JSON.stringify(filters) : undefined
}, { responseType: 'blob' }),
importDatabase: (formData: FormData) => api.post('/schema/import', formData, { importDatabase: (formData: FormData) => api.post('/schema/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}), }),