From bc6f06fc66a3a56a7fc959b945ecc5cb2875b65d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:33:26 -0500 Subject: [PATCH 1/4] feat: add clone tool --- app/components/CloneDatabaseModal.tsx | 83 +++++++++++++++++++++++++ app/components/Icons.tsx | 6 ++ app/const/localization.ts | 8 +++ app/pages/api/databases/[name]/clone.ts | 56 +++++++++++++++++ app/pages/databases/index.tsx | 16 +++++ 5 files changed, 169 insertions(+) create mode 100644 app/components/CloneDatabaseModal.tsx create mode 100644 app/pages/api/databases/[name]/clone.ts diff --git a/app/components/CloneDatabaseModal.tsx b/app/components/CloneDatabaseModal.tsx new file mode 100644 index 0000000..40fd2b6 --- /dev/null +++ b/app/components/CloneDatabaseModal.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { ModalDialog } from './ModalDialog'; +import { successButtonClassName, primaryButtonClassName } from './InteractivePrimitives'; +import { localization } from '../const/localization'; + +export function CloneDatabaseModal({ + database, + onClose: handleClose, +}: { + readonly database: string; + readonly onClose: () => void; +}): JSX.Element { + const [prefix, setPrefix] = React.useState(''); + const [isCloning, setIsCloning] = React.useState(false); + + const handleClone = async (): Promise => { + if (!prefix.trim()) { + alert(localization.enterPrefix); + return; + } + setIsCloning(true); + const newDbName = `${prefix.trim()}_${database}`; + try { + const response = await fetch(`/api/databases/${database}/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newName: newDbName }), + }); + if (response.ok) { + alert(localization.databaseCloned(newDbName)); + handleClose(); + } else { + const error = await response.json(); + alert(`${localization.failedToCloneDatabase}: ${error.error || response.statusText}`); + } + } catch (error) { + console.error('Failed to clone database:', error); + alert(`${localization.failedToCloneDatabase}: ${error}`); + } finally { + setIsCloning(false); + } + }; + + return ( + + + + + } + title={localization.cloneDatabaseDialogTitle} + onClose={handleClose} + > +
+ + setPrefix(e.target.value)} + placeholder={localization.cloneDatabasePrefixPlaceholder} + disabled={isCloning} + /> +
+
+ ); +} diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx index eebb185..d7d9501 100644 --- a/app/components/Icons.tsx +++ b/app/components/Icons.tsx @@ -33,4 +33,10 @@ export const icons = { ), + clone: ( + + + + + ), } as const; diff --git a/app/const/localization.ts b/app/const/localization.ts index 3fab37d..7242ec0 100644 --- a/app/const/localization.ts +++ b/app/const/localization.ts @@ -91,4 +91,12 @@ export const localization = { noLogsAvailable: 'No logs available for this container', failedToDownload: 'Failed to download', pleaseTryAgain: 'Please try again.', + cloneDatabase: 'Clone Database', + cloningDatabase: 'Cloning...', + cloneDatabaseDialogTitle: 'Clone Database', + cloneDatabaseDialogMessage: (database: string): string => `Enter a prefix for the new database cloned from ${database}:`, + cloneDatabasePrefixPlaceholder: 'Prefix', + databaseCloned: (database: string): string => `Database cloned as ${database}.`, + failedToCloneDatabase: 'Failed to clone database', + enterPrefix: 'Please enter a prefix.', } as const; diff --git a/app/pages/api/databases/[name]/clone.ts b/app/pages/api/databases/[name]/clone.ts new file mode 100644 index 0000000..f09ae58 --- /dev/null +++ b/app/pages/api/databases/[name]/clone.ts @@ -0,0 +1,56 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getUser, noCaching } from '../../../../lib/apiUtils'; +import { connectToDatabase } from '../../../../lib/database'; + +/** + * Clone a database by copying all tables and data to a new database name. + */ +async function cloneDatabase(sourceDb: string, targetDb: string): Promise { + const connection = await connectToDatabase(); + + // Create the new database + await connection.execute(`CREATE DATABASE \`${targetDb}\``); + + // Get all tables from the source database + const [tables] = await connection.query( + `SELECT table_name FROM information_schema.tables WHERE table_schema = ?`, + [sourceDb] + ); + + for (const { table_name } of tables as Array<{ table_name: string }>) { + // Copy table structure + await connection.execute( + `CREATE TABLE \`${targetDb}\`.\`${table_name}\` LIKE \`${sourceDb}\`.\`${table_name}\`` + ); + // Copy table data + await connection.execute( + `INSERT INTO \`${targetDb}\`.\`${table_name}\` SELECT * FROM \`${sourceDb}\`.\`${table_name}\`` + ); + } +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const user = await getUser(req, res); + if (typeof user === 'undefined') return; + + if (req.method !== 'POST') { + res.status(405).send({ error: 'Method not allowed' }); + return; + } + + const { name } = req.query; + const { newName } = req.body; + + if (typeof name !== 'string' || typeof newName !== 'string' || !newName.trim()) { + res.status(400).send({ error: 'Invalid database name(s)' }); + return; + } + + try { + await cloneDatabase(name, newName); + noCaching(res).status(200).send({ success: true }); + } catch (error) { + console.error(error); + res.status(500).send({ error: error?.toString() }); + } +} diff --git a/app/pages/databases/index.tsx b/app/pages/databases/index.tsx index 0083e64..165c6b6 100644 --- a/app/pages/databases/index.tsx +++ b/app/pages/databases/index.tsx @@ -16,6 +16,7 @@ import { Database, useDatabases } from '../index'; import { multiSortFunction } from '../../lib/helpers'; import { Deployment } from '../../lib/deployment'; import { localization } from '../../const/localization'; +import { CloneDatabaseModal } from '../../components/CloneDatabaseModal'; type DatabaseWithSize = Database & { readonly size?: string | undefined }; @@ -42,6 +43,7 @@ export default function Index(): JSX.Element { string | undefined >(undefined); + const [cloneDatabase, setCloneDatabase] = React.useState(undefined); const [showSizes, setShowSizes] = React.useState(false); const [sizes, setSizes] = React.useState< { readonly data: IR } | string | undefined @@ -130,6 +132,14 @@ export default function Index(): JSX.Element { > {icons.key} + ))} @@ -174,6 +184,12 @@ export default function Index(): JSX.Element { onClose={(): void => setResetPasswordsDatabase(undefined)} /> )} + {typeof cloneDatabase === 'string' && ( + setCloneDatabase(undefined)} + /> + )} )} From 6642379d8140b65db4f840d79d317ee551675ddb Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 21 Jul 2025 01:17:08 -0500 Subject: [PATCH 2/4] feat: add cloning interface --- app/components/CloneDatabaseModal.tsx | 78 +++++++++++++++++++++---- app/const/localization.ts | 1 + app/pages/api/databases/[name]/clone.ts | 35 ++++++++--- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/app/components/CloneDatabaseModal.tsx b/app/components/CloneDatabaseModal.tsx index 40fd2b6..f2d5c96 100644 --- a/app/components/CloneDatabaseModal.tsx +++ b/app/components/CloneDatabaseModal.tsx @@ -12,32 +12,72 @@ export function CloneDatabaseModal({ }): JSX.Element { const [prefix, setPrefix] = React.useState(''); const [isCloning, setIsCloning] = React.useState(false); + const [progress, setProgress] = React.useState<{ total: number; current: number; done: boolean; error?: string } | null>(null); + const [newDbName, setNewDbName] = React.useState(''); + + React.useEffect(() => { + let interval: NodeJS.Timeout | null = null; + if (isCloning && newDbName) { + interval = setInterval(async () => { + try { + const res = await fetch(`/api/databases/${database}/clone?newName=${encodeURIComponent(newDbName)}`); + if (res.ok) { + const status = await res.json(); + setProgress(status); + if (status.done) { + clearInterval(interval!); + setIsCloning(false); + if (!status.error) { + alert(localization.databaseCloned(newDbName)); + handleClose(); + } else { + alert(`${localization.failedToCloneDatabase}: ${status.error}`); + } + } + } + } catch (err) { + // ignore polling errors + } + }, 1000); + } + return () => { + if (interval) clearInterval(interval); + }; + }, [isCloning, newDbName, database, handleClose]); const handleClone = async (): Promise => { if (!prefix.trim()) { alert(localization.enterPrefix); return; } + const dbName = `${prefix.trim()}_${database}`; + setNewDbName(dbName); setIsCloning(true); - const newDbName = `${prefix.trim()}_${database}`; + setProgress(null); try { const response = await fetch(`/api/databases/${database}/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newName: newDbName }), + body: JSON.stringify({ newName: dbName }), }); - if (response.ok) { - alert(localization.databaseCloned(newDbName)); - handleClose(); - } else { - const error = await response.json(); - alert(`${localization.failedToCloneDatabase}: ${error.error || response.statusText}`); + if (!response.ok) { + let errorMessage = response.statusText; + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const error = await response.json(); + errorMessage = error.error || response.statusText; + } catch (jsonError) { + // Fallback to status text if JSON parsing fails + } + } + setIsCloning(false); + alert(`${localization.failedToCloneDatabase}: ${errorMessage}`); } } catch (error) { + setIsCloning(false); console.error('Failed to clone database:', error); alert(`${localization.failedToCloneDatabase}: ${error}`); - } finally { - setIsCloning(false); } }; @@ -77,6 +117,24 @@ export function CloneDatabaseModal({ placeholder={localization.cloneDatabasePrefixPlaceholder} disabled={isCloning} /> + {prefix.trim() && ( +
+ {localization.previewDatabaseNameLabel || 'Preview:'} {`${prefix.trim()}_${database}`} +
+ )} + {isCloning && progress && progress.total > 0 && ( +
+
+
+ )} + {isCloning && (!progress || progress.total === 0) && ( +
+
+
+ )}
); diff --git a/app/const/localization.ts b/app/const/localization.ts index 7242ec0..d62d9fd 100644 --- a/app/const/localization.ts +++ b/app/const/localization.ts @@ -99,4 +99,5 @@ export const localization = { databaseCloned: (database: string): string => `Database cloned as ${database}.`, failedToCloneDatabase: 'Failed to clone database', enterPrefix: 'Please enter a prefix.', + previewDatabaseNameLabel: 'Preview:', } as const; diff --git a/app/pages/api/databases/[name]/clone.ts b/app/pages/api/databases/[name]/clone.ts index f09ae58..2c1e511 100644 --- a/app/pages/api/databases/[name]/clone.ts +++ b/app/pages/api/databases/[name]/clone.ts @@ -2,37 +2,53 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { getUser, noCaching } from '../../../../lib/apiUtils'; import { connectToDatabase } from '../../../../lib/database'; +const cloneProgress: Record = {}; /** * Clone a database by copying all tables and data to a new database name. */ async function cloneDatabase(sourceDb: string, targetDb: string): Promise { const connection = await connectToDatabase(); - - // Create the new database await connection.execute(`CREATE DATABASE \`${targetDb}\``); - - // Get all tables from the source database const [tables] = await connection.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = ?`, [sourceDb] ); - + cloneProgress[targetDb] = { total: (tables as Array<{ table_name: string }>).length, current: 0, done: false }; for (const { table_name } of tables as Array<{ table_name: string }>) { - // Copy table structure await connection.execute( - `CREATE TABLE \`${targetDb}\`.\`${table_name}\` LIKE \`${sourceDb}\`.\`${table_name}\`` + `CREATE TABLE \`${targetDb}\`. + \`${table_name}\` LIKE \`${sourceDb}\`. + \`${table_name}\`` ); - // Copy table data await connection.execute( - `INSERT INTO \`${targetDb}\`.\`${table_name}\` SELECT * FROM \`${sourceDb}\`.\`${table_name}\`` + `INSERT INTO \`${targetDb}\`. + \`${table_name}\` SELECT * FROM \`${sourceDb}\`. + \`${table_name}\`` ); + cloneProgress[targetDb].current++; } + cloneProgress[targetDb].done = true; } export default async function handler(req: NextApiRequest, res: NextApiResponse) { const user = await getUser(req, res); if (typeof user === 'undefined') return; + if (req.method === 'GET') { + const { newName } = req.query; + if (typeof newName !== 'string' || !newName.trim()) { + res.status(400).send({ error: 'Missing new database name' }); + return; + } + const status = cloneProgress[newName]; + if (!status) { + res.status(404).send({ error: 'No clone in progress or not found' }); + return; + } + noCaching(res).status(200).json(status); + return; + } + if (req.method !== 'POST') { res.status(405).send({ error: 'Method not allowed' }); return; @@ -51,6 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) noCaching(res).status(200).send({ success: true }); } catch (error) { console.error(error); + cloneProgress[newName] = { total: 1, current: 1, done: true, error: error?.toString() }; res.status(500).send({ error: error?.toString() }); } } From ec8b248aae440e49e04dc1bee4c20b440c7397e1 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 21 Jul 2025 01:23:55 -0500 Subject: [PATCH 3/4] fix: sanitize cloned database names --- app/components/CloneDatabaseModal.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/CloneDatabaseModal.tsx b/app/components/CloneDatabaseModal.tsx index f2d5c96..191b25f 100644 --- a/app/components/CloneDatabaseModal.tsx +++ b/app/components/CloneDatabaseModal.tsx @@ -113,7 +113,13 @@ export function CloneDatabaseModal({ type="text" className="border rounded p-2" value={prefix} - onChange={e => setPrefix(e.target.value)} + onChange={e => { + // Only allow underscores, no dashes or spaces + const raw = e.target.value; + // Replace any non-word character (except underscore) with underscore + const sanitized = raw.replace(/[^A-Za-z0-9_]+/g, '_'); + setPrefix(sanitized); + }} placeholder={localization.cloneDatabasePrefixPlaceholder} disabled={isCloning} /> From 4de8751c30804a9ff557f06c5a1dc33d27b2e199 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 21 Jul 2025 01:29:13 -0500 Subject: [PATCH 4/4] fix: automatically refresh after clone --- app/components/CloneDatabaseModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/CloneDatabaseModal.tsx b/app/components/CloneDatabaseModal.tsx index 191b25f..cf7d7fa 100644 --- a/app/components/CloneDatabaseModal.tsx +++ b/app/components/CloneDatabaseModal.tsx @@ -30,6 +30,7 @@ export function CloneDatabaseModal({ if (!status.error) { alert(localization.databaseCloned(newDbName)); handleClose(); + window.location.reload(); } else { alert(`${localization.failedToCloneDatabase}: ${status.error}`); }