From eaefd5723f7d4c1d430c2d01a197194db9c89ed7 Mon Sep 17 00:00:00 2001 From: Elizabeth Date: Wed, 15 Oct 2025 10:35:18 -0500 Subject: [PATCH] feat: fully functional deduplicated backups --- .../Api/Client/Servers/BackupsController.php | 220 ++++++- .../Api/Client/Servers/SettingsController.php | 2 - .../Api/Remote/ElytraJobCompleteRequest.php | 1 + app/Jobs/Schedule/RunTaskJob.php | 24 +- app/Jobs/Server/ApplyEggChangeJob.php | 188 +++--- app/Models/Backup.php | 12 + app/Models/Egg.php | 1 - app/Models/Node.php | 4 +- app/Models/Server.php | 1 + app/Services/Backups/DownloadLinkService.php | 1 + app/Services/Elytra/ElytraJobService.php | 8 +- app/Services/Elytra/Jobs/BackupJob.php | 252 +++++++- .../Api/Client/BackupTransformer.php | 1 + config/backups.php | 6 + ...add_repository_backup_bytes_to_servers.php | 28 + ..._03_000000_add_is_automatic_to_backups.php | 28 + resources/scripts/api/server/types.d.ts | 1 + resources/scripts/api/swr/getServerBackups.ts | 6 + resources/scripts/api/transformers.ts | 1 + .../components/elements/CheckboxNew.tsx | 2 +- .../components/elements/MainPageHeader.tsx | 2 +- .../elements/dialog/DialogFooter.tsx | 2 +- .../components/elements/pages/PageList.tsx | 6 +- .../server/backups/BackupContainer.tsx | 604 +++++++++++++++++- .../server/backups/BackupContextMenu.tsx | 224 ++++++- .../components/server/backups/BackupItem.tsx | 70 +- .../server/backups/useUnifiedBackups.ts | 127 +--- resources/scripts/routers/ServerRouter.tsx | 1 + routes/api-client.php | 4 + 29 files changed, 1515 insertions(+), 312 deletions(-) create mode 100644 database/migrations/2025_10_01_000000_add_repository_backup_bytes_to_servers.php create mode 100644 database/migrations/2025_10_03_000000_add_is_automatic_to_backups.php diff --git a/app/Http/Controllers/Api/Client/Servers/BackupsController.php b/app/Http/Controllers/Api/Client/Servers/BackupsController.php index f19b7edd5..de54afde9 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupsController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupsController.php @@ -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), + ]); + } } \ No newline at end of file diff --git a/app/Http/Controllers/Api/Client/Servers/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/SettingsController.php index 376739d9d..364192397 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/SettingsController.php @@ -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, diff --git a/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php b/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php index cb56781b6..22b2ced65 100644 --- a/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php +++ b/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php @@ -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', ]; } diff --git a/app/Jobs/Schedule/RunTaskJob.php b/app/Jobs/Schedule/RunTaskJob.php index 037de71d1..f639f02f6 100644 --- a/app/Jobs/Schedule/RunTaskJob.php +++ b/app/Jobs/Schedule/RunTaskJob.php @@ -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: diff --git a/app/Jobs/Server/ApplyEggChangeJob.php b/app/Jobs/Server/ApplyEggChangeJob.php index 8865f5882..4648817f4 100644 --- a/app/Jobs/Server/ApplyEggChangeJob.php +++ b/app/Jobs/Server/ApplyEggChangeJob.php @@ -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); } /** diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 8dbecd09c..73d538fca 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -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. diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 08a281ce1..00652534d 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -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. diff --git a/app/Models/Node.php b/app/Models/Node.php index 15c637e5f..07714a2d7 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -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', diff --git a/app/Models/Server.php b/app/Models/Server.php index 475dd4de4..44d664747 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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. diff --git a/app/Services/Backups/DownloadLinkService.php b/app/Services/Backups/DownloadLinkService.php index 5ba718dd4..b2ded0829 100644 --- a/app/Services/Backups/DownloadLinkService.php +++ b/app/Services/Backups/DownloadLinkService.php @@ -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); diff --git a/app/Services/Elytra/ElytraJobService.php b/app/Services/Elytra/ElytraJobService.php index e1f5f55d3..47a767778 100644 --- a/app/Services/Elytra/ElytraJobService.php +++ b/app/Services/Elytra/ElytraJobService.php @@ -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') { diff --git a/app/Services/Elytra/Jobs/BackupJob.php b/app/Services/Elytra/Jobs/BackupJob.php index bc98cdc73..c6ec1a2a3 100644 --- a/app/Services/Elytra/Jobs/BackupJob.php +++ b/app/Services/Elytra/Jobs/BackupJob.php @@ -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 + } } \ No newline at end of file diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index 4c4c9eaed..49a6f4b77 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -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, diff --git a/config/backups.php b/config/backups.php index bc315ba02..f226a4644 100644 --- a/config/backups.php +++ b/config/backups.php @@ -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. diff --git a/database/migrations/2025_10_01_000000_add_repository_backup_bytes_to_servers.php b/database/migrations/2025_10_01_000000_add_repository_backup_bytes_to_servers.php new file mode 100644 index 000000000..092f689fe --- /dev/null +++ b/database/migrations/2025_10_01_000000_add_repository_backup_bytes_to_servers.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2025_10_03_000000_add_is_automatic_to_backups.php b/database/migrations/2025_10_03_000000_add_is_automatic_to_backups.php new file mode 100644 index 000000000..2b3e370bf --- /dev/null +++ b/database/migrations/2025_10_03_000000_add_is_automatic_to_backups.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index eca25dec9..91766dc96 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -10,6 +10,7 @@ export interface ServerBackup { uuid: string; isSuccessful: boolean; isLocked: boolean; + isAutomatic: boolean; name: string; ignoredFiles: string; checksum: string; diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts index 4a6d845ac..54d6f399f 100644 --- a/resources/scripts/api/swr/getServerBackups.ts +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -19,6 +19,12 @@ type BackupResponse = PaginatedResult & { 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; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 23d3ed5f1..d85c995f6 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -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, diff --git a/resources/scripts/components/elements/CheckboxNew.tsx b/resources/scripts/components/elements/CheckboxNew.tsx index 4778d5504..cfd36f832 100644 --- a/resources/scripts/components/elements/CheckboxNew.tsx +++ b/resources/scripts/components/elements/CheckboxNew.tsx @@ -17,7 +17,7 @@ const Checkbox = React.forwardRef< )} {...props} > - + diff --git a/resources/scripts/components/elements/MainPageHeader.tsx b/resources/scripts/components/elements/MainPageHeader.tsx index 98ae0d778..70e16bc06 100644 --- a/resources/scripts/components/elements/MainPageHeader.tsx +++ b/resources/scripts/components/elements/MainPageHeader.tsx @@ -18,7 +18,7 @@ export const MainPageHeader: React.FC = ({ direction = 'row', }) => { return ( - +
{ const { setFooter } = useContext(DialogContext); useDeepCompareEffect(() => { - setFooter(
{children}
); + setFooter(
{children}
); }, [children]); return null; diff --git a/resources/scripts/components/elements/pages/PageList.tsx b/resources/scripts/components/elements/pages/PageList.tsx index 118cd9e4b..9fd579762 100644 --- a/resources/scripts/components/elements/pages/PageList.tsx +++ b/resources/scripts/components/elements/pages/PageList.tsx @@ -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')} > -
{children}
+
{children}
); }; @@ -24,7 +24,7 @@ const PageListItem = ({ className, children }: Props) => {
{children} diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 1b04c1c7b..d72ca8aed 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -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>({}); + // 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>(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 && (
{backupStorageLimit === null ? ( -

- {formatStorage(storage.used_mb)} storage used -

+ <> +

+ {formatStorage(storage.used_mb)} storage used +

+ {(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && ( +

+ {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`} +

+ )} + ) : ( <>

{formatStorage(storage.used_mb)} {' '} {backupStorageLimit === null ? "used" : (of {formatStorage(backupStorageLimit)} used)}

- + {(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && ( +

+ {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`} +

+ )} )}
)}
- {(backupLimit === null || backupLimit > backupCount) && - (!backupStorageLimit || !storage?.is_over_limit) && ( - setCreateModalVisible(true)}> - New Backup +
+ {backupCount > 0 && ( + setDeleteAllModalVisible(true)}> + + + + Delete All Backups )} + {(backupLimit === null || backupLimit > backupCount) && + (!backupStorageLimit || !storage?.is_over_limit) && ( + setCreateModalVisible(true)}> + New Backup + + )} +
} @@ -242,6 +421,200 @@ const BackupContainer = () => { )} + {deleteAllModalVisible && ( + { + setDeleteAllModalVisible(false); + setDeleteAllPassword(''); + setDeleteAllTotpCode(''); + }} + title='Delete All Backups' + > +
+

+ You are about to permanently delete{' '} + + {backupCount} {backupCount === 1 ? 'backup' : 'backups'} + + {' '}and completely destroy the backup repository for this server. +

+ +
+
+ + + +
+

This action cannot be undone

+
    +
  • All backup data will be permanently deleted
  • +
  • Locked backups will also be deleted
  • +
  • The entire backup repository will be destroyed
  • +
  • This operation may take several minutes to complete
  • +
  • You will not be able to restore any of these backups
  • +
+
+
+
+ +
+
+ + setDeleteAllPassword(e.target.value)} + disabled={isDeleting} + /> +
+ + {hasTwoFactor && ( +
+ + setDeleteAllTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={isDeleting} + /> +
+ )} +
+ +
+ { + setDeleteAllModalVisible(false); + setDeleteAllPassword(''); + setDeleteAllTotpCode(''); + }} + disabled={isDeleting} + > + Cancel + + + {isDeleting && } + {isDeleting ? 'Deleting...' : 'Delete All Backups'} + +
+
+
+ )} + + {/* Bulk delete modal */} + {bulkDeleteModalVisible && ( + { + setBulkDeleteModalVisible(false); + setBulkDeletePassword(''); + setBulkDeleteTotpCode(''); + }} + title='Delete Selected Backups' + > + +
+

+ You are about to permanently delete{' '} + + {selectedBackups.size} backup{selectedBackups.size > 1 ? 's' : ''} + + . This action cannot be undone. +

+ +
+
+ + + +
+

Warning

+

+ The selected backup files and their snapshots will be permanently deleted. You will not be able to restore them. +

+
+
+
+ +
+
+ + setBulkDeletePassword(e.target.value)} + disabled={isBulkDeleting} + /> +
+ + {hasTwoFactor && ( +
+ + setBulkDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={isBulkDeleting} + /> +
+ )} +
+ +
+ { + setBulkDeleteModalVisible(false); + setBulkDeletePassword(''); + setBulkDeleteTotpCode(''); + }} + disabled={isBulkDeleting} + > + Cancel + + + {isBulkDeleting && } + {isBulkDeleting ? 'Deleting...' : `Delete ${selectedBackups.size} Backup${selectedBackups.size > 1 ? 's' : ''}`} + +
+
+
+ )} + {backups.length === 0 ? (
@@ -265,11 +638,64 @@ const BackupContainer = () => {
) : ( - - {backups.map((backup) => ( - - ))} - + <> + {/* Bulk action bar */} + {selectableBackups.length > 0 && ( +
+
+ 0} + onCheckedChange={toggleSelectAll} + /> + + {selectedBackups.size > 0 ? ( + <> + {selectedBackups.size} selected + + ) : ( + 'Select backups' + )} + +
+ +
0 ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> + + Clear + + + setBulkDeleteModalVisible(true)} + > + Delete Selected ({selectedBackups.size}) + + +
+
+ )} + + + {backups.map((backup) => ( + toggleBackupSelection(backup.uuid)} + isSelectable={selectableBackups.some((b) => b.uuid === backup.uuid)} + retryBackup={retryBackup} + /> + ))} + + + {pagination && pagination.currentPage && pagination.totalPages && pagination.totalPages > 1 && ( + + {() => null} + + )} + )} ); @@ -277,10 +703,144 @@ const BackupContainer = () => { const BackupContainerWrapper = () => { const [page, setPage] = useState(1); + const { mutate } = getServerBackups(); + const [liveProgress, setLiveProgress] = useState>({}); + + // 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 ( - - - + + + + + ); }; diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 4abe0f0a0..515c26a07 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -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. - setModal('')} title='Restore Backup'> + { + setModal(''); + setRestorePassword(''); + setRestoreTotpCode(''); + }} title='Restore Backup'> +

"{backup.name}"

@@ -208,26 +273,141 @@ const BackupContextMenu = ({ backup }: Props) => {
+ +
+
+ + setRestorePassword(e.target.value)} + disabled={loading} + /> +
+ + {hasTwoFactor && ( +
+ + setRestoreTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={loading} + /> +
+ )} +
- setModal('')} variant='secondary'> + { + setModal(''); + setRestorePassword(''); + setRestoreTotpCode(''); + }} variant='secondary' disabled={loading}> Cancel - doRestorationAction()} variant='danger' disabled={countdown > 0}> - {countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'} + doRestorationAction()} variant='danger' disabled={countdown > 0 || loading}> + {loading && } + {loading ? 'Restoring...' : countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'} + + +
+ { + setModal(''); + setDeletePassword(''); + setDeleteTotpCode(''); + }} title={`Delete "${backup.name}"`}> + +
+

