diff --git a/app/components/CloneDatabaseModal.tsx b/app/components/CloneDatabaseModal.tsx new file mode 100644 index 0000000..cf7d7fa --- /dev/null +++ b/app/components/CloneDatabaseModal.tsx @@ -0,0 +1,148 @@ +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 [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(); + window.location.reload(); + } 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); + setProgress(null); + try { + const response = await fetch(`/api/databases/${database}/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newName: dbName }), + }); + 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}`); + } + }; + + return ( + + + + + } + title={localization.cloneDatabaseDialogTitle} + onClose={handleClose} + > +
+ + { + // 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} + /> + {prefix.trim() && ( +
+ {localization.previewDatabaseNameLabel || 'Preview:'} {`${prefix.trim()}_${database}`} +
+ )} + {isCloning && progress && progress.total > 0 && ( +
+
+
+ )} + {isCloning && (!progress || progress.total === 0) && ( +
+
+
+ )} +
+ + ); +} 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..d62d9fd 100644 --- a/app/const/localization.ts +++ b/app/const/localization.ts @@ -91,4 +91,13 @@ 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.', + previewDatabaseNameLabel: 'Preview:', } 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..2c1e511 --- /dev/null +++ b/app/pages/api/databases/[name]/clone.ts @@ -0,0 +1,73 @@ +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(); + await connection.execute(`CREATE DATABASE \`${targetDb}\``); + 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 }>) { + await connection.execute( + `CREATE TABLE \`${targetDb}\`. + \`${table_name}\` LIKE \`${sourceDb}\`. + \`${table_name}\`` + ); + await connection.execute( + `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; + } + + 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); + cloneProgress[newName] = { total: 1, current: 1, done: true, error: error?.toString() }; + 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)} + /> + )} )}