style: backups page touch-ups

This commit is contained in:
Elizabeth
2025-08-17 00:28:41 -05:00
parent e33bd62578
commit ea21e14d38
11 changed files with 275 additions and 130 deletions

View File

@@ -114,6 +114,47 @@ class BackupController extends ClientApiController
->toArray();
}
/**
* Rename a backup.
*
* @throws AuthorizationException
*/
public function rename(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$request->validate([
'name' => 'required|string|min:1|max:191',
]);
$oldName = $backup->name;
$newName = trim($request->input('name'));
// Sanitize backup name to prevent injection
$newName = preg_replace('/[^a-zA-Z0-9\s\-_\.\(\)→:,]/', '', $newName);
$newName = substr($newName, 0, 191); // Limit to database field length
if (empty($newName)) {
throw new BadRequestHttpException('Backup name cannot be empty after sanitization.');
}
$backup->update(['name' => $newName]);
Activity::event('server:backup.rename')
->subject($backup)
->property([
'old_name' => $oldName,
'new_name' => $newName,
])
->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Returns information about a single backup.
*

View File

@@ -115,7 +115,23 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
{
$operation->updateProgress('Creating backup before proceeding...');
$backupName = "Software Change Backup - " . now()->format('Y-m-d H:i:s');
// Get current and target egg names for better backup naming
$currentEgg = $this->server->egg;
$targetEgg = Egg::find($this->eggId);
// Create descriptive backup name
$backupName = sprintf(
'Pre-Change Backup: %s → %s (%s)',
$currentEgg->name ?? 'Unknown',
$targetEgg->name ?? 'Unknown',
now()->format('M j, Y g:i A')
);
// Limit backup name length to prevent database issues
if (strlen($backupName) > 190) {
$backupName = substr($backupName, 0, 187) . '...';
}
$backup = $backupService
->setIsLocked(false)
->handle($this->server, $backupName);

View File

@@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, backup: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/backups/${backup}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@@ -0,0 +1,5 @@
import http from '@/api/http';
export default async (uuid: string, backup: string): Promise<void> => {
await http.delete(`/api/client/servers/${uuid}/backups/${backup}`);
};

View File

@@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, backup: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups/${backup}/download`)
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View File

@@ -0,0 +1,6 @@
import http from '@/api/http';
export default async (uuid: string, backup: string): Promise<string> => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups/${backup}/download`);
return data.attributes.url;
};

View File

@@ -3,3 +3,8 @@ import http from '@/api/http';
export const restoreServerBackup = async (uuid: string, backup: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, {});
};
export { default as createServerBackup } from './createServerBackup';
export { default as deleteServerBackup } from './deleteServerBackup';
export { default as getServerBackupDownloadUrl } from './getServerBackupDownloadUrl';
export { default as renameServerBackup } from './renameServerBackup';

View File

@@ -0,0 +1,11 @@
import http from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
export default async (uuid: string, backup: string, name: string): Promise<ServerBackup> => {
const { data } = await http.post(`/api/client/servers/${uuid}/backups/${backup}/rename`, {
name: name,
});
return rawDataToServerBackup(data);
};

View File

