mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
feat: fully functional deduplicated backups
This commit is contained in:
@@ -8,11 +8,14 @@ use Pterodactyl\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Pterodactyl\Services\Elytra\ElytraJobService;
|
||||
use Pterodactyl\Services\Backups\DownloadLinkService;
|
||||
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
|
||||
|
||||
@@ -22,6 +25,7 @@ class BackupsController extends ClientApiController
|
||||
private ElytraJobService $elytraJobService,
|
||||
private DownloadLinkService $downloadLinkService,
|
||||
private BackupTransformer $transformer,
|
||||
private Google2FA $google2FA,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -38,12 +42,38 @@ class BackupsController extends ClientApiController
|
||||
->orderByRaw('is_locked DESC, created_at DESC')
|
||||
->paginate($limit);
|
||||
|
||||
$rusticBackupSum = $server->backups()
|
||||
->where('is_successful', true)
|
||||
->whereIn('disk', [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3])
|
||||
->sum('bytes');
|
||||
|
||||
$rusticSumMb = round($rusticBackupSum / 1024 / 1024, 2);
|
||||
|
||||
$legacyBackupSum = $server->backups()
|
||||
->where('is_successful', true)
|
||||
->whereNotIn('disk', [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3])
|
||||
->sum('bytes');
|
||||
|
||||
$legacyUsageMb = round($legacyBackupSum / 1024 / 1024, 2);
|
||||
|
||||
$repositoryUsageMb = round($server->repository_backup_bytes / 1024 / 1024, 2);
|
||||
|
||||
$overheadMb = max(0, $repositoryUsageMb - $rusticSumMb);
|
||||
|
||||
$totalUsedMb = $legacyUsageMb + $repositoryUsageMb;
|
||||
|
||||
return $this->fractal->collection($backups)
|
||||
->transformWith($this->transformer)
|
||||
->addMeta([
|
||||
'backup_count' => $server->backups()->count(),
|
||||
'storage' => [
|
||||
'used_mb' => round($server->backups()->where('is_successful', true)->sum('bytes') / 1024 / 1024, 2),
|
||||
'used_mb' => $totalUsedMb,
|
||||
'legacy_usage_mb' => $legacyUsageMb,
|
||||
'repository_usage_mb' => $repositoryUsageMb,
|
||||
'rustic_backup_sum_mb' => $rusticSumMb,
|
||||
'overhead_mb' => $overheadMb,
|
||||
'overhead_percent' => $rusticSumMb > 0 ? round(($overheadMb / $rusticSumMb) * 100, 1) : 0,
|
||||
'needs_pruning' => $overheadMb > $rusticSumMb * 0.1,
|
||||
'limit_mb' => null,
|
||||
'has_limit' => false,
|
||||
'usage_percentage' => null,
|
||||
@@ -103,12 +133,35 @@ class BackupsController extends ClientApiController
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
// Only require password/2FA for web session requests, not API keys
|
||||
if (!$request->user()->currentAccessToken()) {
|
||||
// Require password confirmation for this destructive operation
|
||||
$password = $request->input('password');
|
||||
if (empty($password) || !password_verify($password, $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require TOTP code
|
||||
if ($request->user()->use_totp) {
|
||||
$totpCode = $request->input('totp_code');
|
||||
if (empty($totpCode)) {
|
||||
throw new BadRequestHttpException('Two-factor authentication code is required.');
|
||||
}
|
||||
|
||||
$secret = Crypt::decrypt($request->user()->totp_secret);
|
||||
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
|
||||
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->elytraJobService->submitJob(
|
||||
$server,
|
||||
'backup_delete',
|
||||
[
|
||||
'operation' => 'delete',
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
],
|
||||
$request->user()
|
||||
);
|
||||
@@ -127,12 +180,35 @@ class BackupsController extends ClientApiController
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
// Only require password/2FA for web session requests, not API keys
|
||||
if (!$request->user()->currentAccessToken()) {
|
||||
// Require password confirmation for this destructive operation
|
||||
$password = $request->input('password');
|
||||
if (empty($password) || !password_verify($password, $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require TOTP code
|
||||
if ($request->user()->use_totp) {
|
||||
$totpCode = $request->input('totp_code');
|
||||
if (empty($totpCode)) {
|
||||
throw new BadRequestHttpException('Two-factor authentication code is required.');
|
||||
}
|
||||
|
||||
$secret = Crypt::decrypt($request->user()->totp_secret);
|
||||
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
|
||||
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->elytraJobService->submitJob(
|
||||
$server,
|
||||
'backup_restore',
|
||||
[
|
||||
'operation' => 'restore',
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'truncate_directory' => $request->boolean('truncate_directory'),
|
||||
],
|
||||
$request->user()
|
||||
@@ -216,4 +292,146 @@ class BackupsController extends ClientApiController
|
||||
|
||||
return new JsonResponse($transformed);
|
||||
}
|
||||
|
||||
public function deleteAll(Request $request, Server $server): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
// Only require password/2FA for web session requests, not API keys
|
||||
if (!$request->user()->currentAccessToken()) {
|
||||
// Require password confirmation for this destructive operation
|
||||
$password = $request->input('password');
|
||||
if (empty($password) || !password_verify($password, $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require TOTP code
|
||||
if ($request->user()->use_totp) {
|
||||
$totpCode = $request->input('totp_code');
|
||||
if (empty($totpCode)) {
|
||||
throw new BadRequestHttpException('Two-factor authentication code is required.');
|
||||
}
|
||||
|
||||
$secret = Crypt::decrypt($request->user()->totp_secret);
|
||||
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
|
||||
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$backupCount = $server->backups()->count();
|
||||
|
||||
if ($backupCount === 0) {
|
||||
return new JsonResponse([
|
||||
'error' => 'No backups to delete.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->elytraJobService->submitJob(
|
||||
$server,
|
||||
'backup_delete_all',
|
||||
[
|
||||
'operation' => 'delete_all',
|
||||
],
|
||||
$request->user()
|
||||
);
|
||||
|
||||
Activity::event('backup:delete_all')
|
||||
->subject($server)
|
||||
->property(['backup_count' => $backupCount, 'job_id' => $result['job_id']])
|
||||
->log();
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
|
||||
public function bulkDelete(Request $request, Server $server): JsonResponse
|
||||
{
|
||||
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
// Only require password/2FA for web session requests, not API keys
|
||||
if (!$request->user()->currentAccessToken()) {
|
||||
// Require password confirmation for this destructive operation
|
||||
$password = $request->input('password');
|
||||
if (empty($password) || !password_verify($password, $request->user()->password)) {
|
||||
throw new BadRequestHttpException('The password provided was not valid.');
|
||||
}
|
||||
|
||||
// If user has 2FA enabled, require TOTP code
|
||||
if ($request->user()->use_totp) {
|
||||
$totpCode = $request->input('totp_code');
|
||||
if (empty($totpCode)) {
|
||||
throw new BadRequestHttpException('Two-factor authentication code is required.');
|
||||
}
|
||||
|
||||
$secret = Crypt::decrypt($request->user()->totp_secret);
|
||||
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
|
||||
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate backup_uuids
|
||||
$backupUuids = $request->input('backup_uuids', []);
|
||||
if (empty($backupUuids) || !is_array($backupUuids)) {
|
||||
return new JsonResponse([
|
||||
'error' => 'No backups specified for deletion.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Limit to reasonable number of backups at once
|
||||
if (count($backupUuids) > 50) {
|
||||
return new JsonResponse([
|
||||
'error' => 'Cannot delete more than 50 backups at once. Use Delete All for larger operations.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Verify all backups belong to this server
|
||||
$backups = $server->backups()->whereIn('uuid', $backupUuids)->get();
|
||||
if ($backups->count() !== count($backupUuids)) {
|
||||
return new JsonResponse([
|
||||
'error' => 'One or more backups not found or do not belong to this server.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Submit individual delete jobs for each backup
|
||||
$jobIds = [];
|
||||
foreach ($backups as $backup) {
|
||||
try {
|
||||
$result = $this->elytraJobService->submitJob(
|
||||
$server,
|
||||
'backup_delete',
|
||||
[
|
||||
'operation' => 'delete',
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'adapter_type' => $backup->getElytraAdapterType(),
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'checksum' => $backup->checksum,
|
||||
],
|
||||
$request->user()
|
||||
);
|
||||
|
||||
$jobIds[] = $result['job_id'];
|
||||
} catch (\Exception $e) {
|
||||
// Log error but continue with other backups
|
||||
\Log::error("Failed to submit delete job for backup {$backup->uuid}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Activity::event('backup:bulk_delete')
|
||||
->subject($server)
|
||||
->property(['backup_count' => count($backupUuids), 'job_ids' => $jobIds])
|
||||
->log();
|
||||
|
||||
return new JsonResponse([
|
||||
'message' => 'Bulk delete jobs submitted successfully',
|
||||
'job_count' => count($jobIds),
|
||||
'backup_count' => count($backupUuids),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Repositories\Eloquent\ServerRepository;
|
||||
use Pterodactyl\Services\Servers\ReinstallServerService;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Services\ServerOperations\ServerOperationService;
|
||||
use Pterodactyl\Services\ServerOperations\ServerStateValidationService;
|
||||
@@ -32,7 +31,6 @@ class SettingsController extends ClientApiController
|
||||
private ServerRepository $repository,
|
||||
private ReinstallServerService $reinstallServerService,
|
||||
private StartupModificationService $startupModificationService,
|
||||
private InitiateBackupService $backupService,
|
||||
private DaemonFileRepository $fileRepository,
|
||||
private ServerOperationService $operationService,
|
||||
private ServerStateValidationService $validationService,
|
||||
|
||||
@@ -28,6 +28,7 @@ class ElytraJobCompleteRequest extends FormRequest
|
||||
'size' => 'nullable|integer|min:0',
|
||||
'snapshot_id' => 'nullable|string',
|
||||
'adapter' => 'nullable|string',
|
||||
'repository_size' => 'nullable|integer|min:0',
|
||||
'result_data' => 'nullable|array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Services\Elytra\ElytraJobService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
|
||||
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
@@ -21,9 +21,6 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
use InteractsWithQueue;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* RunTaskJob constructor.
|
||||
*/
|
||||
public function __construct(public Task $task, public bool $manualRun = false)
|
||||
{
|
||||
$this->queue = 'standard';
|
||||
@@ -36,7 +33,7 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
*/
|
||||
public function handle(
|
||||
DaemonCommandRepository $commandRepository,
|
||||
InitiateBackupService $backupService,
|
||||
ElytraJobService $elytraJobService,
|
||||
DaemonPowerRepository $powerRepository,
|
||||
) {
|
||||
// Do not process a task that is not set to active, unless it's been manually triggered.
|
||||
@@ -70,7 +67,22 @@ class RunTaskJob extends Job implements ShouldQueue
|
||||
case Task::ACTION_BACKUP:
|
||||
// Mark the task as running before initiating the backup to prevent duplicate runs
|
||||
$this->task->update(['is_processing' => true]);
|
||||
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
|
||||
|
||||
$ignoredFiles = !empty($this->task->payload) ? explode(PHP_EOL, $this->task->payload) : [];
|
||||
|
||||
$elytraJobService->submitJob(
|
||||
$server,
|
||||
'backup_create',
|
||||
[
|
||||
'operation' => 'create',
|
||||
'adapter' => config('backups.default', 'elytra'),
|
||||
'ignored' => implode("\n", $ignoredFiles),
|
||||
'name' => 'Scheduled Backup - ' . now()->format('Y-m-d H:i'),
|
||||
'is_automatic' => true,
|
||||
],
|
||||
auth()->user() ?? $server->user
|
||||
);
|
||||
|
||||
$this->task->update(['is_processing' => false]);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -7,7 +7,6 @@ use Carbon\Carbon;
|
||||
use Pterodactyl\Jobs\Job;
|
||||
use Pterodactyl\Models\Egg;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,7 +17,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Pterodactyl\Models\ServerOperation;
|
||||
use Pterodactyl\Services\Servers\ReinstallServerService;
|
||||
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||
use Pterodactyl\Services\Elytra\ElytraJobService;
|
||||
use Pterodactyl\Services\Servers\StartupModificationService;
|
||||
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
|
||||
use Pterodactyl\Exceptions\Service\Backup\BackupFailedException;
|
||||
@@ -61,7 +60,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
* Execute the egg change job.
|
||||
*/
|
||||
public function handle(
|
||||
InitiateBackupService $backupService,
|
||||
ElytraJobService $elytraJobService,
|
||||
ReinstallServerService $reinstallServerService,
|
||||
StartupModificationService $startupModificationService,
|
||||
DaemonFileRepository $fileRepository,
|
||||
@@ -88,14 +87,17 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
->with(['variables', 'nest'])
|
||||
->findOrFail($this->eggId);
|
||||
|
||||
$backup = null;
|
||||
|
||||
$backupJobId = null;
|
||||
if ($this->shouldBackup) {
|
||||
$backup = $this->createBackup($backupService, $operation);
|
||||
$backupJobId = $this->createBackup($elytraJobService, $operation);
|
||||
}
|
||||
|
||||
if ($this->shouldWipe) {
|
||||
$this->wipeServerFiles($fileRepository, $operation, $backup);
|
||||
// If we created a backup, wait for it to complete before wiping
|
||||
if ($backupJobId) {
|
||||
$this->waitForJobCompletion($elytraJobService, $backupJobId, $operation);
|
||||
}
|
||||
$this->wipeServerFiles($fileRepository, $operation);
|
||||
}
|
||||
|
||||
$this->applyServerChanges($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService);
|
||||
@@ -113,56 +115,100 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
/**
|
||||
* Create backup before proceeding with changes.
|
||||
*/
|
||||
private function createBackup(InitiateBackupService $backupService, ServerOperation $operation): Backup
|
||||
private function createBackup(ElytraJobService $elytraJobService, ServerOperation $operation): string
|
||||
{
|
||||
$operation->updateProgress('Creating backup before proceeding...');
|
||||
|
||||
// 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)',
|
||||
'Software Change: %s → %s (%s)',
|
||||
$currentEgg->name ?? 'Unknown',
|
||||
$targetEgg->name ?? 'Unknown',
|
||||
now()->format('M j, Y g:i A')
|
||||
now()->format('M j, 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);
|
||||
try {
|
||||
$result = $elytraJobService->submitJob(
|
||||
$this->server,
|
||||
'backup_create',
|
||||
[
|
||||
'operation' => 'create',
|
||||
'adapter' => config('backups.default', 'elytra'),
|
||||
'ignored' => '',
|
||||
'name' => $backupName,
|
||||
],
|
||||
$this->user
|
||||
);
|
||||
|
||||
Activity::actor($this->user)->event('server:backup.software-change')
|
||||
->property([
|
||||
'backup_name' => $backupName,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'operation_id' => $this->operationId,
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
Activity::actor($this->user)->event('server:backup.software-change')
|
||||
->property([
|
||||
'backup_name' => $backupName,
|
||||
'backup_job_id' => $result['job_id'],
|
||||
'operation_id' => $this->operationId,
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
|
||||
$operation->updateProgress('Waiting for backup to complete...');
|
||||
$this->waitForBackupCompletion($backup, $operation);
|
||||
$operation->updateProgress('Backup job submitted successfully');
|
||||
|
||||
$backup->refresh();
|
||||
if (!$backup->is_successful) {
|
||||
throw new BackupFailedException('Backup failed. Aborting software change to prevent data loss.');
|
||||
return $result['job_id'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
throw new BackupFailedException('Failed to create backup before egg change: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an Elytra job to complete.
|
||||
*/
|
||||
private function waitForJobCompletion(ElytraJobService $elytraJobService, string $jobId, ServerOperation $operation, int $timeoutMinutes = 30): void
|
||||
{
|
||||
$operation->updateProgress('Waiting for backup to complete before continuing...');
|
||||
|
||||
$startTime = Carbon::now();
|
||||
$timeout = $startTime->addMinutes($timeoutMinutes);
|
||||
$lastProgressUpdate = 0;
|
||||
|
||||
while (Carbon::now()->lt($timeout)) {
|
||||
$jobStatus = $elytraJobService->getJobStatus($this->server, $jobId);
|
||||
|
||||
if (!$jobStatus) {
|
||||
throw new BackupFailedException('Backup job not found');
|
||||
}
|
||||
|
||||
if ($jobStatus['status'] === 'completed') {
|
||||
$operation->updateProgress('Backup completed successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($jobStatus['status'], ['failed', 'cancelled'])) {
|
||||
throw new BackupFailedException('Backup failed: ' . ($jobStatus['error'] ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
$elapsed = Carbon::now()->diffInSeconds($startTime);
|
||||
if ($elapsed - $lastProgressUpdate >= 30) {
|
||||
$progress = $jobStatus['progress'] ?? 0;
|
||||
$operation->updateProgress("Backup in progress... {$progress}%");
|
||||
$lastProgressUpdate = $elapsed;
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
return $backup;
|
||||
throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe server files if requested.
|
||||
*/
|
||||
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation, ?Backup $backup): void
|
||||
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation): void
|
||||
{
|
||||
$operation->updateProgress('Wiping server files...');
|
||||
|
||||
@@ -189,9 +235,13 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
'from_egg' => $this->server->egg_id,
|
||||
'to_egg' => $this->eggId,
|
||||
'files_deleted' => count($filesToDelete),
|
||||
'backup_verified' => $backup ? true : false,
|
||||
'backup_created' => $this->shouldBackup,
|
||||
])
|
||||
->log();
|
||||
|
||||
$operation->updateProgress('Server files wiped successfully');
|
||||
} else {
|
||||
$operation->updateProgress('No files found to wipe');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to wipe files', [
|
||||
@@ -199,9 +249,16 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if (!$backup) {
|
||||
// If file wipe failed and we don't have a backup, this is dangerous
|
||||
if (!$this->shouldBackup) {
|
||||
throw new \RuntimeException('File wipe failed and no backup was created. Aborting operation to prevent data loss.');
|
||||
}
|
||||
|
||||
// If we have a backup, log the wipe failure but continue
|
||||
Log::warning('File wipe failed but backup was created, continuing with operation', [
|
||||
'server_id' => $this->server->id,
|
||||
'operation_id' => $this->operationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,70 +357,11 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle job failure.
|
||||
* Handle job failure when the Laravel queue system detects a failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
try {
|
||||
$operation = ServerOperation::where('operation_id', $this->operationId)->first();
|
||||
|
||||
Log::error('Egg change job failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
if ($operation) {
|
||||
$operation->markAsFailed('Job failed: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
Activity::actor($this->user)->event('server:software.change-job-failed')
|
||||
->property([
|
||||
'operation_id' => $this->operationId,
|
||||
'error' => $exception->getMessage(),
|
||||
'attempted_egg_id' => $this->eggId,
|
||||
])
|
||||
->log();
|
||||
} catch (\Throwable $e) {
|
||||
Log::critical('Failed to handle job failure properly', [
|
||||
'operation_id' => $this->operationId,
|
||||
'original_error' => $exception->getMessage(),
|
||||
'handler_error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for backup completion with timeout monitoring.
|
||||
*/
|
||||
private function waitForBackupCompletion(Backup $backup, ServerOperation $operation, int $timeoutMinutes = 30): void
|
||||
{
|
||||
$startTime = Carbon::now();
|
||||
$timeout = $startTime->addMinutes($timeoutMinutes);
|
||||
$lastProgressUpdate = 0;
|
||||
|
||||
while (Carbon::now()->lt($timeout)) {
|
||||
$backup->refresh();
|
||||
|
||||
if ($backup->is_successful && !is_null($backup->completed_at)) {
|
||||
$operation->updateProgress('Backup completed successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_null($backup->completed_at) && !$backup->is_successful) {
|
||||
throw new BackupFailedException('Backup failed during creation process.');
|
||||
}
|
||||
|
||||
$elapsed = Carbon::now()->diffInSeconds($startTime);
|
||||
if ($elapsed - $lastProgressUpdate >= 30) {
|
||||
$operation->updateProgress("Backup in progress...");
|
||||
$lastProgressUpdate = $elapsed;
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.');
|
||||
$this->handleJobFailure($exception, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
* @property string $uuid
|
||||
* @property bool $is_successful
|
||||
* @property bool $is_locked
|
||||
* @property bool $is_automatic
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property array|null $server_state
|
||||
@@ -54,6 +55,7 @@ class Backup extends Model
|
||||
'id' => 'int',
|
||||
'is_successful' => 'bool',
|
||||
'is_locked' => 'bool',
|
||||
'is_automatic' => 'bool',
|
||||
'ignored_files' => 'array',
|
||||
'server_state' => 'array',
|
||||
'bytes' => 'int',
|
||||
@@ -63,6 +65,7 @@ class Backup extends Model
|
||||
protected $attributes = [
|
||||
'is_successful' => false,
|
||||
'is_locked' => false,
|
||||
'is_automatic' => false,
|
||||
'checksum' => null,
|
||||
'bytes' => 0,
|
||||
'upload_id' => null,
|
||||
@@ -120,6 +123,7 @@ class Backup extends Model
|
||||
'uuid' => 'required|uuid',
|
||||
'is_successful' => 'boolean',
|
||||
'is_locked' => 'boolean',
|
||||
'is_automatic' => 'boolean',
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'server_state' => 'nullable|array',
|
||||
@@ -191,6 +195,14 @@ class Backup extends Model
|
||||
return $query->where('is_locked', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get automatic backups
|
||||
*/
|
||||
public function scopeAutomatic($query)
|
||||
{
|
||||
return $query->where('is_automatic', true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the route key for the model.
|
||||
|
||||
@@ -73,7 +73,6 @@ class Egg extends Model
|
||||
* than leaving it null.
|
||||
*/
|
||||
public const FEATURE_EULA_POPUP = 'eula';
|
||||
public const FEATURE_FASTDL = 'fastdl';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
|
||||
@@ -214,6 +214,9 @@ class Node extends Model
|
||||
],
|
||||
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
|
||||
'remote' => route('index'),
|
||||
'allowed_origins' => [
|
||||
config('app.url'), // note: I have no idea why this wasn't included by Pterodactyl upstream, this might need to be configurable later - ellie
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -251,7 +254,6 @@ class Node extends Model
|
||||
'endpoint' => $s3Config['endpoint'] ?? '',
|
||||
'region' => $s3Config['region'] ?? 'us-east-1',
|
||||
'bucket' => $s3Config['bucket'] ?? '',
|
||||
'key_prefix' => $s3Config['prefix'] ?? 'pterodactyl-backups/',
|
||||
'use_cold_storage' => $s3Config['use_cold_storage'] ?? false,
|
||||
'hot_bucket' => $s3Config['hot_bucket'] ?? '',
|
||||
'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER',
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
|
||||
use Pterodactyl\Models\ServerSubdomain;
|
||||
|
||||
/**
|
||||
* \Pterodactyl\Models\Server.
|
||||
|
||||
@@ -41,6 +41,7 @@ class DownloadLinkService
|
||||
'server_uuid' => $backup->server->uuid,
|
||||
'backup_disk' => $backup->disk,
|
||||
'repository_type' => $backup->getRepositoryType(),
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
])
|
||||
->handle($backup->server->node, $user->id . $backup->server->uuid);
|
||||
|
||||
|
||||
@@ -205,11 +205,17 @@ class ElytraJobService
|
||||
$server = $job->server;
|
||||
$currentStatus = $job->status;
|
||||
$newStatus = $statusData['status'] ?? 'unknown';
|
||||
|
||||
$errorMessage = $statusData['error_message'] ?? null;
|
||||
if ($errorMessage && str_starts_with($job->job_type, 'backup_')) {
|
||||
$errorMessage = 'Backup operation failed. Please contact an administrator for details.';
|
||||
}
|
||||
|
||||
$job->update([
|
||||
'status' => $newStatus,
|
||||
'progress' => $statusData['progress'] ?? $job->progress,
|
||||
'status_message' => $statusData['message'] ?? null,
|
||||
'error_message' => $statusData['error_message'] ?? null,
|
||||
'error_message' => $errorMessage,
|
||||
]);
|
||||
|
||||
if ($newStatus === 'completed' || $newStatus === 'failed') {
|
||||
|
||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Pterodactyl\Contracts\Elytra\Job;
|
||||
use Pterodactyl\Repositories\Elytra\ElytraRepository;
|
||||
use Pterodactyl\Services\Backups\ServerStateService;
|
||||
use Pterodactyl\Services\Backups\DownloadLinkService;
|
||||
use Pterodactyl\Extensions\Backups\BackupManager;
|
||||
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
|
||||
|
||||
class BackupJob implements Job
|
||||
@@ -19,11 +21,13 @@ class BackupJob implements Job
|
||||
public function __construct(
|
||||
private ServerStateService $serverStateService,
|
||||
private BackupTransformer $backupTransformer,
|
||||
private DownloadLinkService $downloadLinkService,
|
||||
private BackupManager $backupManager,
|
||||
) {}
|
||||
|
||||
public static function getSupportedJobTypes(): array
|
||||
{
|
||||
return ['backup_create', 'backup_delete', 'backup_restore', 'backup_download'];
|
||||
return ['backup_create', 'backup_delete', 'backup_restore', 'backup_download', 'backup_delete_all'];
|
||||
}
|
||||
|
||||
public function getRequiredPermissions(string $operation): array
|
||||
@@ -45,20 +49,26 @@ class BackupJob implements Job
|
||||
'adapter' => 'nullable|string',
|
||||
'ignored' => 'nullable|string',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'is_automatic' => 'nullable|boolean',
|
||||
],
|
||||
'delete' => [
|
||||
'operation' => 'required|string|in:delete',
|
||||
'backup_uuid' => 'required|string|uuid',
|
||||
'snapshot_id' => 'nullable|string',
|
||||
],
|
||||
'restore' => [
|
||||
'operation' => 'required|string|in:restore',
|
||||
'backup_uuid' => 'required|string|uuid',
|
||||
'snapshot_id' => 'nullable|string',
|
||||
'truncate_directory' => 'boolean',
|
||||
],
|
||||
'download' => [
|
||||
'operation' => 'required|string|in:download',
|
||||
'backup_uuid' => 'required|string|uuid',
|
||||
],
|
||||
'delete_all' => [
|
||||
'operation' => 'required|string|in:delete_all',
|
||||
],
|
||||
default => throw new \Exception('Invalid or missing operation'),
|
||||
};
|
||||
|
||||
@@ -81,6 +91,7 @@ class BackupJob implements Job
|
||||
'delete' => $this->submitDeleteJob($server, $job, $elytraRepository),
|
||||
'restore' => $this->submitRestoreJob($server, $job, $elytraRepository),
|
||||
'download' => $this->submitDownloadJob($server, $job, $elytraRepository),
|
||||
'delete_all' => $this->submitDeleteAllJob($server, $job, $elytraRepository),
|
||||
default => throw new \Exception("Unsupported backup operation: {$operation}"),
|
||||
};
|
||||
}
|
||||
@@ -100,11 +111,24 @@ class BackupJob implements Job
|
||||
$jobType = $statusData['job_type'] ?? '';
|
||||
$operation = $this->getOperationFromJobType($jobType);
|
||||
|
||||
Log::debug("processStatusUpdate called", [
|
||||
'job_id' => $job->id,
|
||||
'job_type' => $jobType,
|
||||
'operation' => $operation,
|
||||
'successful' => $successful,
|
||||
'has_repository_size' => isset($statusData['repository_size']),
|
||||
]);
|
||||
|
||||
$errorMessage = $successful ? null : ($statusData['error_message'] ?? 'Unknown error');
|
||||
if ($errorMessage) {
|
||||
$errorMessage = $this->sanitizeBackupError($errorMessage);
|
||||
}
|
||||
|
||||
$job->update([
|
||||
'status' => $successful ? ElytraJob::STATUS_COMPLETED : ElytraJob::STATUS_FAILED,
|
||||
'progress' => $successful ? 100 : $job->progress,
|
||||
'status_message' => $statusData['message'] ?? ($successful ? 'Completed successfully' : 'Failed'),
|
||||
'error_message' => $successful ? null : ($statusData['error_message'] ?? 'Unknown error'),
|
||||
'error_message' => $errorMessage,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
|
||||
@@ -113,6 +137,7 @@ class BackupJob implements Job
|
||||
'delete' => $this->handleDeleteCompletion($job, $statusData),
|
||||
'restore' => $this->handleRestoreCompletion($job, $statusData),
|
||||
'download' => $this->handleDownloadCompletion($job, $statusData),
|
||||
'delete_all' => $this->handleDeleteAllCompletion($job, $statusData),
|
||||
default => Log::warning("Unknown backup operation for status update: {$operation}"),
|
||||
};
|
||||
}
|
||||
@@ -185,6 +210,7 @@ class BackupJob implements Job
|
||||
$elytraJobData = [
|
||||
'server_id' => $server->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'adapter_type' => $backup->getElytraAdapterType(),
|
||||
];
|
||||
|
||||
@@ -198,12 +224,27 @@ class BackupJob implements Job
|
||||
$jobData = $job->job_data;
|
||||
$backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail();
|
||||
|
||||
$downloadUrl = $jobData['download_url'] ?? null;
|
||||
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3 && empty($downloadUrl)) {
|
||||
try {
|
||||
$downloadUrl = $this->generateS3DownloadUrl($backup);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate S3 download URL for backup restoration', [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw new \Exception('Failed to generate S3 download URL: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$elytraJobData = [
|
||||
'server_id' => $server->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'adapter_type' => $backup->getElytraAdapterType(),
|
||||
'truncate_directory' => $jobData['truncate_directory'] ?? false,
|
||||
'download_url' => $jobData['download_url'] ?? null,
|
||||
'download_url' => $downloadUrl,
|
||||
];
|
||||
|
||||
$response = $elytraRepository->setServer($server)->createJob('backup_restore', $elytraJobData);
|
||||
@@ -230,6 +271,7 @@ class BackupJob implements Job
|
||||
$server = $job->server;
|
||||
|
||||
$actualAdapter = $this->mapElytraAdapterToModel($statusData['adapter'] ?? 'rustic_local');
|
||||
$isRusticBackup = in_array($actualAdapter, [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3]);
|
||||
|
||||
$backupData = [
|
||||
'server_id' => $server->id,
|
||||
@@ -239,6 +281,7 @@ class BackupJob implements Job
|
||||
'disk' => $actualAdapter,
|
||||
'is_successful' => true,
|
||||
'is_locked' => false,
|
||||
'is_automatic' => $jobData['is_automatic'] ?? false,
|
||||
'checksum' => ($statusData['checksum_type'] ?? 'sha1') . ':' . ($statusData['checksum'] ?? ''),
|
||||
'bytes' => $statusData['size'] ?? 0,
|
||||
'snapshot_id' => $statusData['snapshot_id'] ?? null,
|
||||
@@ -261,12 +304,27 @@ class BackupJob implements Job
|
||||
|
||||
$backup = Backup::create($backupData);
|
||||
|
||||
Log::info("Backup record created successfully", [
|
||||
'backup_id' => $backup->id,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'disk' => $backup->disk,
|
||||
'size_mb' => round($backup->bytes / 1024 / 1024, 2),
|
||||
]);
|
||||
if ($isRusticBackup && isset($statusData['repository_size'])) {
|
||||
$server->update(['repository_backup_bytes' => $statusData['repository_size']]);
|
||||
|
||||
Log::info("Backup record created successfully (rustic)", [
|
||||
'backup_id' => $backup->id,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'disk' => $backup->disk,
|
||||
'repository_size_mb' => round($statusData['repository_size'] / 1024 / 1024, 2),
|
||||
]);
|
||||
} else {
|
||||
Log::info("Backup record created successfully", [
|
||||
'backup_id' => $backup->id,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'disk' => $backup->disk,
|
||||
'size_mb' => round($backup->bytes / 1024 / 1024, 2),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($backup->is_automatic) {
|
||||
$this->pruneOldAutomaticBackups($server);
|
||||
}
|
||||
} else {
|
||||
Log::error("Backup job failed", [
|
||||
'backup_uuid' => $backupUuid,
|
||||
@@ -278,12 +336,38 @@ class BackupJob implements Job
|
||||
|
||||
private function handleDeleteCompletion(ElytraJob $job, array $statusData): void
|
||||
{
|
||||
Log::debug("handleDeleteCompletion called", [
|
||||
'job_id' => $job->id,
|
||||
'statusData' => $statusData,
|
||||
]);
|
||||
|
||||
if ($statusData['successful']) {
|
||||
$jobData = $job->job_data;
|
||||
$backup = Backup::where('uuid', $jobData['backup_uuid'])->first();
|
||||
|
||||
if ($backup) {
|
||||
$server = $backup->server;
|
||||
$isRusticBackup = in_array($backup->disk, [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3]);
|
||||
|
||||
Log::debug("Backup found for deletion", [
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'disk' => $backup->disk,
|
||||
'is_rustic' => $isRusticBackup,
|
||||
'has_repository_size' => isset($statusData['repository_size']),
|
||||
]);
|
||||
|
||||
$backup->delete();
|
||||
|
||||
// If this was a rustic backup and we got the updated repository size, update the server
|
||||
if ($isRusticBackup && isset($statusData['repository_size'])) {
|
||||
$server->update(['repository_backup_bytes' => $statusData['repository_size']]);
|
||||
|
||||
Log::info("Updated repository size after backup deletion", [
|
||||
'server_uuid' => $server->uuid,
|
||||
'repository_size_mb' => round($statusData['repository_size'] / 1024 / 1024, 2),
|
||||
'adapter_type' => $backup->disk,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +380,43 @@ class BackupJob implements Job
|
||||
{
|
||||
}
|
||||
|
||||
private function submitDeleteAllJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
|
||||
{
|
||||
// Get all backups for this server with necessary information
|
||||
$backups = $server->backups()->get(['uuid', 'disk', 'snapshot_id', 'checksum'])->map(function ($backup) {
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'adapter' => $backup->disk,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'checksum' => $backup->checksum,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$elytraJobData = [
|
||||
'server_id' => $server->uuid,
|
||||
'backups' => $backups,
|
||||
];
|
||||
|
||||
$response = $elytraRepository->setServer($server)->createJob('backup_delete_all', $elytraJobData);
|
||||
|
||||
return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra');
|
||||
}
|
||||
|
||||
private function handleDeleteAllCompletion(ElytraJob $job, array $statusData): void
|
||||
{
|
||||
if ($statusData['successful']) {
|
||||
$server = $job->server;
|
||||
|
||||
$deletedCount = $server->backups()->delete();
|
||||
$server->update(['repository_backup_bytes' => 0]);
|
||||
|
||||
Log::info("All backups deleted successfully", [
|
||||
'server_uuid' => $server->uuid,
|
||||
'deleted_count' => $deletedCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getOperationFromJobType(string $jobType): string
|
||||
{
|
||||
return match ($jobType) {
|
||||
@@ -303,6 +424,7 @@ class BackupJob implements Job
|
||||
'backup_delete' => 'delete',
|
||||
'backup_restore' => 'restore',
|
||||
'backup_download' => 'download',
|
||||
'backup_delete_all' => 'delete_all',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
@@ -342,4 +464,116 @@ class BackupJob implements Job
|
||||
|
||||
return array_values($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned S3 download URL for backup restoration
|
||||
*/
|
||||
private function generateS3DownloadUrl(Backup $backup): string
|
||||
{
|
||||
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
|
||||
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
|
||||
|
||||
$request = $adapter->getClient()->createPresignedRequest(
|
||||
$adapter->getClient()->getCommand('GetObject', [
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
|
||||
'ContentType' => 'application/x-gzip',
|
||||
]),
|
||||
CarbonImmutable::now()->addMinutes(15) // Longer timeout for restoration downloads
|
||||
);
|
||||
|
||||
return $request->getUri()->__toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old automatic backups for a server if the count exceeds the configured limit.
|
||||
* Only unlocked automatic backups count toward the limit. Locked backups are preserved indefinitely.
|
||||
* This ensures users don't accumulate hundreds of automatic backups without manual intervention.
|
||||
*/
|
||||
private function pruneOldAutomaticBackups(Server $server): void
|
||||
{
|
||||
$limit = config('backups.automatic_backup_limit', 32); // todo: make this configurable in the panel (maybe?) - ellie
|
||||
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unlockedAutomaticBackupCount = $server->backups()
|
||||
->where('is_automatic', true)
|
||||
->where('is_successful', true)
|
||||
->where('is_locked', false)
|
||||
->count();
|
||||
|
||||
if ($unlockedAutomaticBackupCount <= $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
$excessCount = $unlockedAutomaticBackupCount - $limit;
|
||||
|
||||
$oldBackups = $server->backups()
|
||||
->where('is_automatic', true)
|
||||
->where('is_successful', true)
|
||||
->where('is_locked', false)
|
||||
->orderBy('created_at', 'asc')
|
||||
->limit($excessCount)
|
||||
->get();
|
||||
|
||||
if ($oldBackups->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$elytraRepository = app(\Pterodactyl\Repositories\Elytra\ElytraRepository::class);
|
||||
$deletedCount = 0;
|
||||
|
||||
foreach ($oldBackups as $backup) {
|
||||
try {
|
||||
$elytraRepository->setServer($server)->createJob('backup_delete', [
|
||||
'server_id' => $server->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'adapter_type' => $backup->getElytraAdapterType(),
|
||||
]);
|
||||
|
||||
$deletedCount++;
|
||||
|
||||
Log::info("Queued automatic backup for deletion due to limit", [
|
||||
'server_id' => $server->id,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'backup_name' => $backup->name,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to queue automatic backup deletion", [
|
||||
'server_id' => $server->id,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$lockedCount = $server->backups()
|
||||
->where('is_automatic', true)
|
||||
->where('is_successful', true)
|
||||
->where('is_locked', true)
|
||||
->count();
|
||||
|
||||
Log::info("Automatic backup pruning completed", [
|
||||
'server_id' => $server->id,
|
||||
'unlocked_automatic_backup_count' => $unlockedAutomaticBackupCount,
|
||||
'locked_automatic_backup_count' => $lockedCount,
|
||||
'limit' => $limit,
|
||||
'queued_deletions' => $deletedCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize backup error messages to prevent leaking sensitive information.
|
||||
* Never expose raw errors from backup systems as they may contain credentials or paths.
|
||||
*
|
||||
* @param string $errorMessage The raw error message from the backup system
|
||||
* @return string Generic error message safe for frontend display
|
||||
*/
|
||||
private function sanitizeBackupError(string $errorMessage): string
|
||||
{
|
||||
return 'Backup operation failed. Please contact an administrator for details.'; // todo: better sanitization - elllie
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class BackupTransformer extends BaseClientTransformer
|
||||
'uuid' => $backup->uuid,
|
||||
'is_successful' => $backup->is_successful,
|
||||
'is_locked' => $backup->is_locked,
|
||||
'is_automatic' => $backup->is_automatic,
|
||||
'name' => $backup->name,
|
||||
'ignored_files' => $backup->ignored_files,
|
||||
'checksum' => $backup->checksum,
|
||||
|
||||
@@ -22,6 +22,12 @@ return [
|
||||
// to 6 hours. To disable this feature, set the value to `0`.
|
||||
'prune_age' => env('BACKUP_PRUNE_AGE', 360),
|
||||
|
||||
// The maximum number of unlocked automatic backups to keep per server. When this limit is
|
||||
// exceeded, the oldest unlocked automatic backups will be automatically deleted. Locked
|
||||
// automatic backups do not count toward this limit and are preserved indefinitely.
|
||||
// Set to 0 to disable automatic pruning. Defaults to 32.
|
||||
'automatic_backup_limit' => env('BACKUP_AUTOMATIC_LIMIT', 32),
|
||||
|
||||
'disks' => [
|
||||
// There is no configuration for the local disk for Wings. That configuration
|
||||
// is determined by the Daemon configuration, and not the Panel.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('repository_backup_bytes')->default(0)->after('backup_storage_limit');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('repository_backup_bytes');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->boolean('is_automatic')->default(false)->after('is_locked')->comment('Whether this backup was created automatically by a schedule');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->dropColumn('is_automatic');
|
||||
});
|
||||
}
|
||||
};
|
||||
1
resources/scripts/api/server/types.d.ts
vendored
1
resources/scripts/api/server/types.d.ts
vendored
@@ -10,6 +10,7 @@ export interface ServerBackup {
|
||||
uuid: string;
|
||||
isSuccessful: boolean;
|
||||
isLocked: boolean;
|
||||
isAutomatic: boolean;
|
||||
name: string;
|
||||
ignoredFiles: string;
|
||||
checksum: string;
|
||||
|
||||
@@ -19,6 +19,12 @@ type BackupResponse = PaginatedResult<ServerBackup> & {
|
||||
backupCount: number;
|
||||
storage: {
|
||||
used_mb: number;
|
||||
legacy_usage_mb: number;
|
||||
repository_usage_mb: number;
|
||||
rustic_backup_sum_mb: number;
|
||||
overhead_mb: number;
|
||||
overhead_percent: number;
|
||||
needs_pruning: boolean;
|
||||
limit_mb: number | null;
|
||||
has_limit: boolean;
|
||||
usage_percentage: number | null;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
|
||||
uuid: attributes.uuid,
|
||||
isSuccessful: attributes.is_successful,
|
||||
isLocked: attributes.is_locked,
|
||||
isAutomatic: attributes.is_automatic || false,
|
||||
name: attributes.name,
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
checksum: attributes.checksum,
|
||||
|
||||
@@ -17,7 +17,7 @@ const Checkbox = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex h-full w-full items-center justify-center text-current')}>
|
||||
<HugeIconsCheck fill='currentColor' className='h-4 w-4' />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MainPageHeader: React.FC<MainPageHeaderProps> = ({
|
||||
direction = 'row',
|
||||
}) => {
|
||||
return (
|
||||
<HeaderWrapper className={clsx('flex flex-col', 'mb-4 gap-4 md:gap-8 mt-8 md:mt-0 select-none')}>
|
||||
<HeaderWrapper className={clsx('flex flex-col', 'mb-8 gap-4 md:gap-8 mt-8 md:mt-0 select-none')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
|
||||
@@ -8,7 +8,7 @@ export default ({ children }: { children: React.ReactNode }) => {
|
||||
const { setFooter } = useContext(DialogContext);
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
setFooter(<div className={'px-4 py-4 flex items-center justify-end gap-4 rounded-b'}>{children}</div>);
|
||||
setFooter(<div className={'px-6 py-4 pb-6 flex items-center justify-end gap-4 rounded-b'}>{children}</div>);
|
||||
}, [children]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -11,9 +11,9 @@ const PageListContainer = ({ className, children }: Props) => {
|
||||
style={{
|
||||
background: 'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(16, 16, 16) 0%, rgb(4, 4, 4) 100%)',
|
||||
}}
|
||||
className={clsx(className, 'p-1 border-[1px] border-[#ffffff12] rounded-xl')}
|
||||
className={clsx(className, 'p-2 border-[1px] border-[#ffffff12] rounded-xl')}
|
||||
>
|
||||
<div className='flex h-full w-full flex-col gap-1 overflow-hidden rounded-lg'>{children}</div>
|
||||
<div className='flex h-full w-full flex-col gap-3 overflow-hidden rounded-lg'>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const PageListItem = ({ className, children }: Props) => {
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all flex items-center gap-4 flex-col sm:flex-row',
|
||||
'bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] px-5 py-4 rounded-xl hover:border-[#ffffff20] transition-all flex items-center gap-3 flex-col sm:flex-row',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Form, Formik, Field as FormikField, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||
import { boolean, object, string } from 'yup';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { Checkbox } from '@/components/elements/CheckboxNew';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
|
||||
@@ -17,13 +20,30 @@ import Spinner from '@/components/elements/Spinner';
|
||||
import { PageListContainer } from '@/components/elements/pages/PageList';
|
||||
|
||||
import { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { SocketEvent } from '@/components/server/events';
|
||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { useUnifiedBackups } from './useUnifiedBackups';
|
||||
import BackupItem from './BackupItem';
|
||||
|
||||
// Context to share live backup progress across components
|
||||
export const LiveProgressContext = createContext<Record<string, {
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
canRetry: boolean;
|
||||
lastUpdated: string;
|
||||
completed: boolean;
|
||||
isDeletion: boolean;
|
||||
backupName?: string;
|
||||
}>>({});
|
||||
|
||||
// Helper function to format storage values
|
||||
const formatStorage = (mb: number | undefined | null): string => {
|
||||
if (mb === null || mb === undefined) {
|
||||
@@ -97,16 +117,33 @@ const BackupContainer = () => {
|
||||
const { page, setPage } = useContext(ServerBackupContext);
|
||||
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [deleteAllModalVisible, setDeleteAllModalVisible] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteAllPassword, setDeleteAllPassword] = useState('');
|
||||
const [deleteAllTotpCode, setDeleteAllTotpCode] = useState('');
|
||||
|
||||
// Bulk operations state
|
||||
const [selectedBackups, setSelectedBackups] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleteModalVisible, setBulkDeleteModalVisible] = useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [bulkDeletePassword, setBulkDeletePassword] = useState('');
|
||||
const [bulkDeleteTotpCode, setBulkDeleteTotpCode] = useState('');
|
||||
|
||||
const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
|
||||
|
||||
const {
|
||||
backups,
|
||||
backupCount,
|
||||
storage,
|
||||
pagination,
|
||||
error,
|
||||
isValidating,
|
||||
createBackup
|
||||
createBackup,
|
||||
retryBackup,
|
||||
refresh
|
||||
} = useUnifiedBackups();
|
||||
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
|
||||
|
||||
@@ -132,6 +169,122 @@ const BackupContainer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
if (!deleteAllPassword) {
|
||||
toast.error('Password is required to delete all backups.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactor && !deleteAllTotpCode) {
|
||||
toast.error('Two-factor authentication code is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const http = (await import('@/api/http')).default;
|
||||
await http.delete(`/api/client/servers/${uuid}/backups/delete-all`, {
|
||||
data: {
|
||||
password: deleteAllPassword,
|
||||
...(hasTwoFactor ? { totp_code: deleteAllTotpCode } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('All backups and repositories are being deleted. This may take a few minutes.');
|
||||
|
||||
setDeleteAllModalVisible(false);
|
||||
setDeleteAllPassword('');
|
||||
setDeleteAllTotpCode('');
|
||||
|
||||
// Websocket events will handle the UI updates automatically
|
||||
} catch (error) {
|
||||
toast.error(httpErrorToHuman(error));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk selection handlers
|
||||
const toggleBackupSelection = (backupUuid: string) => {
|
||||
setSelectedBackups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(backupUuid)) {
|
||||
newSet.delete(backupUuid);
|
||||
} else {
|
||||
newSet.add(backupUuid);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedBackups.size === selectableBackups.length) {
|
||||
setSelectedBackups(new Set());
|
||||
} else {
|
||||
setSelectedBackups(new Set(selectableBackups.map((b) => b.uuid)));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedBackups(new Set());
|
||||
};
|
||||
|
||||
// Get backups that can be selected (completed and not active)
|
||||
const selectableBackups = backups.filter(
|
||||
(b) => b.status === 'completed' && b.isSuccessful && !b.isLiveOnly
|
||||
);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!bulkDeletePassword) {
|
||||
addFlash({
|
||||
key: 'backups:bulk_delete',
|
||||
type: 'error',
|
||||
message: 'Password is required to delete backups.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactor && !bulkDeleteTotpCode) {
|
||||
addFlash({
|
||||
key: 'backups:bulk_delete',
|
||||
type: 'error',
|
||||
message: 'Two-factor authentication code is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBulkDeleting(true);
|
||||
clearFlashes('backups:bulk_delete');
|
||||
|
||||
try {
|
||||
const http = (await import('@/api/http')).default;
|
||||
await http.post(`/api/client/servers/${uuid}/backups/bulk-delete`, {
|
||||
backup_uuids: Array.from(selectedBackups),
|
||||
password: bulkDeletePassword,
|
||||
...(hasTwoFactor ? { totp_code: bulkDeleteTotpCode } : {}),
|
||||
});
|
||||
|
||||
addFlash({
|
||||
key: 'backups',
|
||||
type: 'success',
|
||||
message: `${selectedBackups.size} backup${selectedBackups.size > 1 ? 's are' : ' is'} being deleted.`,
|
||||
});
|
||||
|
||||
setBulkDeleteModalVisible(false);
|
||||
setBulkDeletePassword('');
|
||||
setBulkDeleteTotpCode('');
|
||||
clearSelection();
|
||||
|
||||
// Refresh the backup list to reflect the deletions
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
clearAndAddHttpError({ key: 'backups:bulk_delete', error });
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
clearFlashes('backups');
|
||||
@@ -189,35 +342,61 @@ const BackupContainer = () => {
|
||||
{storage && (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{backupStorageLimit === null ? (
|
||||
<p
|
||||
className='text-sm text-zinc-300 cursor-help'
|
||||
title={`${storage.used_mb?.toFixed(2) || 0}MB used(No Limit)`}
|
||||
>
|
||||
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> storage used
|
||||
</p>
|
||||
<>
|
||||
<p
|
||||
className='text-sm text-zinc-300 cursor-help'
|
||||
title={`${storage.used_mb?.toFixed(2) || 0}MB total (Repository: ${storage.repository_usage_mb?.toFixed(2) || 0}MB, Legacy: ${storage.legacy_usage_mb?.toFixed(2) || 0}MB)`}
|
||||
>
|
||||
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> storage used
|
||||
</p>
|
||||
{(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && (
|
||||
<p className='text-xs text-zinc-400'>
|
||||
{storage.repository_usage_mb > 0 && `${formatStorage(storage.repository_usage_mb)} deduplicated`}
|
||||
{storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0 && ' + '}
|
||||
{storage.legacy_usage_mb > 0 && `${formatStorage(storage.legacy_usage_mb)} legacy`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className='text-sm text-zinc-300 cursor-help'
|
||||
title={`${storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (${storage.available_mb?.toFixed(2) || 0}MB Available)`}
|
||||
title={`${storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (Repository: ${storage.repository_usage_mb?.toFixed(2) || 0}MB, Legacy: ${storage.legacy_usage_mb?.toFixed(2) || 0}MB, ${storage.available_mb?.toFixed(2) || 0}MB Available)`}
|
||||
>
|
||||
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> {' '}
|
||||
{backupStorageLimit === null ?
|
||||
"used" :
|
||||
(<span className='font-medium'>of {formatStorage(backupStorageLimit)} used</span>)}
|
||||
</p>
|
||||
|
||||
{(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && (
|
||||
<p className='text-xs text-zinc-400'>
|
||||
{storage.repository_usage_mb > 0 && `${formatStorage(storage.repository_usage_mb)} deduplicated`}
|
||||
{storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0 && ' + '}
|
||||
{storage.legacy_usage_mb > 0 && `${formatStorage(storage.legacy_usage_mb)} legacy`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(backupLimit === null || backupLimit > backupCount) &&
|
||||
(!backupStorageLimit || !storage?.is_over_limit) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Backup
|
||||
<div className='flex gap-2'>
|
||||
{backupCount > 0 && (
|
||||
<ActionButton variant='danger' onClick={() => setDeleteAllModalVisible(true)}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete All Backups
|
||||
</ActionButton>
|
||||
)}
|
||||
{(backupLimit === null || backupLimit > backupCount) &&
|
||||
(!backupStorageLimit || !storage?.is_over_limit) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Can>
|
||||
}
|
||||
@@ -242,6 +421,200 @@ const BackupContainer = () => {
|
||||
</Formik>
|
||||
)}
|
||||
|
||||
{deleteAllModalVisible && (
|
||||
<Modal
|
||||
visible={deleteAllModalVisible}
|
||||
onDismissed={() => {
|
||||
setDeleteAllModalVisible(false);
|
||||
setDeleteAllPassword('');
|
||||
setDeleteAllTotpCode('');
|
||||
}}
|
||||
title='Delete All Backups'
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-300">
|
||||
You are about to permanently delete{' '}
|
||||
<span className="font-medium text-red-400">
|
||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
||||
</span>
|
||||
{' '}and completely destroy the backup repository for this server.
|
||||
</p>
|
||||
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-300">This action cannot be undone</p>
|
||||
<ul className="text-red-400 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>All backup data will be permanently deleted</li>
|
||||
<li>Locked backups will also be deleted</li>
|
||||
<li>The entire backup repository will be destroyed</li>
|
||||
<li>This operation may take several minutes to complete</li>
|
||||
<li>You will not be able to restore any of these backups</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="Enter your password"
|
||||
value={deleteAllPassword}
|
||||
onChange={(e) => setDeleteAllPassword(e.target.value)}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="totp_code" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Two-Factor Authentication Code
|
||||
</label>
|
||||
<input
|
||||
id="totp_code"
|
||||
type="text"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
value={deleteAllTotpCode}
|
||||
onChange={(e) => setDeleteAllTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pb-6 pt-2">
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={() => {
|
||||
setDeleteAllModalVisible(false);
|
||||
setDeleteAllPassword('');
|
||||
setDeleteAllTotpCode('');
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={handleDeleteAll}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting && <Spinner size='small' />}
|
||||
{isDeleting ? 'Deleting...' : 'Delete All Backups'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Bulk delete modal */}
|
||||
{bulkDeleteModalVisible && (
|
||||
<Modal
|
||||
visible={bulkDeleteModalVisible}
|
||||
onDismissed={() => {
|
||||
setBulkDeleteModalVisible(false);
|
||||
setBulkDeletePassword('');
|
||||
setBulkDeleteTotpCode('');
|
||||
}}
|
||||
title='Delete Selected Backups'
|
||||
>
|
||||
<FlashMessageRender byKey={'backups:bulk_delete'} />
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-300">
|
||||
You are about to permanently delete{' '}
|
||||
<span className="font-medium text-red-400">
|
||||
{selectedBackups.size} backup{selectedBackups.size > 1 ? 's' : ''}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-300">Warning</p>
|
||||
<p className="text-red-400 mt-1">
|
||||
The selected backup files and their snapshots will be permanently deleted. You will not be able to restore them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="bulk-password" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="bulk-password"
|
||||
type="password"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="Enter your password"
|
||||
value={bulkDeletePassword}
|
||||
onChange={(e) => setBulkDeletePassword(e.target.value)}
|
||||
disabled={isBulkDeleting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="bulk-totp" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Two-Factor Authentication Code
|
||||
</label>
|
||||
<input
|
||||
id="bulk-totp"
|
||||
type="text"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
value={bulkDeleteTotpCode}
|
||||
onChange={(e) => setBulkDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
disabled={isBulkDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pb-6 pt-2">
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={() => {
|
||||
setBulkDeleteModalVisible(false);
|
||||
setBulkDeletePassword('');
|
||||
setBulkDeleteTotpCode('');
|
||||
}}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={handleBulkDelete}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
{isBulkDeleting && <Spinner size='small' />}
|
||||
{isBulkDeleting ? 'Deleting...' : `Delete ${selectedBackups.size} Backup${selectedBackups.size > 1 ? 's' : ''}`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
|
||||
<div className='text-center'>
|
||||
@@ -265,11 +638,64 @@ const BackupContainer = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PageListContainer>
|
||||
{backups.map((backup) => (
|
||||
<BackupItem key={backup.uuid} backup={backup} />
|
||||
))}
|
||||
</PageListContainer>
|
||||
<>
|
||||
{/* Bulk action bar */}
|
||||
{selectableBackups.length > 0 && (
|
||||
<div className='mb-8 flex items-center justify-between px-4 py-3.5 rounded-xl bg-[#ffffff08] border border-zinc-700'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Checkbox
|
||||
checked={selectedBackups.size === selectableBackups.length && selectableBackups.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<span className='text-sm text-zinc-300'>
|
||||
{selectedBackups.size > 0 ? (
|
||||
<>
|
||||
<span className='font-medium'>{selectedBackups.size}</span> selected
|
||||
</>
|
||||
) : (
|
||||
'Select backups'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 transition-opacity ${selectedBackups.size > 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ActionButton>
|
||||
<Can action='backup.delete'>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={() => setBulkDeleteModalVisible(true)}
|
||||
>
|
||||
Delete Selected ({selectedBackups.size})
|
||||
</ActionButton>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageListContainer>
|
||||
{backups.map((backup) => (
|
||||
<BackupItem
|
||||
key={backup.uuid}
|
||||
backup={backup}
|
||||
isSelected={selectedBackups.has(backup.uuid)}
|
||||
onToggleSelect={() => toggleBackupSelection(backup.uuid)}
|
||||
isSelectable={selectableBackups.some((b) => b.uuid === backup.uuid)}
|
||||
retryBackup={retryBackup}
|
||||
/>
|
||||
))}
|
||||
</PageListContainer>
|
||||
|
||||
{pagination && pagination.currentPage && pagination.totalPages && pagination.totalPages > 1 && (
|
||||
<Pagination data={{ items: backups, pagination }} onPageSelect={setPage}>
|
||||
{() => null}
|
||||
</Pagination>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
@@ -277,10 +703,144 @@ const BackupContainer = () => {
|
||||
|
||||
const BackupContainerWrapper = () => {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const { mutate } = getServerBackups();
|
||||
const [liveProgress, setLiveProgress] = useState<Record<string, {
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
canRetry: boolean;
|
||||
lastUpdated: string;
|
||||
completed: boolean;
|
||||
isDeletion: boolean;
|
||||
backupName?: string;
|
||||
}>>({});
|
||||
|
||||
// Single websocket listener for the entire page
|
||||
const handleBackupStatus = useCallback((rawData: any) => {
|
||||
let data;
|
||||
try {
|
||||
if (typeof rawData === 'string') {
|
||||
data = JSON.parse(rawData);
|
||||
} else {
|
||||
data = rawData;
|
||||
}
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backup_uuid = data?.backup_uuid;
|
||||
if (!backup_uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
status,
|
||||
progress,
|
||||
message,
|
||||
timestamp,
|
||||
operation,
|
||||
error: errorMsg,
|
||||
name,
|
||||
} = data;
|
||||
|
||||
const can_retry = status === 'failed' && operation === 'create';
|
||||
const last_updated_at = timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
|
||||
const isDeletionOperation = operation === 'delete' || data.deleted === true;
|
||||
|
||||
setLiveProgress(prevProgress => {
|
||||
const currentState = prevProgress[backup_uuid];
|
||||
const newProgress = progress || 0;
|
||||
const isCompleted = status === 'completed' && newProgress === 100;
|
||||
const displayMessage = errorMsg ? `${message || 'Operation failed'}: ${errorMsg}` : (message || '');
|
||||
|
||||
if (currentState?.completed && !isCompleted) {
|
||||
return prevProgress;
|
||||
}
|
||||
|
||||
if (currentState && !isCompleted && currentState.lastUpdated >= last_updated_at && currentState.progress >= newProgress) {
|
||||
return prevProgress;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevProgress,
|
||||
[backup_uuid]: {
|
||||
status,
|
||||
progress: newProgress,
|
||||
message: displayMessage,
|
||||
canRetry: can_retry || false,
|
||||
lastUpdated: last_updated_at,
|
||||
completed: isCompleted,
|
||||
isDeletion: isDeletionOperation,
|
||||
backupName: name || currentState?.backupName,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (status === 'completed' && progress === 100) {
|
||||
if (isDeletionOperation) {
|
||||
// Optimistically remove the deleted backup from SWR cache immediately
|
||||
// note: this is incredibly buggy sometimes, somebody please refactor how "live" backups work. - ellie
|
||||
mutate(
|
||||
(currentData) => {
|
||||
if (!currentData) return currentData;
|
||||
return {
|
||||
...currentData,
|
||||
items: currentData.items.filter(b => b.uuid !== backup_uuid),
|
||||
backupCount: Math.max(0, (currentData.backupCount || 0) - 1),
|
||||
};
|
||||
},
|
||||
{ revalidate: true }
|
||||
);
|
||||
|
||||
// Remove from live progress
|
||||
setTimeout(() => {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
// For new backups, wait for them to appear in the API
|
||||
mutate();
|
||||
const checkForBackup = async (attempts = 0) => {
|
||||
if (attempts > 10) {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Force fresh data
|
||||
const currentBackups = await mutate();
|
||||
const backupExists = currentBackups?.items?.some(b => b.uuid === backup_uuid);
|
||||
|
||||
if (backupExists) {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => checkForBackup(attempts + 1), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => checkForBackup(), 1000);
|
||||
}
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.BACKUP_STATUS, handleBackupStatus);
|
||||
|
||||
return (
|
||||
<ServerBackupContext.Provider value={{ page, setPage }}>
|
||||
<BackupContainer />
|
||||
</ServerBackupContext.Provider>
|
||||
<LiveProgressContext.Provider value={liveProgress}>
|
||||
<ServerBackupContext.Provider value={{ page, setPage }}>
|
||||
<BackupContainer />
|
||||
</ServerBackupContext.Provider>
|
||||
</LiveProgressContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/elements/DropdownMenu';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import HugeIconsAlert from '@/components/elements/hugeicons/Alert';
|
||||
import HugeIconsCloudUp from '@/components/elements/hugeicons/CloudUp';
|
||||
@@ -18,6 +20,7 @@ import HugeIconsFileDownload from '@/components/elements/hugeicons/FileDownload'
|
||||
import HugeIconsFileSecurity from '@/components/elements/hugeicons/FileSecurity';
|
||||
import HugeIconsPencil from '@/components/elements/hugeicons/Pencil';
|
||||
import HugeIconsHamburger from '@/components/elements/hugeicons/hamburger';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
|
||||
import http, { httpErrorToHuman } from '@/api/http';
|
||||
import {
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from '@/api/server/backups';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
@@ -41,8 +45,13 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
const [newName, setNewName] = useState(backup.name);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteTotpCode, setDeleteTotpCode] = useState('');
|
||||
const [restorePassword, setRestorePassword] = useState('');
|
||||
const [restoreTotpCode, setRestoreTotpCode] = useState('');
|
||||
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
|
||||
const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups();
|
||||
const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
|
||||
|
||||
const doDownload = () => {
|
||||
setLoading(true);
|
||||
@@ -59,25 +68,75 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
};
|
||||
|
||||
const doDeletion = async () => {
|
||||
if (!deletePassword) {
|
||||
addFlash({
|
||||
key: 'backup:delete',
|
||||
type: 'error',
|
||||
message: 'Password is required to delete this backup.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactor && !deleteTotpCode) {
|
||||
addFlash({
|
||||
key: 'backup:delete',
|
||||
type: 'error',
|
||||
message: 'Two-factor authentication code is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
clearFlashes('backup:delete');
|
||||
|
||||
try {
|
||||
await deleteBackup(backup.uuid);
|
||||
await http.delete(`/api/client/servers/${uuid}/backups/${backup.uuid}`, {
|
||||
data: {
|
||||
password: deletePassword,
|
||||
...(hasTwoFactor ? { totp_code: deleteTotpCode } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
setModal('');
|
||||
setDeletePassword('');
|
||||
setDeleteTotpCode('');
|
||||
|
||||
// Refresh the backup list to reflect the deletion
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
clearAndAddHttpError({ key: 'backup:delete', error });
|
||||
setLoading(false);
|
||||
setModal('');
|
||||
}
|
||||
};
|
||||
|
||||
const doRestorationAction = async () => {
|
||||
if (!restorePassword) {
|
||||
addFlash({
|
||||
key: 'backup:restore',
|
||||
type: 'error',
|
||||
message: 'Password is required to restore this backup.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactor && !restoreTotpCode) {
|
||||
addFlash({
|
||||
key: 'backup:restore',
|
||||
type: 'error',
|
||||
message: 'Two-factor authentication code is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
clearFlashes('backup:restore');
|
||||
|
||||
try {
|
||||
await restoreBackup(backup.uuid);
|
||||
await http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/restore`, {
|
||||
password: restorePassword,
|
||||
...(hasTwoFactor ? { totp_code: restoreTotpCode } : {}),
|
||||
});
|
||||
|
||||
// Set server status to restoring
|
||||
setServerFromState((s) => ({
|
||||
@@ -87,10 +146,11 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
|
||||
setLoading(false);
|
||||
setModal('');
|
||||
setRestorePassword('');
|
||||
setRestoreTotpCode('');
|
||||
} catch (error) {
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
clearAndAddHttpError({ key: 'backup:restore', error });
|
||||
setLoading(false);
|
||||
setModal('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,7 +244,12 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
>
|
||||
This backup will no longer be protected from automated or accidental deletions.
|
||||
</Dialog.Confirm>
|
||||
<Dialog open={modal === 'restore'} onClose={() => setModal('')} title='Restore Backup'>
|
||||
<Dialog open={modal === 'restore'} onClose={() => {
|
||||
setModal('');
|
||||
setRestorePassword('');
|
||||
setRestoreTotpCode('');
|
||||
}} title='Restore Backup'>
|
||||
<FlashMessageRender byKey={'backup:restore'} />
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium text-zinc-200'>"{backup.name}"</p>
|
||||
@@ -208,26 +273,141 @@ const BackupContextMenu = ({ backup }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="restore-password" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="restore-password"
|
||||
type="password"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="Enter your password"
|
||||
value={restorePassword}
|
||||
onChange={(e) => setRestorePassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="restore-totp" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Two-Factor Authentication Code
|
||||
</label>
|
||||
<input
|
||||
id="restore-totp"
|
||||
type="text"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
value={restoreTotpCode}
|
||||
onChange={(e) => setRestoreTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<ActionButton onClick={() => setModal('')} variant='secondary'>
|
||||
<ActionButton onClick={() => {
|
||||
setModal('');
|
||||
setRestorePassword('');
|
||||
setRestoreTotpCode('');
|
||||
}} variant='secondary' disabled={loading}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => doRestorationAction()} variant='danger' disabled={countdown > 0}>
|
||||
{countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'}
|
||||
<ActionButton onClick={() => doRestorationAction()} variant='danger' disabled={countdown > 0 || loading}>
|
||||
{loading && <Spinner size='small' />}
|
||||
{loading ? 'Restoring...' : countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'}
|
||||
</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
<Dialog open={modal === 'delete'} onClose={() => {
|
||||
setModal('');
|
||||
setDeletePassword('');
|
||||
setDeleteTotpCode('');
|
||||
}} title={`Delete "${backup.name}"`}>
|
||||
<FlashMessageRender byKey={'backup:delete'} />
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-300">
|
||||
This is a permanent operation. The backup cannot be recovered once deleted.
|
||||
</p>
|
||||
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-300">Warning</p>
|
||||
<p className="text-red-400 mt-1">
|
||||
The backup file and its snapshot will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="delete-password" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="Enter your password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="delete-totp" className="block text-sm font-medium text-zinc-300 mb-1">
|
||||
Two-Factor Authentication Code
|
||||
</label>
|
||||
<input
|
||||
id="delete-totp"
|
||||
type="text"
|
||||
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
|
||||
placeholder="6-digit code"
|
||||
maxLength={6}
|
||||
value={deleteTotpCode}
|
||||
onChange={(e) => setDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={() => {
|
||||
setModal('');
|
||||
setDeletePassword('');
|
||||
setDeleteTotpCode('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={doDeletion}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Spinner size='small' />}
|
||||
{loading ? 'Deleting...' : 'Delete Backup'}
|
||||
</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
<Dialog.Confirm
|
||||
title={`Delete "${backup.name}"`}
|
||||
confirm={'Continue'}
|
||||
open={modal === 'delete'}
|
||||
onClose={() => setModal('')}
|
||||
onConfirmed={doDeletion}
|
||||
>
|
||||
This is a permanent operation. The backup cannot be recovered once deleted.
|
||||
</Dialog.Confirm>
|
||||
<SpinnerOverlay visible={loading} fixed />
|
||||
{backup.isSuccessful ? (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
import { Checkbox } from '@/components/elements/CheckboxNew';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock';
|
||||
import HugeIconsStorage from '@/components/elements/hugeicons/Storage';
|
||||
@@ -10,7 +11,6 @@ import { PageListItem } from '@/components/elements/pages/PageList';
|
||||
import { bytesToString } from '@/lib/formatters';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { useUnifiedBackups } from './useUnifiedBackups';
|
||||
|
||||
import BackupContextMenu from './BackupContextMenu';
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UnifiedBackup {
|
||||
message: string;
|
||||
isSuccessful?: boolean;
|
||||
isLocked: boolean;
|
||||
isAutomatic: boolean;
|
||||
checksum?: string;
|
||||
bytes?: number;
|
||||
createdAt: Date;
|
||||
@@ -36,11 +37,14 @@ export interface UnifiedBackup {
|
||||
|
||||
interface Props {
|
||||
backup: UnifiedBackup;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
isSelectable?: boolean;
|
||||
retryBackup: (backupUuid: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const BackupItem = ({ backup }: Props) => {
|
||||
const BackupItem = ({ backup, isSelected = false, onToggleSelect, isSelectable = false, retryBackup }: Props) => {
|
||||
const { addFlash, clearFlashes } = useFlash();
|
||||
const { retryBackup } = useUnifiedBackups();
|
||||
|
||||
|
||||
const handleRetry = async () => {
|
||||
@@ -125,34 +129,50 @@ const BackupItem = ({ backup }: Props) => {
|
||||
|
||||
return (
|
||||
<PageListItem>
|
||||
<div className='flex items-center gap-4 w-full py-1'>
|
||||
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<div className='flex items-center gap-3 w-full'>
|
||||
{/* Selection checkbox - always reserve space to prevent layout shift */}
|
||||
<div className='flex-shrink-0 w-5'>
|
||||
{isSelectable && onToggleSelect ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-5 h-5' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 w-9 h-9 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<div className='flex items-center gap-2 mb-1.5'>
|
||||
{getStatusBadge()}
|
||||
<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>
|
||||
{backup.isAutomatic && (
|
||||
<span className='text-xs text-blue-400 font-medium bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded'>
|
||||
Automatic
|
||||
</span>
|
||||
)}
|
||||
{backup.isLocked && (
|
||||
<span className='text-xs text-red-400 font-medium bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded'>
|
||||
Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for active backups */}
|
||||
{showProgressBar && (
|
||||
<div className='mb-2'>
|
||||
<div className='flex justify-between text-xs text-zinc-400 mb-1'>
|
||||
<div className='flex justify-between text-xs text-zinc-400 mb-1.5'>
|
||||
<span>{backup.message || 'Processing...'}</span>
|
||||
<span>{backup.progress}%</span>
|
||||
</div>
|
||||
<div className='w-full bg-zinc-700 rounded-full h-1.5'>
|
||||
<div className='w-full bg-zinc-700 rounded-full h-2'>
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
backup.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${backup.progress || 0}%` }}
|
||||
@@ -163,7 +183,7 @@ const BackupItem = ({ backup }: Props) => {
|
||||
|
||||
{/* Error message for failed backups */}
|
||||
{backup.status === 'failed' && backup.message && (
|
||||
<p className='text-xs text-red-400 truncate mb-1'>{backup.message}</p>
|
||||
<p className='text-xs text-red-400 truncate mb-1.5'>{backup.message}</p>
|
||||
)}
|
||||
|
||||
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
|
||||
@@ -171,23 +191,23 @@ const BackupItem = ({ backup }: Props) => {
|
||||
</div>
|
||||
|
||||
{/* Size info for completed backups */}
|
||||
<div className='hidden sm:block flex-shrink-0 text-right min-w-[80px]'>
|
||||
<div className='hidden sm:block flex-shrink-0 text-right min-w-[90px]'>
|
||||
{backup.completedAt && backup.isSuccessful && backup.bytes ? (
|
||||
<>
|
||||
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
|
||||
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Size</p>
|
||||
<p className='text-sm text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-xs text-transparent uppercase tracking-wide'>Size</p>
|
||||
<p className='text-xs text-transparent uppercase tracking-wide mb-1'>Size</p>
|
||||
<p className='text-sm text-transparent font-medium'>-</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Created time */}
|
||||
<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>
|
||||
<div className='hidden sm:block flex-shrink-0 text-right min-w-[130px]'>
|
||||
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Created</p>
|
||||
<p
|
||||
className='text-sm text-zinc-300 font-medium'
|
||||
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
|
||||
@@ -196,14 +216,14 @@ const BackupItem = ({ backup }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex-shrink-0 flex items-center gap-2'>
|
||||
{/* Actions - fixed width to prevent layout shifts */}
|
||||
<div className='flex-shrink-0 flex items-center gap-2 min-w-[68px] justify-end'>
|
||||
{/* Retry button for failed backups */}
|
||||
{backup.status === 'failed' && backup.canRetry && (
|
||||
<Can action='backup.create'>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className='p-1.5 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20 transition-colors'
|
||||
className='p-2 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20 transition-colors'
|
||||
title='Retry backup'
|
||||
>
|
||||
<HugeIconsRefresh className='w-4 h-4' />
|
||||
|
||||
@@ -1,132 +1,14 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SocketEvent } from '@/components/server/events';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { UnifiedBackup } from './BackupItem';
|
||||
import { LiveProgressContext } from './BackupContainer';
|
||||
|
||||
export const useUnifiedBackups = () => {
|
||||
const { data: backups, error, isValidating, mutate } = getServerBackups();
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
|
||||
const [liveProgress, setLiveProgress] = useState<Record<string, {
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
canRetry: boolean;
|
||||
lastUpdated: string;
|
||||
completed: boolean;
|
||||
isDeletion: boolean;
|
||||
backupName?: string;
|
||||
}>>({});
|
||||
|
||||
const handleBackupStatus = useCallback((rawData: any) => {
|
||||
let data;
|
||||
try {
|
||||
if (typeof rawData === 'string') {
|
||||
data = JSON.parse(rawData);
|
||||
} else {
|
||||
data = rawData;
|
||||
}
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backup_uuid = data?.backup_uuid;
|
||||
if (!backup_uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
status,
|
||||
progress,
|
||||
message,
|
||||
timestamp,
|
||||
operation,
|
||||
error: errorMsg,
|
||||
adapter,
|
||||
name,
|
||||
} = data;
|
||||
|
||||
|
||||
const can_retry = status === 'failed' && operation === 'create';
|
||||
const last_updated_at = timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
|
||||
const isDeletionOperation = operation === 'delete' || data.deleted === true;
|
||||
|
||||
setLiveProgress(prevProgress => {
|
||||
const currentState = prevProgress[backup_uuid];
|
||||
const newProgress = progress || 0;
|
||||
const isCompleted = status === 'completed' && newProgress === 100;
|
||||
const displayMessage = errorMsg ? `${message || 'Operation failed'}: ${errorMsg}` : (message || '');
|
||||
|
||||
if (currentState?.completed && !isCompleted) {
|
||||
return prevProgress;
|
||||
}
|
||||
|
||||
if (currentState && !isCompleted && currentState.lastUpdated >= last_updated_at && currentState.progress >= newProgress) {
|
||||
return prevProgress;
|
||||
}
|
||||
|
||||
return {
|
||||
...prevProgress,
|
||||
[backup_uuid]: {
|
||||
status,
|
||||
progress: newProgress,
|
||||
message: displayMessage,
|
||||
canRetry: can_retry || false,
|
||||
lastUpdated: last_updated_at,
|
||||
completed: isCompleted,
|
||||
isDeletion: isDeletionOperation,
|
||||
backupName: name || currentState?.backupName,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (status === 'completed' && progress === 100) {
|
||||
mutate();
|
||||
|
||||
if (isDeletionOperation) {
|
||||
setTimeout(() => {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
const checkForBackup = async (attempts = 0) => {
|
||||
if (attempts > 10) {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Force fresh data
|
||||
await mutate();
|
||||
|
||||
const currentBackups = await mutate();
|
||||
const backupExists = currentBackups?.items?.some(b => b.uuid === backup_uuid);
|
||||
|
||||
if (backupExists) {
|
||||
setLiveProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => checkForBackup(attempts + 1), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => checkForBackup(), 1000);
|
||||
}
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.BACKUP_STATUS, handleBackupStatus);
|
||||
const liveProgress = useContext(LiveProgressContext);
|
||||
|
||||
const createBackup = useCallback(async (name: string, ignored: string, isLocked: boolean) => {
|
||||
const { default: createServerBackup } = await import('@/api/server/backups/createServerBackup');
|
||||
@@ -181,6 +63,7 @@ export const useUnifiedBackups = () => {
|
||||
message: live ? live.message : (backup.isSuccessful ? 'Completed' : 'Failed'),
|
||||
isSuccessful: backup.isSuccessful,
|
||||
isLocked: backup.isLocked,
|
||||
isAutomatic: backup.isAutomatic,
|
||||
checksum: backup.checksum,
|
||||
bytes: backup.bytes,
|
||||
createdAt: backup.createdAt,
|
||||
@@ -208,6 +91,7 @@ export const useUnifiedBackups = () => {
|
||||
message: live.message,
|
||||
isSuccessful: false,
|
||||
isLocked: false,
|
||||
isAutomatic: false,
|
||||
checksum: undefined,
|
||||
bytes: undefined,
|
||||
createdAt: new Date(),
|
||||
@@ -227,6 +111,7 @@ export const useUnifiedBackups = () => {
|
||||
backups: unifiedBackups,
|
||||
backupCount: backups?.backupCount || 0,
|
||||
storage: backups?.storage,
|
||||
pagination: backups?.pagination,
|
||||
error,
|
||||
isValidating,
|
||||
createBackup,
|
||||
|
||||
@@ -35,6 +35,7 @@ import HugeIconsFolder from '@/components/elements/hugeicons/Folder';
|
||||
import HugeIconsHome from '@/components/elements/hugeicons/Home';
|
||||
import HugeIconsPencil from '@/components/elements/hugeicons/Pencil';
|
||||
import HugeIconsPeople from '@/components/elements/hugeicons/People';
|
||||
import HugeIconsZap from '@/components/elements/hugeicons/Zap';
|
||||
import ConflictStateRenderer from '@/components/server/ConflictStateRenderer';
|
||||
import InstallListener from '@/components/server/InstallListener';
|
||||
import TransferListener from '@/components/server/TransferListener';
|
||||
|
||||
@@ -151,6 +151,10 @@ Route::group([
|
||||
Route::get('/', [Client\Servers\BackupsController::class, 'index']);
|
||||
Route::post('/', [Client\Servers\BackupsController::class, 'store'])
|
||||
->middleware('server.operation.rate-limit');
|
||||
Route::delete('/delete-all', [Client\Servers\BackupsController::class, 'deleteAll'])
|
||||
->middleware('throttle:2,60');
|
||||
Route::post('/bulk-delete', [Client\Servers\BackupsController::class, 'bulkDelete'])
|
||||
->middleware('throttle:10,60');
|
||||
Route::get('/{backup}', [Client\Servers\BackupsController::class, 'show']);
|
||||
Route::get('/{backup}/download', [Client\Servers\BackupsController::class, 'download']);
|
||||
Route::post('/{backup}/restore', [Client\Servers\BackupsController::class, 'restore'])
|
||||
|
||||
Reference in New Issue
Block a user