mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
style: backups page touch-ups
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
11
resources/scripts/api/server/backups/renameServerBackup.ts
Normal file
11
resources/scripts/api/server/backups/renameServerBackup.ts
Normal 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);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user