Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5282df56f | |||
| 5af75c95dd | |||
| e75657d22a | |||
| 22ff72d39c | |||
| da6a4249dd | |||
| a447d5a08e | |||
| 53b40e95e5 | |||
| 5192d950f0 | |||
| 6594f639c3 | |||
| a007fa201b |
+64
-38
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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%' }}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user