+ This is a permanent operation. The backup cannot be recovered once deleted. +

+ +
+
+ + + +
+

Warning

+

+ The backup file and its snapshot will be permanently deleted. +

+
+
+
+ +
+
+ + setDeletePassword(e.target.value)} + disabled={loading} + /> +
+ + {hasTwoFactor && ( +
+ + setDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))} + disabled={loading} + /> +
+ )} +
+
+ + + { + setModal(''); + setDeletePassword(''); + setDeleteTotpCode(''); + }} + disabled={loading} + > + Cancel + + + {loading && } + {loading ? 'Deleting...' : 'Delete Backup'}
- setModal('')} - onConfirmed={doDeletion} - > - This is a permanent operation. The backup cannot be recovered once deleted. - {backup.isSuccessful ? ( diff --git a/resources/scripts/components/server/backups/BackupItem.tsx b/resources/scripts/components/server/backups/BackupItem.tsx index 29559c6df..fe4b93365 100644 --- a/resources/scripts/components/server/backups/BackupItem.tsx +++ b/resources/scripts/components/server/backups/BackupItem.tsx @@ -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; } -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 ( -
-
+
+ {/* Selection checkbox - always reserve space to prevent layout shift */} +
+ {isSelectable && onToggleSelect ? ( + e.stopPropagation()} + /> + ) : ( +
+ )} +
+ +
{getStatusIcon()}
-
+
{getStatusBadge()}

{backup.name}

- - Locked - + {backup.isAutomatic && ( + + Automatic + + )} + {backup.isLocked && ( + + Locked + + )}
{/* Progress bar for active backups */} {showProgressBar && (
-
+
{backup.message || 'Processing...'} {backup.progress}%
-
+
{ {/* Error message for failed backups */} {backup.status === 'failed' && backup.message && ( -

{backup.message}

+

{backup.message}

)} {backup.checksum &&

{backup.checksum}

} @@ -171,23 +191,23 @@ const BackupItem = ({ backup }: Props) => {
{/* Size info for completed backups */} -
+
{backup.completedAt && backup.isSuccessful && backup.bytes ? ( <> -

Size

+

Size

{bytesToString(backup.bytes)}

) : ( <> -

Size

+

Size

-

)}
{/* Created time */} -
-

Created

+
+

Created

{

- {/* Actions */} -
+ {/* Actions - fixed width to prevent layout shifts */} +
{/* Retry button for failed backups */} {backup.status === 'failed' && backup.canRetry && (