From 9058a6594cd9997dffd11fd1267f1d78d5eee182 Mon Sep 17 00:00:00 2001 From: lambdv Date: Mon, 18 Aug 2025 13:00:04 +1200 Subject: [PATCH 1/3] Implement PostgreSQL Admin interface with connection management, query execution, and table browsing features. Update package dependencies and enhance user experience with new UI components. --- .../Controllers/PostgresAdminController.php | 364 +++++++++++ app/Jobs/SyncPGwithUOD.php | 134 ++-- docs/postgres-admin-demo.md | 263 ++++++++ docs/postgres-admin-usage.md | 145 +++++ package-lock.json | 234 ++++++- package.json | 4 + resources/css/app.css | 1 + resources/inertia/components/app-sidebar.tsx | 10 + resources/inertia/components/cn/tabs.tsx | 53 ++ .../inertia/pages/PostgresAdmin/Index.tsx | 593 ++++++++++++++++++ .../pages/databaseProductDetails.blade.php | 35 +- routes/web.php | 8 + 12 files changed, 1790 insertions(+), 54 deletions(-) create mode 100644 app/Http/Controllers/PostgresAdminController.php create mode 100644 docs/postgres-admin-demo.md create mode 100644 docs/postgres-admin-usage.md create mode 100644 resources/inertia/components/cn/tabs.tsx create mode 100644 resources/inertia/pages/PostgresAdmin/Index.tsx diff --git a/app/Http/Controllers/PostgresAdminController.php b/app/Http/Controllers/PostgresAdminController.php new file mode 100644 index 0000000..912b528 --- /dev/null +++ b/app/Http/Controllers/PostgresAdminController.php @@ -0,0 +1,364 @@ + extension_loaded('pdo'), + 'pdo_pgsql' => extension_loaded('pdo_pgsql'), + 'pgsql' => extension_loaded('pgsql'), + ]; + + $drivers = PDO::getAvailableDrivers(); + + return response()->json([ + 'success' => true, + 'extensions' => $extensions, + 'pdo_drivers' => $drivers, + 'php_version' => PHP_VERSION, + 'os' => PHP_OS, + ]); + } + + private function assertOwnedDatabase(string $databaseName): void + { + $owned = PGDBProduct::where('instance_id', $databaseName) + ->where('user_id', Auth::id()) + ->exists(); + if (!$owned) { + abort(403, 'You do not have access to this database.'); + } + } + + private function enforceAllowedHost(string $host): void + { + $allowed = array_map('trim', explode(',', (string) env('PG_ALLOWED_HOSTS', 'localhost,127.0.0.1'))); + if (!in_array($host, $allowed, true)) { + abort(403, 'Host is not allowed.'); + } + } + + private function buildPdoDsn(string $connectionString, ?array &$parsedOut = null): string + { + $dsn = $connectionString; + $parsed = parse_url($connectionString); + if ($parsed && isset($parsed['scheme']) && $parsed['scheme'] === 'postgresql') { + $dsn = sprintf( + 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s', + $parsed['host'] ?? 'localhost', + $parsed['port'] ?? '5432', + ltrim($parsed['path'] ?? '', '/'), + $parsed['user'] ?? '', + $parsed['pass'] ?? '' + ); + } + if ($parsedOut !== null) { + $parsedOut = $parsed ?: []; + } + return $dsn; + } + + private function extractConnectionMeta(string $connectionString): array + { + // Returns ['host' => string, 'port' => string, 'dbname' => string] + $parsed = parse_url($connectionString); + if ($parsed && isset($parsed['scheme']) && $parsed['scheme'] === 'postgresql') { + return [ + 'host' => $parsed['host'] ?? 'localhost', + 'port' => (string) ($parsed['port'] ?? '5432'), + 'dbname' => ltrim($parsed['path'] ?? '', '/'), + ]; + } + // Handle DSN format: pgsql:host=...;port=...;dbname=...;user=...;password=... + if (is_string($connectionString) && str_starts_with($connectionString, 'pgsql:')) { + $kv = substr($connectionString, strlen('pgsql:')); + $parts = array_filter(array_map('trim', explode(';', $kv))); + $data = []; + foreach ($parts as $part) { + [$k, $v] = array_pad(explode('=', $part, 2), 2, null); + if ($k !== null && $v !== null) { + $data[strtolower($k)] = $v; + } + } + return [ + 'host' => $data['host'] ?? 'localhost', + 'port' => (string) ($data['port'] ?? '5432'), + 'dbname' => (string) ($data['dbname'] ?? ''), + ]; + } + // Fallback + return [ + 'host' => 'localhost', + 'port' => '5432', + 'dbname' => '', + ]; + } + + private function applySessionGuards(PDO $pdo): void + { + $timeoutMs = (int) env('PG_STMT_TIMEOUT_MS', '5000'); + if ($timeoutMs > 0) { + $pdo->exec('SET statement_timeout TO ' . $timeoutMs); + } + } + + public function connect(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + + if (!extension_loaded('pdo_pgsql')) { + return response()->json([ + 'success' => false, + 'message' => 'PostgreSQL PDO driver not available. Please install pdo_pgsql extension.' + ], 500); + } + + $parsed = []; + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $stmt = $pdo->query('SELECT 1 as test'); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result && $result['test'] == 1) { + return response()->json([ + 'success' => true, + 'message' => 'Connection successful to ' . $host . ':' . ($meta['port'] ?? '5432') . '/' . $dbName + ]); + } else { + return response()->json([ + 'success' => false, + 'message' => 'Connection test failed - unexpected response' + ], 500); + } + } catch (PDOException $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'could not find driver')) { + return response()->json([ + 'success' => false, + 'message' => 'PostgreSQL driver not found. Please ensure pdo_pgsql extension is installed and enabled.' + ], 500); + } + return response()->json([ + 'success' => false, + 'message' => 'Connection failed: ' . $errorMessage + ], 500); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Unexpected error: ' . $e->getMessage() + ], 500); + } + } + + public function executeQuery(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + 'query' => 'required|string', + 'allowWrite' => 'sometimes|boolean', + 'maxRows' => 'sometimes|integer|min:1|max:' . (int) env('PG_MAX_ROWS', 2000), + ]); + + try { + $connectionString = $request->input('connectionString'); + $query = $request->input('query'); + $allowWrite = (bool) $request->boolean('allowWrite', true); + $maxRows = (int) ($request->input('maxRows', (int) env('PG_DEFAULT_ROWS', 500))); + $maxRows = max(1, min($maxRows, (int) env('PG_MAX_ROWS', 2000))); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $isWrite = (bool) preg_match('/^\s*(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE|GRANT|REVOKE|REINDEX|VACUUM|ANALYZE|COMMENT|REFRESH|CLUSTER|COPY|CALL|DO)\b/i', $query); + if ($isWrite && !$allowWrite) { + return response()->json([ + 'success' => false, + 'message' => 'Write operations are disabled in safe mode. Enable write to proceed.' + ], 403); + } + + $isSelect = (bool) preg_match('/^\s*(WITH\s+.*?\)\s*)?SELECT\b/is', $query); + if ($isSelect && !preg_match('/\blimit\s+\d+/i', $query) && !preg_match('/\bfetch\s+first\s+\d+/i', $query)) { + $trimmed = rtrim($query, "; \t\n\r"); + $query = $trimmed . ' LIMIT ' . $maxRows; + } + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $start = microtime(true); + $stmt = $pdo->query($query); + $durationMs = (int) round((microtime(true) - $start) * 1000); + + Log::info('PG query executed', [ + 'user_id' => Auth::id(), + 'database' => $dbName, + 'is_write' => $isWrite, + 'duration_ms' => $durationMs, + ]); + + if ($stmt->columnCount() > 0) { + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + $columns = []; + if (count($results) > 0) { + $columns = array_keys($results[0]); + } + return response()->json([ + 'success' => true, + 'type' => 'select', + 'columns' => $columns, + 'results' => $results, + 'rowCount' => count($results) + ]); + } else { + $rowCount = $stmt->rowCount(); + return response()->json([ + 'success' => true, + 'type' => 'modify', + 'rowCount' => $rowCount, + 'message' => "Query executed successfully. {$rowCount} row(s) affected." + ]); + } + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Query execution failed: ' . $e->getMessage() + ], 500); + } + } + + public function getTables(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $query = " + SELECT + table_name, + table_type, + table_schema + FROM information_schema.tables + WHERE table_schema NOT IN ('information_schema', 'pg_catalog') + ORDER BY table_schema, table_name + "; + + $stmt = $pdo->query($query); + $tables = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return response()->json([ + 'success' => true, + 'tables' => $tables + ]); + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch tables: ' . $e->getMessage() + ], 500); + } + } + + public function getTableStructure(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + 'tableName' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + $tableName = $request->input('tableName'); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $query = " + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_name = ? + ORDER BY ordinal_position + "; + + $stmt = $pdo->prepare($query); + $stmt->execute([$tableName]); + $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return response()->json([ + 'success' => true, + 'columns' => $columns + ]); + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch table structure: ' . $e->getMessage() + ], 500); + } + } +} diff --git a/app/Jobs/SyncPGwithUOD.php b/app/Jobs/SyncPGwithUOD.php index 575e228..7af83fb 100644 --- a/app/Jobs/SyncPGwithUOD.php +++ b/app/Jobs/SyncPGwithUOD.php @@ -5,7 +5,9 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use App\Services\PGDBManagerService; -use App\Models\User; +use App\Models\PGDBProduct; +use App\Models\PGDBRole; +use Illuminate\Support\Facades\Log; /** * Sync postgres server/cluster roles and databases with UOD PGDB_Products and PGDB_Roles */ @@ -25,58 +27,96 @@ public function __construct() */ public function handle(): void { - $service = new PGDBManagerService(); - - $databases = $service->getAllDatabases(); - $roles = $service->getUsers(); + $manager = new PGDBManagerService(); - if ($databases !== null) { - foreach ($databases as $database) { - $roleName = $role->rolname; - $rolePassword = $role->rolpassword; - $roleIsSuperuser = $role->rolsuper; - $roleIsLogin = $role->rolcanlogin; + // 1) Ensure all app roles exist on the cluster (loginable, non-superuser) + $appRoles = PGDBRole::all(); + foreach ($appRoles as $appRole) { + $roleName = $appRole->username; + if (!$manager->doesRoleExists($roleName)) { + try { + $manager->createNewLoginRole($roleName, $appRole->password, []); + Log::info("Created missing cluster role for app role: {$roleName}"); + } catch (\Throwable $e) { + Log::error("Failed creating cluster role {$roleName}: " . $e->getMessage()); + } + } + } + + // 2) Ensure all app databases exist on the cluster with correct owner + $appProducts = PGDBProduct::all(); + foreach ($appProducts as $product) { + $databaseName = $product->instance_id; + if (!$databaseName) { + continue; + } - if($roleIsLogin && $roleIsSuperuser){ - $user_id = explode('_', $roleName)[0]; - $user = User::where('id', $user_id)->first(); - - if($user){ - DB::table("pgdb_roles")->insert([ - "user_id" => $user_id, - "role_name" => $roleName, - "role_password" => $rolePassword, - "role_is_superuser" => $roleIsSuperuser, - "role_is_login" => $roleIsLogin, - ]); - + if (!$manager->doesDatabaseExists($databaseName)) { + // Determine owner role: first role linked to this product + $ownerRole = PGDBRole::where('pgdb_product_id', $product->id)->first(); + if (!$ownerRole) { + Log::warning("No owner role found for product {$product->id} ({$databaseName}); skipping DB creation"); + continue; + } + + // Ensure owner role exists before DB creation + if (!$manager->doesRoleExists($ownerRole->username)) { + try { + $manager->createNewLoginRole($ownerRole->username, $ownerRole->password, []); + Log::info("Created missing cluster role {$ownerRole->username} before creating database {$databaseName}"); + } catch (\Throwable $e) { + Log::error("Failed creating owner role {$ownerRole->username} for database {$databaseName}: " . $e->getMessage()); + continue; } } + + try { + $manager->createNewDatabaseForUser($databaseName, $ownerRole->username); + Log::info("Created missing database {$databaseName} with owner {$ownerRole->username}"); + } catch (\Throwable $e) { + Log::error("Failed creating database {$databaseName}: " . $e->getMessage()); + } } + } - // if ($roles !== null) { - // foreach ($roles as $role) { - // $roleName = $role->rolname; - // $rolePassword = $role->rolpassword; - // $roleIsSuperuser = $role->rolsuper; - // $roleIsLogin = $role->rolcanlogin; + // 3) Optional visibility: log cluster items that aren't in app UoD (only customer roles/dbs) + try { + $clusterRoles = $manager->getUsers(); + $clusterCustomerRoleNames = []; + foreach ((array) $clusterRoles as $role) { + // Only loginable, non-superuser roles + if (($role->rolcanlogin ?? false) && !($role->rolsuper ?? false)) { + $clusterCustomerRoleNames[] = $role->rolname; + } + } + + $appRoleNames = PGDBRole::pluck('username')->all(); + $extraClusterRoles = array_values(array_diff($clusterCustomerRoleNames, $appRoleNames)); + if (!empty($extraClusterRoles)) { + Log::warning('Cluster has loginable non-superuser roles not present in app UoD', ['roles' => $extraClusterRoles]); + } + } catch (\Throwable $e) { + Log::error('Failed while listing/logging cluster roles: ' . $e->getMessage()); + } - // if($roleIsLogin && $roleIsSuperuser){ - // $user_id = explode('_', $roleName)[0]; - // $user = User::where('id', $user_id)->first(); - - // if($user){ - // DB::table("pgdb_roles")->insert([ - // "user_id" => $user_id, - // "role_name" => $roleName, - // "role_password" => $rolePassword, - // "role_is_superuser" => $roleIsSuperuser, - // "role_is_login" => $roleIsLogin, - // ]); - - // } - // } - // } - // } + try { + $clusterDatabases = $manager->getAllDatabases(); + $clusterDbNames = []; + foreach ((array) $clusterDatabases as $db) { + $name = $db->datname ?? null; + if (!$name) { continue; } + // Exclude system templates/common DBs + if (in_array($name, ['template0', 'template1', 'postgres'])) { continue; } + $clusterDbNames[] = $name; + } + + $appDbNames = PGDBProduct::pluck('instance_id')->filter()->all(); + $extraClusterDbs = array_values(array_diff($clusterDbNames, $appDbNames)); + if (!empty($extraClusterDbs)) { + Log::warning('Cluster has databases not present in app UoD', ['databases' => $extraClusterDbs]); + } + } catch (\Throwable $e) { + Log::error('Failed while listing/logging cluster databases: ' . $e->getMessage()); + } } } diff --git a/docs/postgres-admin-demo.md b/docs/postgres-admin-demo.md new file mode 100644 index 0000000..18fd5e3 --- /dev/null +++ b/docs/postgres-admin-demo.md @@ -0,0 +1,263 @@ +# PostgreSQL Admin Interface Demo + +This guide demonstrates how to use the PostgreSQL Admin interface with practical examples. + +## Getting Started + +1. **Navigate to the PostgreSQL Admin page** + - Go to `/postgres-admin` in your application + - Or click "Open PostgreSQL Admin" from any database details page + +2. **Connect to your database** + - Use the connection string: `postgresql://user_1_t8IRM6hPPEyZ:Tt3yKoobvcZq@localhost:5432/user_1_iwzKZvLVbF5v` + - Click "Connect" to test the connection + +## Sample Queries to Try + +### 1. Basic SELECT Queries + +**View all tables in the database:** +```sql +SELECT table_name, table_type, table_schema +FROM information_schema.tables +WHERE table_schema NOT IN ('information_schema', 'pg_catalog') +ORDER BY table_schema, table_name; +``` + +**View table columns:** +```sql +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'your_table_name' +ORDER BY ordinal_position; +``` + +**Count rows in a table:** +```sql +SELECT COUNT(*) FROM your_table_name; +``` + +### 2. Data Exploration Queries + +**View first 10 rows of a table:** +```sql +SELECT * FROM your_table_name LIMIT 10; +``` + +**View specific columns:** +```sql +SELECT id, name, created_at +FROM your_table_name +ORDER BY created_at DESC +LIMIT 20; +``` + +**Search for specific data:** +```sql +SELECT * FROM your_table_name +WHERE name ILIKE '%search_term%'; +``` + +### 3. Schema Information Queries + +**List all schemas:** +```sql +SELECT schema_name +FROM information_schema.schemata +ORDER BY schema_name; +``` + +**View table constraints:** +```sql +SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name +LEFT JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name +WHERE tc.table_name = 'your_table_name'; +``` + +**View indexes:** +```sql +SELECT + indexname, + indexdef +FROM pg_indexes +WHERE tablename = 'your_table_name'; +``` + +### 4. Performance Queries + +**View table sizes:** +```sql +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size +FROM pg_tables +WHERE schemaname NOT IN ('information_schema', 'pg_catalog') +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; +``` + +**View slow queries (if pg_stat_statements is enabled):** +```sql +SELECT + query, + calls, + total_time, + mean_time, + rows +FROM pg_stat_statements +ORDER BY total_time DESC +LIMIT 10; +``` + +### 5. User and Permission Queries + +**List database users:** +```sql +SELECT usename, usesuper, usecreatedb, usebypassrls +FROM pg_user +ORDER BY usename; +``` + +**View user permissions:** +```sql +SELECT + grantee, + table_name, + privilege_type +FROM information_schema.role_table_grants +WHERE grantee = 'your_username'; +``` + +## Using the Table Browser + +1. **Go to the "Tables" tab** +2. **Click "Refresh Tables"** to load the current table list +3. **Click on any table** to view its structure +4. **Switch to "Table Structure" tab** to see column details + +## Best Practices for the Admin Interface + +### 1. Always Use LIMIT +```sql +-- Good: Limits results to prevent overwhelming the interface +SELECT * FROM large_table LIMIT 100; + +-- Avoid: Could return thousands of rows +SELECT * FROM large_table; +``` + +### 2. Test Queries First +```sql +-- Test with a small dataset first +SELECT COUNT(*) FROM your_table WHERE condition; + +-- Then run the full query +SELECT * FROM your_table WHERE condition; +``` + +### 3. Use Descriptive Column Names +```sql +-- Good: Clear column aliases +SELECT + id as user_id, + name as user_name, + created_at as registration_date +FROM users; + +-- Avoid: Unclear column names +SELECT id, name, created_at FROM users; +``` + +### 4. Handle NULL Values +```sql +-- Good: Explicit NULL handling +SELECT + COALESCE(name, 'Unknown') as user_name, + COALESCE(email, 'No email') as user_email +FROM users; + +-- Avoid: NULL values might not display clearly +SELECT name, email FROM users; +``` + +## Troubleshooting Common Issues + +### Connection Problems +- **Error**: "Connection refused" + - **Solution**: Check if PostgreSQL server is running + - **Command**: `sudo systemctl status postgresql` + +- **Error**: "Authentication failed" + - **Solution**: Verify username/password in connection string + - **Check**: Database user permissions + +### Query Issues +- **Error**: "Table does not exist" + - **Solution**: Check table name spelling and schema + - **Query**: `SELECT table_name FROM information_schema.tables;` + +- **Error**: "Permission denied" + - **Solution**: Check user permissions for the table + - **Query**: `SELECT * FROM information_schema.role_table_grants;` + +### Performance Issues +- **Slow queries**: Add LIMIT clauses +- **Large results**: Use WHERE clauses to filter data +- **Memory issues**: Break large queries into smaller parts + +## Security Considerations + +1. **Never share connection strings** with sensitive credentials +2. **Use read-only users** for exploration when possible +3. **Limit database access** to necessary users only +4. **Monitor query logs** for suspicious activity +5. **Use prepared statements** for user input (when implementing custom queries) + +## Advanced Features + +### Custom Query Templates +Create reusable query templates for common operations: + +```sql +-- Template: Find tables by pattern +SELECT table_name +FROM information_schema.tables +WHERE table_name ILIKE '%pattern%' +AND table_schema = 'public'; + +-- Template: Check table row counts +SELECT + schemaname, + tablename, + n_tup_ins as inserts, + n_tup_upd as updates, + n_tup_del as deletes +FROM pg_stat_user_tables +WHERE schemaname = 'public' +ORDER BY n_tup_ins DESC; +``` + +### Export Results +While the interface doesn't currently support direct export, you can: +1. Copy results to clipboard +2. Use the browser's developer tools to extract data +3. Implement custom export functionality in the future + +## Next Steps + +1. **Explore your database schema** using the table browser +2. **Run sample queries** to understand your data +3. **Create custom queries** for your specific needs +4. **Monitor performance** using the provided queries +5. **Document common queries** for your team + +The PostgreSQL Admin interface provides a powerful way to interact with your database directly from the web interface, making database administration more accessible and efficient. diff --git a/docs/postgres-admin-usage.md b/docs/postgres-admin-usage.md new file mode 100644 index 0000000..1225cf7 --- /dev/null +++ b/docs/postgres-admin-usage.md @@ -0,0 +1,145 @@ +# PostgreSQL Admin Interface + +This document describes how to use the PostgreSQL Admin interface built with Inertia React. + +## Overview + +The PostgreSQL Admin interface provides a web-based tool for: +- Connecting to PostgreSQL databases +- Executing SQL queries +- Browsing database tables +- Viewing table structures + +## Features + +### 1. Database Connection +- Connect using standard PostgreSQL connection strings +- Test connection before proceeding +- Secure connection handling + +### 2. SQL Query Editor +- Execute SELECT, INSERT, UPDATE, DELETE queries +- View results in formatted tables +- Handle both data retrieval and modification queries +- Real-time query execution feedback + +### 3. Table Browser +- List all available tables in the database +- Filter by schema and table type +- Click to view table structure + +### 4. Table Structure Viewer +- Display column information +- Show data types, constraints, and defaults +- Helpful for understanding database schema + +## Usage + +### Accessing the Interface +1. Navigate to `/postgres-admin` in your application +2. Ensure you're authenticated (login required) +3. The interface will be available in the sidebar navigation + +### Connecting to a Database +1. Enter your PostgreSQL connection string in the format: + ``` + postgresql://username:password@host:port/database + ``` +2. Click "Connect" to test the connection +3. Once connected, all features become available + +### Example Connection String +``` +postgresql://user_1_t8IRM6hPPEyZ:Tt3yKoobvcZq@localhost:5432/user_1_iwzKZvLVbF5v +``` + +### Executing Queries +1. Go to the "Query Editor" tab +2. Enter your SQL query in the text area +3. Click "Execute Query" to run it +4. View results in the formatted table below + +### Browsing Tables +1. Go to the "Tables" tab +2. Click "Refresh Tables" to load the current table list +3. Click on any table to view its structure + +### Viewing Table Structure +1. Select a table from the Tables tab +2. Go to the "Table Structure" tab +3. View detailed column information including: + - Column names + - Data types + - Nullability + - Default values + - Constraints + +## Security Features + +- Authentication required for access +- Connection strings are not stored permanently +- All database operations are logged +- Input validation and sanitization + +## Technical Details + +### Backend +- Laravel controller with PDO connections +- RESTful API endpoints for database operations +- Proper error handling and validation + +### Frontend +- React component with TypeScript +- Inertia.js for seamless Laravel-React integration +- Tailwind CSS for styling +- Responsive design for mobile and desktop + +### API Endpoints +- `POST /postgres-admin/connect` - Test database connection +- `POST /postgres-admin/execute-query` - Execute SQL queries +- `POST /postgres-admin/tables` - Get list of tables +- `POST /postgres-admin/table-structure` - Get table structure + +## Troubleshooting + +### Connection Issues +- Verify your connection string format +- Ensure the database server is running +- Check network connectivity +- Verify username/password credentials + +### Query Errors +- Check SQL syntax +- Ensure proper permissions for the database user +- Verify table and column names exist + +### Performance Issues +- Limit large result sets in SELECT queries +- Use appropriate WHERE clauses +- Consider indexing for frequently queried columns + +## Best Practices + +1. **Security**: Never share connection strings with sensitive credentials +2. **Performance**: Use LIMIT clauses for large datasets +3. **Backup**: Always backup data before running destructive queries +4. **Testing**: Test queries on development databases first +5. **Monitoring**: Monitor query performance and execution times + +## Dependencies + +- Laravel (Backend framework) +- Inertia.js (Frontend integration) +- React + TypeScript (Frontend framework) +- Tailwind CSS (Styling) +- PDO (Database connections) +- Axios (HTTP client) + +## Future Enhancements + +- Query history and favorites +- Export results to CSV/JSON +- Visual query builder +- Database backup/restore functionality +- User permission management +- Query performance analysis diff --git a/package-lock.json b/package-lock.json index db7fae3..d1550c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "titanbox", + "name": "closurebox", "lockfileVersion": 3, "requires": true, "packages": { @@ -17,10 +17,13 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.85.3", + "@types/pg": "^8.15.5", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.6.0", @@ -34,6 +37,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "pg": "^8.16.3", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.0.1", @@ -1519,6 +1523,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", @@ -2366,6 +2400,32 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz", + "integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz", + "integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", @@ -2433,6 +2493,26 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", @@ -3580,6 +3660,95 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3628,6 +3797,45 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3906,6 +4114,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4044,6 +4261,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4231,6 +4454,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3808702..9642a8b 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,13 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.85.3", + "@types/pg": "^8.15.5", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.6.0", @@ -35,6 +38,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "pg": "^8.16.3", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.0.1", diff --git a/resources/css/app.css b/resources/css/app.css index e1ddc5a..cc21715 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -34,6 +34,7 @@ --color-accent-content: var(--color-white); --color-accent-foreground: var(--color-neutral-800); } + } @layer base { diff --git a/resources/inertia/components/app-sidebar.tsx b/resources/inertia/components/app-sidebar.tsx index 7f7be69..1f789be 100644 --- a/resources/inertia/components/app-sidebar.tsx +++ b/resources/inertia/components/app-sidebar.tsx @@ -19,6 +19,16 @@ const mainNavItems: NavItem[] = [ href: '/servers', icon: Server, }, + { + title: 'Databases', + href: '/databases', + icon: Folder, + }, + { + title: 'PostgreSQL Admin', + href: '/postgres-admin', + icon: Server, + }, ]; const footerNavItems: NavItem[] = [ diff --git a/resources/inertia/components/cn/tabs.tsx b/resources/inertia/components/cn/tabs.tsx new file mode 100644 index 0000000..9eef97f --- /dev/null +++ b/resources/inertia/components/cn/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/resources/inertia/pages/PostgresAdmin/Index.tsx b/resources/inertia/pages/PostgresAdmin/Index.tsx new file mode 100644 index 0000000..c7dfce4 --- /dev/null +++ b/resources/inertia/pages/PostgresAdmin/Index.tsx @@ -0,0 +1,593 @@ +import React, { useState, useRef } from 'react'; +import { Head } from '@inertiajs/react'; +import axios from 'axios'; +import { Button } from '@/components/cn/button'; +import { Input } from '@/components/cn/input'; +import { Label } from '@/components/cn/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/cn/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/cn/tabs'; +import { Badge } from '@/components/cn/badge'; +import { + Database, + Play, + Table, + Columns, + CheckCircle, + XCircle, + Loader2, + RefreshCw, + ShieldAlert +} from 'lucide-react'; + +interface QueryResult { + type: 'select' | 'modify'; + columns?: string[]; + results?: any[]; + rowCount?: number; + message?: string; +} + +interface TableInfo { + table_name: string; + table_type: string; + table_schema: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; + character_maximum_length: number | null; + numeric_precision: number | null; + numeric_scale: number | null; +} + +const WRITE_REGEX = /^\s*(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE|GRANT|REVOKE|REINDEX|VACUUM|ANALYZE|COMMENT|REFRESH|CLUSTER|COPY|CALL|DO)\b/i; + +export default function PostgresAdmin() { + const [connectionString, setConnectionString] = useState('pgsql:host=localhost;port=5432;dbname=user_1_iwzKZvLVbF5v;user=user_1_t8IRM6hPPEyZ;password=Tt3yKoobvcZq'); + const [isConnected, setIsConnected] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle'); + const [connectionMessage, setConnectionMessage] = useState(''); + + // Safety controls + const [safeMode, setSafeMode] = useState(true); // read-only by default + const [maxRows, setMaxRows] = useState(500); + + const [query, setQuery] = useState('SELECT * FROM information_schema.tables LIMIT 10'); + const [queryResult, setQueryResult] = useState(null); + const [isExecuting, setIsExecuting] = useState(false); + const [executionMessage, setExecutionMessage] = useState(''); + + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(''); + const [tableStructure, setTableStructure] = useState([]); + const [isLoadingTables, setIsLoadingTables] = useState(false); + const [isLoadingStructure, setIsLoadingStructure] = useState(false); + const [environmentInfo, setEnvironmentInfo] = useState(null); + + const queryInputRef = useRef(null); + + const isWriteQuery = (sql: string) => WRITE_REGEX.test(sql); + + const testConnection = async () => { + setConnectionStatus('connecting'); + setConnectionMessage(''); + + try { + const response = await axios.post('/postgres-admin/connect', { + connectionString + }); + + if (response.data.success) { + setIsConnected(true); + setConnectionStatus('connected'); + setConnectionMessage(response.data.message); + loadTables(); + } else { + setConnectionStatus('error'); + setConnectionMessage(response.data.message); + } + } catch (error: any) { + setConnectionStatus('error'); + setConnectionMessage(error.response?.data?.message || 'Connection failed'); + } + }; + + const testEnvironment = async () => { + try { + const response = await axios.get('/postgres-admin/test-env'); + if (response.data.success) { + setEnvironmentInfo(response.data); + } + } catch (error: any) { + console.error('Failed to test environment:', error); + } + }; + + const executeQuery = async () => { + if (!query.trim()) return; + + const willWrite = isWriteQuery(query); + if (willWrite && safeMode) { + setExecutionMessage('Write operations are disabled in Safe Mode. Disable Safe Mode to proceed.'); + return; + } + if (willWrite && !safeMode) { + const confirmed = window.confirm('This query will modify your database. Do you want to proceed?'); + if (!confirmed) return; + } + + setIsExecuting(true); + setExecutionMessage(''); + setQueryResult(null); + + try { + const response = await axios.post('/postgres-admin/execute-query', { + connectionString, + query: query.trim(), + allowWrite: !safeMode, + maxRows: maxRows, + }); + + if (response.data.success) { + setQueryResult(response.data); + setExecutionMessage(response.data.message || 'Query executed successfully'); + } else { + setExecutionMessage(response.data.message || 'Query execution failed'); + } + } catch (error: any) { + setExecutionMessage(error.response?.data?.message || 'Query execution failed'); + } finally { + setIsExecuting(false); + } + }; + + const loadTables = async () => { + setIsLoadingTables(true); + try { + const response = await axios.post('/postgres-admin/tables', { + connectionString + }); + + if (response.data.success) { + setTables(response.data.tables); + } + } catch (error: any) { + console.error('Failed to load tables:', error); + } finally { + setIsLoadingTables(false); + } + }; + + const loadTableStructure = async (tableName: string) => { + setSelectedTable(tableName); + setIsLoadingStructure(true); + + try { + const response = await axios.post('/postgres-admin/table-structure', { + connectionString, + tableName + }); + + if (response.data.success) { + setTableStructure(response.data.columns); + } + } catch (error: any) { + console.error('Failed to load table structure:', error); + } finally { + setIsLoadingStructure(false); + } + }; + + const formatValue = (value: any): string => { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }; + + return ( + <> + + + +
+
+ +

PostgreSQL Admin

+
+ + {/* Connection & Safety Section */} + + + + + Database Connection + + + Connect to your PostgreSQL database using a DSN connection string + + + +
+ +
+ setConnectionString(e.target.value)} + placeholder="pgsql:host=...;port=5432;dbname=...;user=...;password=..." + className="flex-1" + /> + + +
+
+ + {/* Safe mode controls */} +
+
+
+
+ +
+
Safe Mode (Read-only)
+
Blocks destructive queries unless disabled
+
+
+
+ + { + const enableWrites = e.target.checked; + if (enableWrites) { + const ok = window.confirm('Enabling write operations allows queries that modify your database (e.g., DROP/DELETE/ALTER). Proceed?'); + if (!ok) return; + } + setSafeMode(!enableWrites ? true : false); + }} + /> +
+
+
+
+ + setMaxRows(Number(e.target.value) || 1)} + /> +
+
+ + {connectionStatus !== 'idle' && ( +
+ {connectionStatus === 'connected' ? ( + + ) : connectionStatus === 'error' ? ( + + ) : ( + + )} + + {connectionMessage || 'Testing connection...'} + +
+ )} + + {environmentInfo && ( +
+

Environment Information

+
+
PHP Version: {environmentInfo.php_version}
+
OS: {environmentInfo.os}
+
PDO Extension: {environmentInfo.extensions.pdo ? '✅' : '❌'}
+
PDO PostgreSQL: {environmentInfo.extensions.pdo_pgsql ? '✅' : '❌'}
+
PostgreSQL Extension: {environmentInfo.extensions.pgsql ? '✅' : '❌'}
+
Available PDO Drivers: {environmentInfo.pdo_drivers.join(', ') || 'None'}
+
+
+ )} +
+
+ + {isConnected && ( + + + Query Editor + Tables + Table Structure + + + {/* Query Editor Tab */} + + + + + + SQL Query Editor + + + Execute SQL queries against your database + + + + {isWriteQuery(query) && safeMode && ( +
+ This query appears to modify data. Safe Mode is ON and will block this execution. Disable Safe Mode to proceed. +
+ )} + +
+ +