Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions app/components/CloneDatabaseModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('');

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<void> => {
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 (
<ModalDialog
buttons={
<>
<button
className={`${primaryButtonClassName} flex items-center gap-2`}
type="button"
onClick={handleClose}
disabled={isCloning}
>
{localization.cancel}
</button>
<button
className={`${successButtonClassName} flex items-center gap-2`}
type="button"
onClick={handleClone}
disabled={isCloning || !prefix.trim()}
>
{isCloning ? localization.cloningDatabase : localization.cloneDatabase}
</button>
</>
}
title={localization.cloneDatabaseDialogTitle}
onClose={handleClose}
>
<div className="flex flex-col gap-2">
<label htmlFor="clone-prefix">{localization.cloneDatabaseDialogMessage(database)}</label>
<input
id="clone-prefix"
type="text"
className="border rounded p-2"
value={prefix}
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}
/>
{prefix.trim() && (
<div className="text-sm text-gray-600">
{localization.previewDatabaseNameLabel || 'Preview:'} <span className="font-mono">{`${prefix.trim()}_${database}`}</span>
</div>
)}
{isCloning && progress && progress.total > 0 && (
<div className="w-full h-2 bg-gray-200 rounded overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
)}
{isCloning && (!progress || progress.total === 0) && (
<div className="w-full h-2 bg-gray-200 rounded overflow-hidden">
<div className="h-full bg-blue-500 animate-pulse" style={{ width: '60%' }} />
</div>
)}
</div>
</ModalDialog>
);
}
6 changes: 6 additions & 0 deletions app/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ export const icons = {
<circle cx="15" cy="17" r="1" fill="currentColor"/>
</svg>
),
clone: (
<svg aria-hidden className={iconClassName} fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4a2 2 0 012-2h6a2 2 0 012 2v2a1 1 0 102 0V4a4 4 0 00-4-4H6a4 4 0 00-4 4v6a4 4 0 004 4h2a1 1 0 100-2H6a2 2 0 01-2-2V4z" />
<path d="M8 8a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2h-6a2 2 0 01-2-2V8z" />
</svg>
),
} as const;
9 changes: 9 additions & 0 deletions app/const/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
73 changes: 73 additions & 0 deletions app/pages/api/databases/[name]/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUser, noCaching } from '../../../../lib/apiUtils';
import { connectToDatabase } from '../../../../lib/database';

const cloneProgress: Record<string, { total: number; current: number; done: boolean; error?: string }> = {};
/**
* Clone a database by copying all tables and data to a new database name.
*/
async function cloneDatabase(sourceDb: string, targetDb: string): Promise<void> {
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() });
}
}
16 changes: 16 additions & 0 deletions app/pages/databases/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -42,6 +43,7 @@ export default function Index(): JSX.Element {
string | undefined
>(undefined);

const [cloneDatabase, setCloneDatabase] = React.useState<string | undefined>(undefined);
const [showSizes, setShowSizes] = React.useState(false);
const [sizes, setSizes] = React.useState<
{ readonly data: IR<number> } | string | undefined
Expand Down Expand Up @@ -130,6 +132,14 @@ export default function Index(): JSX.Element {
>
{icons.key}
</button>
<button
className="flex items-center justify-center text-purple-500 hover:bg-purple-100 rounded"
type="button"
title={localization.cloneDatabase}
onClick={(): void => setCloneDatabase(name)}
>
{icons.clone}
</button>
</li>
))}
</ul>
Expand Down Expand Up @@ -174,6 +184,12 @@ export default function Index(): JSX.Element {
onClose={(): void => setResetPasswordsDatabase(undefined)}
/>
)}
{typeof cloneDatabase === 'string' && (
<CloneDatabaseModal
database={cloneDatabase}
onClose={(): void => setCloneDatabase(undefined)}
/>
)}
</>
)}
</Layout>
Expand Down