@@ -4,16 +4,23 @@ import ActionButton from '@/components/elements/ActionButton';
import Can from '@/components/elements/Can';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Dialog } from '@/components/elements/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/elements/DropdownMenu';
import HugeIconsAlert from '@/components/elements/hugeicons/Alert';
import HugeIconsCloudUp from '@/components/elements/hugeicons/CloudUp';
import HugeIconsDelete from '@/components/elements/hugeicons/Delete';
import HugeIconsFileDownload from '@/components/elements/hugeicons/FileDownload';
import HugeIconsFileSecurity from '@/components/elements/hugeicons/FileSecurity';
import HugeIconsHamburger from '@/components/elements/hugeicons/hamburger';
import HugeIconsPencil from '@/components/elements/hugeicons/Pencil';
import http, { httpErrorToHuman } from '@/api/http';
import { restoreServerBackup } from '@/api/server/backups';
import deleteBackup from '@/api/server/backups/deleteBackup';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import { restoreServerBackup, renameServerBackup, deleteServerBackup, getServerBackupDownloadUrl } from '@/api/server/backups';
import { ServerBackup } from '@/api/server/types';
import getServerBackups from '@/api/swr/getServerBackups';
@@ -31,13 +38,14 @@ const BackupContextMenu = ({ backup }: Props) => {
const [modal, setModal] = useState('');
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(5);
const [newName, setNewName] = useState(backup.name);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups();
const doDownload = () => {
setLoading(true);
clearFlashes('backups');
getBackupDownloadUrl(uuid, backup.uuid)
getServerBackupDownloadUrl(uuid, backup.uuid)
.then((url) => {
// @ts-expect-error this is valid
window.location = url;
@@ -52,7 +60,7 @@ const BackupContextMenu = ({ backup }: Props) => {
const doDeletion = () => {
setLoading(true);
clearFlashes('backups');
deleteBackup(uuid, backup.uuid)
deleteServerBackup(uuid, backup.uuid)
.then(
async () =>
await mutate(
@@ -104,9 +112,9 @@ const BackupContextMenu = ({ backup }: Props) => {
b.uuid !== backup.uuid
? b
: {
...b,
isLocked: !b.isLocked,
},
...b,
isLocked: !b.isLocked,
},
),
}),
false,
@@ -116,6 +124,37 @@ const BackupContextMenu = ({ backup }: Props) => {
.then(() => setModal(''));
};
const doRename = () => {
setLoading(true);
clearFlashes('backups');
renameServerBackup(uuid, backup.uuid, newName.trim())
.then(
async () =>
await mutate(
(data) => ({
...data!,
items: data!.items.map((b) =>
b.uuid !== backup.uuid
? b
: {
...b,
name: newName.trim(),
},
),
}),
false,
),
)
.catch((error) => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => {
setLoading(false);
setModal('');
});
};
// Countdown effect for restore modal
useEffect(() => {
let interval: NodeJS.Timeout;
@@ -136,8 +175,49 @@ const BackupContextMenu = ({ backup }: Props) => {
}
}, [modal]);
// Reset name when modal opens
useEffect(() => {
if (modal === 'rename') {
setNewName(backup.name);
}
}, [modal, backup.name]);
return (
<>
<Dialog
open={modal === 'rename'}
onClose={() => setModal('')}
title="Rename Backup"
>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-zinc-200 mb-2'>
Backup Name
</label>
<input
type='text'
value={newName}
onChange={(e) => setNewName(e.target.value)}
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 placeholder-zinc-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
placeholder='Enter backup name...'
maxLength={191}
/>
</div>
</div>
<Dialog.Footer>
<ActionButton onClick={() => setModal('')} variant='secondary'>
Cancel
</ActionButton>
<ActionButton
onClick={doRename}
variant='primary'
disabled={!newName.trim() || newName.trim() === backup.name}
>
Rename Backup
</ActionButton>
</Dialog.Footer>
</Dialog>
<Dialog.Confirm
open={modal === 'unlock'}
onClose={() => setModal('')}
@@ -158,7 +238,7 @@ const BackupContextMenu = ({ backup }: Props) => {
Your server will be stopped during the restoration process. You will not be able to control the power state, access the file manager, or create additional backups until completed.
</p>
</div>
<div className='p-4 bg-red-500/10 border border-red-500/20 rounded-lg'>
<div className='flex items-start space-x-3'>
<HugeIconsAlert fill='currentColor' className='w-5 h-5 text-red-400 flex-shrink-0 mt-0.5' />
@@ -201,56 +281,55 @@ const BackupContextMenu = ({ backup }: Props) => {
</Dialog.Confirm>
<SpinnerOverlay visible={loading} fixed />
{backup.isSuccessful ? (
<div className='flex flex-wrap gap-2'>
<Can action={'backup.download'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton
variant='secondary'
size='sm'
onClick={doDownload}
disabled={loading}
className='flex items-center gap-2'
className='flex items-center justify-center w-8 h-8 p-0 hover:bg-zinc-700'
>
<HugeIconsFileDownload className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Download</span>
<HugeIconsHamburger className='h-4 w-4' fill='currentColor' />
</ActionButton>
</Can>
<Can action={'backup.restore'}>
<ActionButton
variant='secondary'
size='sm'
onClick={() => setModal('restore')}
disabled={loading}
className='flex items-center gap-2'
>
<HugeIconsCloudUp className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Restore</span>
</ActionButton>
</Can>
<Can action={'backup.delete'}>
<ActionButton
variant='secondary'
size='sm'
onClick={onLockToggle}
disabled={loading}
className='flex items-center gap-2'
>
<HugeIconsFileSecurity className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>{backup.isLocked ? 'Unlock' : 'Lock'}</span>
</ActionButton>
{!backup.isLocked && (
<ActionButton
variant='danger'
size='sm'
onClick={() => setModal('delete')}
disabled={loading}
className='flex items-center gap-2'
>
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Delete</span>
</ActionButton>
)}
</Can>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
<Can action={'backup.download'}>
<DropdownMenuItem onClick={doDownload} className='cursor-pointer'>
<HugeIconsFileDownload className='h-4 w-4 mr-2' fill='currentColor' />
Download
</DropdownMenuItem>
</Can>
<Can action={'backup.restore'}>
<DropdownMenuItem onClick={() => setModal('restore')} className='cursor-pointer'>
<HugeIconsCloudUp className='h-4 w-4 mr-2' fill='currentColor' />
Restore
</DropdownMenuItem>
</Can>
<Can action={'backup.delete'}>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setModal('rename')} className='cursor-pointer'>
<HugeIconsPencil className='h-4 w-4 mr-2' fill='currentColor' />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={onLockToggle} className='cursor-pointer'>
<HugeIconsFileSecurity className='h-4 w-4 mr-2' fill='currentColor' />
{backup.isLocked ? 'Unlock' : 'Lock'}
</DropdownMenuItem>
{!backup.isLocked && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setModal('delete')}
className='cursor-pointer text-red-400 focus:text-red-300'
>
<HugeIconsDelete className='h-4 w-4 mr-2' fill='currentColor' />
Delete
</DropdownMenuItem>
</>
)}
</Can>
</DropdownMenuContent>
</DropdownMenu>
) : (
<ActionButton
variant='danger'

View File

@@ -1,20 +1,16 @@
import { faFile, faLock } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { format, formatDistanceToNow } from 'date-fns';
import Can from '@/components/elements/Can';
import { ContextMenu, ContextMenuTrigger } from '@/components/elements/ContextMenu';
import Spinner from '@/components/elements/Spinner';
import { PageListItem } from '@/components/elements/pages/PageList';
import { SocketEvent } from '@/components/server/events';
import HugeIconsStorage from '@/components/elements/hugeicons/Storage';
import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock';
import { bytesToString } from '@/lib/formatters';
import { ServerBackup } from '@/api/server/types';
// import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import getServerBackups from '@/api/swr/getServerBackups';
// import Can from '@/components/elements/Can';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import BackupContextMenu from './BackupContextMenu';
@@ -38,60 +34,63 @@ const BackupRow = ({ backup }: Props) => {
return (
<PageListItem>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 w-full'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-2'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{backup.completedAt === null ? (
<Spinner size={'small'} />
) : backup.isLocked ? (
<FontAwesomeIcon icon={faLock} className='text-red-400 w-4 h-4' />
) : backup.isSuccessful ? (
<FontAwesomeIcon icon={faFile} className='text-green-400 w-4 h-4' />
) : (
<FontAwesomeIcon icon={faFile} className='text-red-400 w-4 h-4' />
)}
</div>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 mb-1'>
{backup.completedAt !== null && !backup.isSuccessful && (
<span className='bg-red-500 py-1 px-2 rounded-full text-white text-xs uppercase font-medium'>
Failed
</span>
)}
<h3 className='text-base font-medium text-zinc-100 truncate'>{backup.name}</h3>
{backup.isLocked && (
<span className='text-xs text-red-400 font-medium bg-red-500/10 px-2 py-1 rounded'>
Locked
</span>
)}
</div>
{backup.checksum && (
<p className='text-sm text-zinc-400 font-mono truncate'>{backup.checksum}</p>
)}
</div>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
{backup.completedAt !== null && backup.isSuccessful && (
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Size</p>
<p className='text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
</div>
)}
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Created</p>
<p
className='text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
</div>
</div>
<div className='flex items-center gap-4 w-full py-1'>
{/* Status Icon */}
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{backup.completedAt === null ? (
<Spinner size={'small'} />
) : backup.isLocked ? (
<HugeIconsSquareLock className='text-red-400 w-4 h-4' fill='currentColor' />
) : backup.isSuccessful ? (
<HugeIconsStorage className='text-green-400 w-4 h-4' fill='currentColor' />
) : (
<HugeIconsStorage className='text-red-400 w-4 h-4' fill='currentColor' />
)}
</div>
<div className='flex items-center gap-2 sm:flex-col sm:gap-3'>
{/* Main Content */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
{backup.completedAt !== null && !backup.isSuccessful && (
<span className='bg-red-500/20 border border-red-500/30 py-0.5 px-2 rounded text-red-300 text-xs font-medium'>
Failed
</span>
)}
<h3 className='text-sm font-medium text-zinc-100 truncate'>{backup.name}</h3>
<span
className={`text-xs text-red-400 font-medium bg-red-500/10 border border-red-500/20 px-1.5 py-0.5 rounded transition-opacity ${
backup.isLocked ? 'opacity-100' : 'opacity-0'
}`}
>
Locked
</span>
</div>
{backup.checksum && (
<p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>
)}
</div>
{/* Size Info */}
{backup.completedAt !== null && backup.isSuccessful && (
<div className='hidden sm:block flex-shrink-0 text-right'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
<p className='text-sm text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
</div>
)}
{/* Date Info */}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[120px]'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Created</p>
<p
className='text-sm text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
</div>
{/* Actions Menu */}
<div className='flex-shrink-0'>
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
{backup.completedAt ? <BackupContextMenu backup={backup} /> : null}
</Can>

View File

@@ -134,6 +134,7 @@ Route::group([
Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']);
Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']);
Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::post('/{backup}/rename', [Client\Servers\BackupController::class, 'rename']);
Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore'])
->middleware('server.operation.rate-limit');
Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']);