diff --git a/.gitignore b/.gitignore index 6c7896de3..e39008182 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ /vendor + +# Elytra binary +elytra +!elytra/ + *.DS_Store* !.env.ci !.env.example diff --git a/Vagrantfile b/Vagrantfile index ceee55995..7df12ca79 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -64,12 +64,13 @@ Vagrant.configure("2") do |config| end if Vagrant::Util::Platform.windows? - config.vm.synced_folder ".", "/var/www/pterodactyl", - type: "rsync", - rsync__exclude: [".git/", "node_modules/", "vendor/**", ".vagrant/", "storage/logs/", "storage/framework/cache/", "storage/framework/sessions/", "storage/framework/views/"], - rsync__args: ["--verbose", "--archive", "--delete", "-z", "--copy-links"] + config.vm.synced_folder ".", "/home/vagrant/pyrodactyl", + type: "virtualbox", + owner: "vagrant", + group: "vagrant", + mount_options: ["dmode=775", "fmode=664"] else - config.vm.synced_folder ".", "/var/www/pterodactyl", + config.vm.synced_folder ".", "/home/vagrant/pyrodactyl", type: "nfs", nfs_version: 4, nfs_udp: false, @@ -84,6 +85,6 @@ Vagrant.configure("2") do |config| Pyrodactyl is up and running at http://localhost:3000 Login with: username: dev@pyro.host - password: password + password: dev MSG end diff --git a/app/Console/Commands/Backups/PollBackupJobsCommand.php b/app/Console/Commands/Backups/PollBackupJobsCommand.php deleted file mode 100644 index 0c6b3b5b9..000000000 --- a/app/Console/Commands/Backups/PollBackupJobsCommand.php +++ /dev/null @@ -1,110 +0,0 @@ -option('timeout'); - $limit = (int) $this->option('limit'); - $startTime = time(); - - $this->info('Starting backup job polling...'); - - try { - $totalResults = ['updated' => 0, 'errors' => 0, 'completed' => 0]; - $iterations = 0; - - do { - $iterations++; - $results = $this->pollingService->pollAllJobs(); - - // Accumulate results - $totalResults['updated'] += $results['updated']; - $totalResults['errors'] += $results['errors']; - $totalResults['completed'] += $results['completed']; - - if ($results['updated'] > 0 || $results['errors'] > 0) { - $this->line(sprintf( - 'Iteration %d: Updated %d jobs, %d errors, %d completed', - $iterations, - $results['updated'], - $results['errors'], - $results['completed'] - )); - } - - // Check if we should continue - $elapsed = time() - $startTime; - $shouldContinue = $elapsed < $timeout && - $totalResults['updated'] < $limit && - ($results['updated'] > 0 || $iterations === 1); // Always do at least one full iteration - - if ($shouldContinue && $results['updated'] > 0) { - // Brief pause before next iteration to avoid overwhelming Elytra - sleep(2); - } - - } while ($shouldContinue); - - $this->info(sprintf( - 'Backup job polling completed. Total: %d updated, %d errors, %d completed in %d iterations (%.2fs)', - $totalResults['updated'], - $totalResults['errors'], - $totalResults['completed'], - $iterations, - time() - $startTime - )); - - Log::info('Backup job polling completed', [ - 'updated' => $totalResults['updated'], - 'errors' => $totalResults['errors'], - 'completed' => $totalResults['completed'], - 'iterations' => $iterations, - 'duration' => time() - $startTime, - ]); - - return self::SUCCESS; - - } catch (\Exception $e) { - $this->error('Backup job polling failed: ' . $e->getMessage()); - - Log::error('Backup job polling failed', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - 'duration' => time() - $startTime, - ]); - - return self::FAILURE; - } - } -} \ No newline at end of file diff --git a/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php b/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php index 7627ae1cc..de51ee14f 100644 --- a/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php +++ b/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Console\Commands\Maintenance; use Illuminate\Console\Command; use Pterodactyl\Models\Backup; -use Pterodactyl\Services\Backups\DeleteBackupService; use Illuminate\Database\Eloquent\Builder; class DeleteOrphanedBackupsCommand extends Command @@ -16,7 +15,7 @@ class DeleteOrphanedBackupsCommand extends Command /** * DeleteOrphanedBackupsCommand constructor. */ - public function __construct(private DeleteBackupService $deleteBackupService) + public function __construct() { parent::__construct(); } @@ -80,8 +79,8 @@ class DeleteOrphanedBackupsCommand extends Command $deletedCount++; $this->info("Force deleted soft-deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}"); } else { - // Use the service to properly delete from storage and database - $this->deleteBackupService->handle($backup); + // Delete the orphaned backup from the database + $backup->forceDelete(); $deletedCount++; $this->info("Deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}"); } diff --git a/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php b/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php index b7a04f8ee..b0346d355 100644 --- a/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php +++ b/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Console\Commands\Maintenance; use Carbon\CarbonImmutable; use Illuminate\Console\Command; -use Pterodactyl\Repositories\Eloquent\BackupRepository; +use Pterodactyl\Models\Backup; class PruneOrphanedBackupsCommand extends Command { @@ -15,7 +15,7 @@ class PruneOrphanedBackupsCommand extends Command /** * PruneOrphanedBackupsCommand constructor. */ - public function __construct(private BackupRepository $backupRepository) + public function __construct() { parent::__construct(); } @@ -27,7 +27,7 @@ class PruneOrphanedBackupsCommand extends Command throw new \InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.'); } - $query = $this->backupRepository->getBuilder() + $query = Backup::query() ->whereNull('completed_at') ->where('created_at', '<=', CarbonImmutable::now()->subMinutes($since)->toDateTimeString()); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 69ab415b9..0af5a0a71 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -11,7 +11,6 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Pterodactyl\Console\Commands\Schedule\ProcessRunnableCommand; use Pterodactyl\Console\Commands\Maintenance\PruneOrphanedBackupsCommand; use Pterodactyl\Console\Commands\Maintenance\CleanServiceBackupFilesCommand; -use Pterodactyl\Console\Commands\Backups\PollBackupJobsCommand; class Kernel extends ConsoleKernel { @@ -33,7 +32,6 @@ class Kernel extends ConsoleKernel $schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping(); $schedule->command(CleanServiceBackupFilesCommand::class)->daily(); - $schedule->command(PollBackupJobsCommand::class)->everyMinute()->withoutOverlapping(); if (config('backups.prune_age')) { $schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes(); diff --git a/app/Contracts/Elytra/Job.php b/app/Contracts/Elytra/Job.php new file mode 100644 index 000000000..c6afd78f7 --- /dev/null +++ b/app/Contracts/Elytra/Job.php @@ -0,0 +1,24 @@ +user()->can(Permission::ACTION_BACKUP_READ, $server)) { - throw new AuthorizationException(); - } - - $limit = min($request->query('per_page') ?? 20, 50); - - // Sort backups: locked ones first, then by created_at descending (latest first) - $backups = $server->backups() - ->orderByRaw('is_locked DESC, created_at DESC') - ->paginate($limit); - - $storageInfo = $this->backupStorageService->getStorageUsageInfo($server); - - return $this->fractal->collection($backups) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->addMeta([ - 'backup_count' => $this->repository->getNonFailedBackups($server)->count(), - 'storage' => [ - 'used_mb' => $storageInfo['used_mb'], - 'limit_mb' => $storageInfo['limit_mb'], - 'has_limit' => $storageInfo['has_limit'], - 'usage_percentage' => $storageInfo['usage_percentage'] ?? null, - 'available_mb' => $storageInfo['available_mb'] ?? null, - 'is_over_limit' => $storageInfo['is_over_limit'] ?? false, - ], - 'limits' => [ - 'count_limit' => $server->backup_limit, - 'has_count_limit' => $server->hasBackupCountLimit(), - 'storage_limit_mb' => $server->backup_storage_limit, - 'has_storage_limit' => $server->hasBackupStorageLimit(), - ], - ]) - ->toArray(); - } - - /** - * Starts the async backup process for a server. - * - * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation - * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified - * @throws \Throwable - */ - public function store(StoreBackupRequest $request, Server $server): array - { - $action = $this->asyncBackupService - ->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? '')); - - // Only set the lock status if the user even has permission to delete backups, - // otherwise ignore this status. This gets a little funky since it isn't clear - // how best to allow a user to create a backup that is locked without also preventing - // them from just filling up a server with backups that can never be deleted? - if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - $action->setIsLocked((bool) $request->input('is_locked')); - } - - $backup = $action->initiate($server, $request->input('name')); - - Activity::event('server:backup.start') - ->subject($backup) - ->property([ - 'name' => $backup->name, - 'locked' => (bool) $request->input('is_locked'), - 'adapter' => $backup->disk, - 'async' => true, - 'job_id' => $backup->job_id, - ]) - ->log(); - - return $this->fractal->item($backup) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->addMeta([ - 'job_id' => $backup->job_id, - 'status' => $backup->job_status, - 'progress' => $backup->job_progress, - 'message' => $backup->job_message, - ]) - ->toArray(); - } - - /** - * Toggles the lock status of a given backup for a server. - * - * @throws \Throwable - * @throws AuthorizationException - */ - public function toggleLock(Request $request, Server $server, Backup $backup): array - { - if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - throw new AuthorizationException(); - } - - $action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock'; - - $backup->update(['is_locked' => !$backup->is_locked]); - - Activity::event($action)->subject($backup)->property('name', $backup->name)->log(); - - return $this->fractal->item($backup) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->toArray(); - } - - /** - * Rename a backup. - * - * @throws AuthorizationException - */ - public function rename(Request $request, Server $server, Backup $backup): array - { - if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - throw new AuthorizationException(); - } - - $request->validate([ - 'name' => 'required|string|min:1|max:191', - ]); - - $oldName = $backup->name; - $newName = trim($request->input('name')); - - // Sanitize backup name to prevent injection - $newName = preg_replace('/[^a-zA-Z0-9\s\-_\.\(\)→:,]/', '', $newName); - $newName = substr($newName, 0, 191); // Limit to database field length - - if (empty($newName)) { - throw new BadRequestHttpException('Backup name cannot be empty after sanitization.'); - } - - $backup->update(['name' => $newName]); - - Activity::event('server:backup.rename') - ->subject($backup) - ->property([ - 'old_name' => $oldName, - 'new_name' => $newName, - ]) - ->log(); - - return $this->fractal->item($backup) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->toArray(); - } - - /** - * Cancel a running backup job - * - * @throws AuthorizationException - */ - public function cancel(Request $request, Server $server, Backup $backup): JsonResponse - { - if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - throw new AuthorizationException(); - } - - if (!$backup->canCancel()) { - throw new BadRequestHttpException('This backup cannot be cancelled.'); - } - - $success = $this->asyncBackupService->cancel($backup); - - if ($success) { - Activity::event('server:backup.cancel') - ->subject($backup) - ->property(['name' => $backup->name, 'job_id' => $backup->job_id]) - ->log(); - - return new JsonResponse([ - 'message' => 'Backup cancelled successfully', - 'status' => $backup->job_status, - ]); - } - - throw new BadRequestHttpException('Failed to cancel backup.'); - } - - /** - * Retry a failed backup - * - * @throws AuthorizationException - */ - public function retry(Request $request, Server $server, Backup $backup): JsonResponse - { - if (!$request->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) { - throw new AuthorizationException(); - } - - if (!$backup->canRetry()) { - throw new BadRequestHttpException('This backup cannot be retried.'); - } - - $success = $this->asyncBackupService->retry($backup); - - if ($success) { - Activity::event('server:backup.retry') - ->subject($backup) - ->property(['name' => $backup->name, 'old_job_id' => $backup->job_id]) - ->log(); - - // Refresh backup to get updated job_id - $backup->refresh(); - - return new JsonResponse([ - 'message' => 'Backup retry initiated successfully', - 'job_id' => $backup->job_id, - 'status' => $backup->job_status, - 'progress' => $backup->job_progress, - ]); - } - - throw new BadRequestHttpException('Failed to retry backup.'); - } - - /** - * Get real-time status of a backup job - * - * @throws AuthorizationException - */ - public function status(Request $request, Server $server, Backup $backup): JsonResponse - { - if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { - throw new AuthorizationException(); - } - - // Poll latest status from Elytra if job is still active - if ($backup->isInProgress() && $backup->job_id) { - $this->pollingService->pollBackupStatus($backup); - $backup->refresh(); - } - - return new JsonResponse([ - 'job_id' => $backup->job_id, - 'status' => $backup->job_status, - 'progress' => $backup->job_progress, - 'message' => $backup->job_message, - 'error' => $backup->job_error, - 'is_successful' => $backup->is_successful, - 'can_cancel' => $backup->canCancel(), - 'can_retry' => $backup->canRetry(), - 'started_at' => $backup->job_started_at?->toISOString(), - 'last_updated_at' => $backup->job_last_updated_at?->toISOString(), - 'completed_at' => $backup->completed_at?->toISOString(), - ]); - } - - /** - * Returns information about a single backup. - * - * @throws AuthorizationException - */ - public function view(Request $request, Server $server, Backup $backup): array - { - if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { - throw new AuthorizationException(); - } - - return $this->fractal->item($backup) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->toArray(); - } - - /** - * Deletes a backup from the panel as well as the remote source where it is currently - * being stored. - * - * @throws \Throwable - */ - public function delete(Request $request, Server $server, Backup $backup): JsonResponse - { - if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - throw new AuthorizationException(); - } - - $this->deleteBackupService->handle($backup); - - Activity::event('server:backup.delete') - ->subject($backup) - ->property(['name' => $backup->name, 'failed' => !$backup->is_successful]) - ->log(); - - return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); - } - - /** - * Download the backup for a given server instance. For daemon local files, the file - * will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated - * which the user is redirected to. - * - * @throws \Throwable - * @throws AuthorizationException - */ - public function download(Request $request, Server $server, Backup $backup): JsonResponse - { - if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) { - throw new AuthorizationException(); - } - - $allowedAdapters = [ - Backup::ADAPTER_AWS_S3, - Backup::ADAPTER_WINGS, - Backup::ADAPTER_RUSTIC_LOCAL, - Backup::ADAPTER_RUSTIC_S3 - ]; - - if (!in_array($backup->disk, $allowedAdapters)) { - throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.'); - } - - $url = $this->downloadLinkService->handle($backup, $request->user()); - - Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log(); - - return new JsonResponse([ - 'object' => 'signed_url', - 'attributes' => ['url' => $url], - ]); - } - - /** - * Handles restoring a backup by making a request to the Wings instance telling it - * to begin the process of finding (or downloading) the backup and unpacking it - * over the server files. - * - * All files that currently exist on the server will be deleted before restoring - * the backup to ensure a clean restoration process. - * - * @throws \Throwable - */ - public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse - { - $this->validateServerForRestore($server); - - $this->validateBackupForRestore($backup); - - // Validate server state compatibility if backup has state data - if ($this->serverStateService->hasServerState($backup)) { - $compatibility = $this->serverStateService->validateRestoreCompatibility($backup); - - if (!empty($compatibility['errors'])) { - throw new BadRequestHttpException('Cannot restore backup: ' . implode(' ', $compatibility['errors'])); - } - - // Log warnings for user awareness - if (!empty($compatibility['warnings'])) { - \Log::warning('Backup restore compatibility warnings', [ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $server->uuid, - 'warnings' => $compatibility['warnings'], - ]); - } - } - - $hasServerState = $this->serverStateService->hasServerState($backup); - - $log = Activity::event('server:backup.restore') - ->subject($backup) - ->property([ - 'name' => $backup->name, - 'truncate' => true, - 'has_server_state' => $hasServerState, - ]); - - $log->transaction(function () use ($backup, $server, $request, $hasServerState) { - // Double-check server state within transaction to prevent race conditions - $server->refresh(); - if (!is_null($server->status)) { - throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.'); - } - - // If the backup is for an S3 file (legacy or rustic) we need to generate a unique - // Download link for it that will allow Wings to actually access the file. - $url = null; - if (in_array($backup->disk, [Backup::ADAPTER_AWS_S3, Backup::ADAPTER_RUSTIC_S3])) { - try { - $url = $this->downloadLinkService->handle($backup, $request->user()); - } catch (\Exception $e) { - throw new BadRequestHttpException('Failed to generate download link for S3 backup: ' . $e->getMessage()); - } - } - - // Update the status right away for the server so that we know not to allow certain - // actions against it via the Panel API. - $server->update(['status' => Server::STATUS_RESTORING_BACKUP]); - - try { - // Start the file restoration process on Wings (always truncate for clean restore) - $this->daemonRepository->setServer($server)->restore($backup, $url); - - // If backup has server state, restore it immediately - // This is safe to do now since we're in a transaction and the daemon request succeeded - if ($hasServerState) { - $this->serverStateService->restoreServerState($server, $backup); - } - } catch (\Exception $e) { - // If either daemon request or state restoration fails, reset server status - $server->update(['status' => null]); - throw $e; - } - }); - - return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); - } - - /** - * Validate server state for backup restoration - */ - private function validateServerForRestore(Server $server): void - { - // Cannot restore a backup unless a server is fully installed and not currently - // processing a different backup restoration request. - if (!is_null($server->status)) { - throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'); - } - - if ($server->isSuspended()) { - throw new BadRequestHttpException('Cannot restore backup for suspended server.'); - } - - if (!$server->isInstalled()) { - throw new BadRequestHttpException('Cannot restore backup for server that is not fully installed.'); - } - - if ($server->transfer) { - throw new BadRequestHttpException('Cannot restore backup while server is being transferred.'); - } - } - - /** - * Validate backup for restoration - */ - private function validateBackupForRestore(Backup $backup): void - { - if (!$backup->is_successful && is_null($backup->completed_at)) { - throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'); - } - - // Additional safety check for backup integrity - if (!$backup->is_successful) { - throw new BadRequestHttpException('Cannot restore a failed backup.'); - } - - if (is_null($backup->completed_at)) { - throw new BadRequestHttpException('Cannot restore backup that is still in progress.'); - } - } -} diff --git a/app/Http/Controllers/Api/Client/Servers/BackupsController.php b/app/Http/Controllers/Api/Client/Servers/BackupsController.php new file mode 100644 index 000000000..f19b7edd5 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/BackupsController.php @@ -0,0 +1,219 @@ +user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + + $limit = min($request->query('per_page') ?? 20, 50); + + $backups = $server->backups() + ->orderByRaw('is_locked DESC, created_at DESC') + ->paginate($limit); + + 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), + 'limit_mb' => null, + 'has_limit' => false, + 'usage_percentage' => null, + 'available_mb' => null, + 'is_over_limit' => false, + ], + 'limits' => [ + 'count_limit' => null, + 'has_count_limit' => false, + 'storage_limit_mb' => null, + 'has_storage_limit' => false, + ], + ]) + ->toArray(); + } + + public function store(StoreBackupRequest $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) { + throw new AuthorizationException(); + } + + $result = $this->elytraJobService->submitJob( + $server, + 'backup_create', + [ + 'operation' => 'create', + 'adapter' => $request->input('adapter', config('backups.default')), + 'ignored' => $request->input('ignored', ''), + 'name' => $request->input('name'), + ], + $request->user() + ); + + Activity::event('backup:create') + ->subject($server) + ->property(['backup_name' => $request->input('name'), 'job_id' => $result['job_id']]) + ->log(); + + return new JsonResponse($result); + } + + public function show(Request $request, Server $server, Backup $backup): array + { + if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + + return $this->fractal->item($backup) + ->transformWith($this->transformer) + ->toArray(); + } + + public function destroy(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $result = $this->elytraJobService->submitJob( + $server, + 'backup_delete', + [ + 'operation' => 'delete', + 'backup_uuid' => $backup->uuid, + ], + $request->user() + ); + + Activity::event('backup:delete') + ->subject($server) + ->property(['backup_name' => $backup->name, 'job_id' => $result['job_id']]) + ->log(); + + return new JsonResponse($result); + } + + public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) { + throw new AuthorizationException(); + } + + $result = $this->elytraJobService->submitJob( + $server, + 'backup_restore', + [ + 'operation' => 'restore', + 'backup_uuid' => $backup->uuid, + 'truncate_directory' => $request->boolean('truncate_directory'), + ], + $request->user() + ); + + Activity::event('backup:restore') + ->subject($server) + ->property(['backup_name' => $backup->name, 'job_id' => $result['job_id']]) + ->log(); + + return new JsonResponse($result); + } + + public function download(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) { + throw new AuthorizationException(); + } + + if (!$backup->is_successful) { + throw new \Exception('Cannot download an incomplete backup.'); + } + + $url = $this->downloadLinkService->handle($backup, $request->user()); + + Activity::event('backup:download') + ->subject($server) + ->property(['backup_name' => $backup->name]) + ->log(); + + return new JsonResponse([ + 'object' => 'signed_url', + 'attributes' => ['url' => $url], + ]); + } + + public function rename(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $request->validate([ + 'name' => 'required|string|max:191', + ]); + + $backup->update([ + 'name' => $request->input('name'), + ]); + + Activity::event('backup:rename') + ->subject($server) + ->property(['old_name' => $backup->getOriginal('name'), 'new_name' => $backup->name]) + ->log(); + + $transformed = $this->fractal->item($backup) + ->transformWith($this->transformer) + ->toArray(); + + return new JsonResponse($transformed); + } + + public function toggleLock(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $backup->update([ + 'is_locked' => !$backup->is_locked, + ]); + + Activity::event('backup:lock') + ->subject($server) + ->property(['backup_name' => $backup->name, 'locked' => $backup->is_locked]) + ->log(); + + $transformed = $this->fractal->item($backup) + ->transformWith($this->transformer) + ->toArray(); + + return new JsonResponse($transformed); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php b/app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php new file mode 100644 index 000000000..dcb2da065 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php @@ -0,0 +1,126 @@ +query('type'); + + if ($jobType) { + $handler = $this->elytraJobService->getJobHandler($jobType); + $requiredPermissions = $handler->getRequiredPermissions('index'); + + foreach ($requiredPermissions as $permission) { + if (!$request->user()->can($permission, $server)) { + throw new AuthorizationException(); + } + } + } + + $jobs = $this->elytraJobService->listJobs( + $server, + $request->query('type'), + $request->query('status') + ); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $jobs, + ]); + } + + public function create(Request $request, Server $server): JsonResponse + { + $jobType = $request->input('job_type'); + $jobData = $request->input('job_data', []); + + $handler = $this->elytraJobService->getJobHandler($jobType); + $requiredPermissions = $handler->getRequiredPermissions('create'); + + foreach ($requiredPermissions as $permission) { + if (!$request->user()->can($permission, $server)) { + throw new AuthorizationException(); + } + } + + $result = $this->elytraJobService->submitJob( + $server, + $jobType, + $jobData, + $request->user() + ); + + Activity::event('job:create') + ->subject($server) + ->property(['job_type' => $jobType, 'job_id' => $result['job_id']]) + ->log(); + + return new JsonResponse($result); + } + + public function show(Request $request, Server $server, string $jobId): JsonResponse + { + $job = $this->elytraJobService->getJobStatus($server, $jobId); + + if (!$job) { + return response()->json(['error' => 'Job not found'], 404); + } + + $handler = $this->elytraJobService->getJobHandler($job['type']); + $requiredPermissions = $handler->getRequiredPermissions('show'); + + foreach ($requiredPermissions as $permission) { + if (!$request->user()->can($permission, $server)) { + throw new AuthorizationException(); + } + } + + return new JsonResponse([ + 'object' => 'job', + 'attributes' => $job, + ]); + } + + public function cancel(Request $request, Server $server, string $jobId): JsonResponse + { + $job = $this->elytraJobService->getJobStatus($server, $jobId); + + if (!$job) { + return response()->json(['error' => 'Job not found'], 404); + } + + $handler = $this->elytraJobService->getJobHandler($job['type']); + $requiredPermissions = $handler->getRequiredPermissions('cancel'); + + foreach ($requiredPermissions as $permission) { + if (!$request->user()->can($permission, $server)) { + throw new AuthorizationException(); + } + } + + $result = $this->elytraJobService->cancelJob($server, $jobId); + + Activity::event('job:cancel') + ->subject($server) + ->property(['job_id' => $jobId]) + ->log(); + + return new JsonResponse($result); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupDeleteController.php b/app/Http/Controllers/Api/Remote/Backups/BackupDeleteController.php index 1a8a99dba..339e11ba5 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupDeleteController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupDeleteController.php @@ -7,19 +7,12 @@ use Pterodactyl\Models\Backup; use Illuminate\Http\JsonResponse; use Pterodactyl\Facades\Activity; use Pterodactyl\Http\Controllers\Controller; -use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Exceptions\Http\HttpForbiddenException; use Pterodactyl\Exceptions\Service\Backup\BackupLockedException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class BackupDeleteController extends Controller { - /** - * BackupDeleteController constructor. - */ - public function __construct(private DeleteBackupService $deleteBackupService) - { - } /** * Handles the deletion of a backup from the remote daemon. @@ -52,7 +45,8 @@ class BackupDeleteController extends Controller ->property('name', $model->name); $log->transaction(function () use ($model) { - $this->deleteBackupService->handle($model); + // Simply mark the backup as deleted + $model->delete(); }); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); diff --git a/app/Http/Controllers/Api/Remote/ElytraJobCompletionController.php b/app/Http/Controllers/Api/Remote/ElytraJobCompletionController.php new file mode 100644 index 000000000..9d8040f0b --- /dev/null +++ b/app/Http/Controllers/Api/Remote/ElytraJobCompletionController.php @@ -0,0 +1,33 @@ +elytraJobService->updateJobStatus($jobId, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => 'Job status updated successfully', + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + ], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php b/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php new file mode 100644 index 000000000..cb56781b6 --- /dev/null +++ b/app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php @@ -0,0 +1,34 @@ + 'required|boolean', + 'job_type' => 'required|string', + 'status' => 'nullable|string|in:pending,running,completed,failed', + 'message' => 'nullable|string', + 'error_message' => 'nullable|string', + 'progress' => 'nullable|integer|min:0|max:100', + 'updated_at' => 'nullable|integer', + + // Generic result data (job-type specific fields can be included here) + 'checksum' => 'nullable|string', + 'checksum_type' => 'nullable|string', + 'size' => 'nullable|integer|min:0', + 'snapshot_id' => 'nullable|string', + 'adapter' => 'nullable|string', + 'result_data' => 'nullable|array', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Remote/ReportJobCompleteRequest.php b/app/Http/Requests/Api/Remote/ReportJobCompleteRequest.php new file mode 100644 index 000000000..32663518b --- /dev/null +++ b/app/Http/Requests/Api/Remote/ReportJobCompleteRequest.php @@ -0,0 +1,31 @@ + 'required|boolean', + 'job_type' => 'required|string|in:backup_create,backup_delete,backup_restore', + + // Backup-specific fields (nullable for future job types) + 'checksum' => 'nullable|string', + 'checksum_type' => 'nullable|string|in:sha1,md5', + 'size' => 'nullable|integer|min:0', + 'snapshot_id' => 'nullable|string', + + // Generic fields + 'error_message' => 'nullable|string', + 'result_data' => 'nullable|array', + ]; + } +} \ No newline at end of file diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 17df1f4df..8dbecd09c 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -8,16 +8,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; /** + * Backup model + * * @property int $id * @property int $server_id * @property string $uuid - * @property string|null $job_id - * @property string $job_status - * @property int $job_progress - * @property string|null $job_message - * @property string|null $job_error - * @property \Carbon\CarbonImmutable|null $job_started_at - * @property \Carbon\CarbonImmutable|null $job_last_updated_at * @property bool $is_successful * @property bool $is_locked * @property string $name @@ -33,7 +28,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; * @property \Carbon\CarbonImmutable $updated_at * @property \Carbon\CarbonImmutable|null $deleted_at * @property Server $server - * @property \Pterodactyl\Models\BackupJobQueue[] $jobQueue + * @property \Pterodactyl\Models\ElytraJob[] $elytraJobs * @property \Pterodactyl\Models\AuditLog[] $audits */ class Backup extends Model @@ -44,27 +39,19 @@ class Backup extends Model public const RESOURCE_NAME = 'backup'; + // Backup adapters public const ADAPTER_WINGS = 'wings'; + public const ADAPTER_ELYTRA = 'elytra'; // Preferred name for local backups public const ADAPTER_AWS_S3 = 's3'; public const ADAPTER_RUSTIC_LOCAL = 'rustic_local'; public const ADAPTER_RUSTIC_S3 = 'rustic_s3'; - // Async job statuses matching Elytra's system - public const JOB_STATUS_PENDING = 'pending'; - public const JOB_STATUS_RUNNING = 'running'; - public const JOB_STATUS_COMPLETED = 'completed'; - public const JOB_STATUS_FAILED = 'failed'; - public const JOB_STATUS_CANCELLED = 'cancelled'; - protected $table = 'backups'; protected bool $immutableDates = true; protected $casts = [ 'id' => 'int', - 'job_progress' => 'int', - 'job_started_at' => 'datetime', - 'job_last_updated_at' => 'datetime', 'is_successful' => 'bool', 'is_locked' => 'bool', 'ignored_files' => 'array', @@ -74,8 +61,6 @@ class Backup extends Model ]; protected $attributes = [ - 'job_status' => self::JOB_STATUS_PENDING, - 'job_progress' => 0, 'is_successful' => false, 'is_locked' => false, 'checksum' => null, @@ -99,7 +84,7 @@ class Backup extends Model */ public function isLocal(): bool { - return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_RUSTIC_LOCAL]); + return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_ELYTRA, self::ADAPTER_RUSTIC_LOCAL]); } /** @@ -122,20 +107,23 @@ class Backup extends Model return !empty($this->snapshot_id); } + /** + * Get the size in gigabytes for display + */ + public function getSizeGbAttribute(): float + { + return round($this->bytes / 1024 / 1024 / 1024, 3); + } + public static array $validationRules = [ 'server_id' => 'bail|required|numeric|exists:servers,id', 'uuid' => 'required|uuid', - 'job_id' => 'nullable|string|max:255', - 'job_status' => 'required|string|in:pending,running,completed,failed,cancelled', - 'job_progress' => 'integer|min:0|max:100', - 'job_message' => 'nullable|string', - 'job_error' => 'nullable|string', 'is_successful' => 'boolean', 'is_locked' => 'boolean', 'name' => 'required|string', 'ignored_files' => 'array', 'server_state' => 'nullable|array', - 'disk' => 'required|string|in:wings,s3,rustic_local,rustic_s3', + 'disk' => 'required|string|in:wings,elytra,s3,rustic_local,rustic_s3', 'checksum' => 'nullable|string', 'snapshot_id' => 'nullable|string|max:64', 'bytes' => 'numeric', @@ -148,106 +136,20 @@ class Backup extends Model } /** - * Relationship to job queue entries for this backup + * Get all Elytra jobs related to this backup */ - public function jobQueue(): HasMany + public function elytraJobs(): HasMany { - return $this->hasMany(BackupJobQueue::class); + return $this->hasMany(ElytraJob::class, 'server_id', 'server_id') + ->where('job_data->backup_uuid', $this->uuid); } /** - * Check if this backup is currently in progress + * Get the latest Elytra job for this backup */ - public function isInProgress(): bool + public function latestElytraJob() { - return in_array($this->job_status, [ - self::JOB_STATUS_PENDING, - self::JOB_STATUS_RUNNING - ]); - } - - /** - * Check if this backup has completed successfully - */ - public function isCompleted(): bool - { - return $this->job_status === self::JOB_STATUS_COMPLETED && $this->is_successful; - } - - /** - * Check if this backup has failed - */ - public function hasFailed(): bool - { - return $this->job_status === self::JOB_STATUS_FAILED || - ($this->job_status === self::JOB_STATUS_COMPLETED && !$this->is_successful); - } - - /** - * Check if this backup has been cancelled - */ - public function isCancelled(): bool - { - return $this->job_status === self::JOB_STATUS_CANCELLED; - } - - /** - * Check if this backup can be cancelled - */ - public function canCancel(): bool - { - return $this->isInProgress() && !empty($this->job_id); - } - - /** - * Check if this backup can be retried - */ - public function canRetry(): bool - { - return $this->hasFailed() && !empty($this->job_id); - } - - /** - * Update the job status and related fields - */ - public function updateJobStatus(string $status, int $progress = null, string $message = null, string $error = null): void - { - $updateData = [ - 'job_status' => $status, - 'job_last_updated_at' => now(), - ]; - - if ($progress !== null) { - $updateData['job_progress'] = max(0, min(100, $progress)); - } - - if ($message !== null) { - $updateData['job_message'] = $message; - } - - if ($error !== null) { - $updateData['job_error'] = $error; - } - - // Mark as started when first moving to running - if ($status === self::JOB_STATUS_RUNNING && $this->job_status === self::JOB_STATUS_PENDING) { - $updateData['job_started_at'] = now(); - } - - // Update completion fields when job finishes - if (in_array($status, [self::JOB_STATUS_COMPLETED, self::JOB_STATUS_FAILED, self::JOB_STATUS_CANCELLED])) { - if ($status === self::JOB_STATUS_COMPLETED) { - $updateData['is_successful'] = true; - $updateData['completed_at'] = now(); - $updateData['job_progress'] = 100; - } elseif ($status === self::JOB_STATUS_FAILED) { - $updateData['is_successful'] = false; - $updateData['completed_at'] = now(); - // Don't change lock status for failed backups - } - } - - $this->update($updateData); + return $this->elytraJobs()->latest('created_at')->first(); } /** @@ -256,7 +158,8 @@ class Backup extends Model public function getElytraAdapterType(): string { return match($this->disk) { - self::ADAPTER_WINGS => 'elytra', // Elytra uses 'elytra' for local backups + self::ADAPTER_WINGS => 'elytra', // Legacy support: wings -> elytra + self::ADAPTER_ELYTRA => 'elytra', // Direct mapping for new backups self::ADAPTER_AWS_S3 => 's3', self::ADAPTER_RUSTIC_LOCAL => 'rustic_local', self::ADAPTER_RUSTIC_S3 => 'rustic_s3', @@ -265,20 +168,11 @@ class Backup extends Model } /** - * Scope to get backups that are currently in progress + * Scope to get successful backups */ - public function scopeInProgress($query) + public function scopeSuccessful($query) { - return $query->whereIn('job_status', [self::JOB_STATUS_PENDING, self::JOB_STATUS_RUNNING]); - } - - /** - * Scope to get completed backups - */ - public function scopeCompleted($query) - { - return $query->where('job_status', self::JOB_STATUS_COMPLETED) - ->where('is_successful', true); + return $query->where('is_successful', true); } /** @@ -286,12 +180,31 @@ class Backup extends Model */ public function scopeFailed($query) { - return $query->where(function($q) { - $q->where('job_status', self::JOB_STATUS_FAILED) - ->orWhere(function($subQ) { - $subQ->where('job_status', self::JOB_STATUS_COMPLETED) - ->where('is_successful', false); - }); - }); + return $query->where('is_successful', false); } -} + + /** + * Scope to get locked backups + */ + public function scopeLocked($query) + { + return $query->where('is_locked', true); + } + + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return 'uuid'; + } + + /** + * Resolve the route binding by UUID instead of ID. + */ + public function resolveRouteBinding($value, $field = null): ?\Illuminate\Database\Eloquent\Model + { + return $this->query()->where($field ?? $this->getRouteKeyName(), $value)->firstOrFail(); + } +} \ No newline at end of file diff --git a/app/Models/BackupJobQueue.php b/app/Models/BackupJobQueue.php deleted file mode 100644 index 473652106..000000000 --- a/app/Models/BackupJobQueue.php +++ /dev/null @@ -1,172 +0,0 @@ - */ - use HasFactory; - - public const RESOURCE_NAME = 'backup_job_queue'; - - // Operation types - public const OPERATION_CREATE = 'create'; - public const OPERATION_DELETE = 'delete'; - public const OPERATION_RESTORE = 'restore'; - - // Job statuses - public const STATUS_QUEUED = 'queued'; - public const STATUS_PROCESSING = 'processing'; - public const STATUS_COMPLETED = 'completed'; - public const STATUS_FAILED = 'failed'; - public const STATUS_CANCELLED = 'cancelled'; - public const STATUS_RETRY = 'retry'; - - protected $table = 'backup_job_queue'; - - protected bool $immutableDates = true; - - protected $casts = [ - 'id' => 'int', - 'backup_id' => 'int', - 'job_data' => 'array', - 'retry_count' => 'int', - 'last_polled_at' => 'datetime', - 'expires_at' => 'datetime', - ]; - - protected $attributes = [ - 'status' => self::STATUS_QUEUED, - 'retry_count' => 0, - ]; - - protected $guarded = ['id', 'created_at', 'updated_at']; - - public static array $validationRules = [ - 'job_id' => 'required|string|max:255', - 'backup_id' => 'required|int|exists:backups,id', - 'operation_type' => 'required|string|in:create,delete,restore', - 'status' => 'required|string|in:queued,processing,completed,failed,cancelled,retry', - 'job_data' => 'nullable|array', - 'error_message' => 'nullable|string', - 'retry_count' => 'int|min:0|max:10', - ]; - - /** - * Relationship to the associated backup - */ - public function backup(): BelongsTo - { - return $this->belongsTo(Backup::class); - } - - /** - * Check if this job can be retried - */ - public function canRetry(): bool - { - return $this->status === self::STATUS_FAILED && - $this->retry_count < config('backups.max_retry_attempts', 3); - } - - /** - * Check if this job has expired and should be cleaned up - */ - public function isExpired(): bool - { - return $this->expires_at && $this->expires_at->isPast(); - } - - /** - * Mark this job for retry - */ - public function markForRetry(string $errorMessage = null): void - { - $this->update([ - 'status' => self::STATUS_RETRY, - 'retry_count' => $this->retry_count + 1, - 'error_message' => $errorMessage, - 'last_polled_at' => CarbonImmutable::now(), - ]); - } - - /** - * Mark this job as completed - */ - public function markCompleted(): void - { - $this->update([ - 'status' => self::STATUS_COMPLETED, - 'last_polled_at' => CarbonImmutable::now(), - ]); - } - - /** - * Mark this job as failed - */ - public function markFailed(string $errorMessage): void - { - $this->update([ - 'status' => self::STATUS_FAILED, - 'error_message' => $errorMessage, - 'last_polled_at' => CarbonImmutable::now(), - ]); - } - - /** - * Update last polled timestamp - */ - public function updateLastPolled(): void - { - $this->update(['last_polled_at' => CarbonImmutable::now()]); - } - - /** - * Get jobs that need status polling - */ - public static function needsPolling(): \Illuminate\Database\Eloquent\Builder - { - $staleThreshold = CarbonImmutable::now()->subMinutes(2); - - return static::query() - ->whereIn('status', [self::STATUS_QUEUED, self::STATUS_PROCESSING, self::STATUS_RETRY]) - ->where(function ($query) use ($staleThreshold) { - $query->whereNull('last_polled_at') - ->orWhere('last_polled_at', '<=', $staleThreshold); - }) - ->where(function ($query) { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', CarbonImmutable::now()); - }); - } - - /** - * Get expired jobs that should be cleaned up - */ - public static function expired(): \Illuminate\Database\Eloquent\Builder - { - return static::query() - ->whereNotNull('expires_at') - ->where('expires_at', '<=', CarbonImmutable::now()) - ->whereNotIn('status', [self::STATUS_COMPLETED]); - } -} \ No newline at end of file diff --git a/app/Models/ElytraJob.php b/app/Models/ElytraJob.php new file mode 100644 index 000000000..118d911da --- /dev/null +++ b/app/Models/ElytraJob.php @@ -0,0 +1,107 @@ + 'array', + 'progress' => 'integer', + 'created_at' => 'immutable_datetime', + 'submitted_at' => 'immutable_datetime', + 'completed_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + + public function uniqueIds(): array + { + return ['uuid']; + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function canBeCancelled(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING, + self::STATUS_SUBMITTED, + self::STATUS_RUNNING, + ]); + } + + public function isCompleted(): bool + { + return in_array($this->status, [ + self::STATUS_COMPLETED, + self::STATUS_FAILED, + self::STATUS_CANCELLED, + ]); + } + + public function isInProgress(): bool + { + return in_array($this->status, [ + self::STATUS_SUBMITTED, + self::STATUS_RUNNING, + ]); + } + + public function getStatusDisplayAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'Pending', + self::STATUS_SUBMITTED => 'Submitted', + self::STATUS_RUNNING => 'Running', + self::STATUS_COMPLETED => 'Completed', + self::STATUS_FAILED => 'Failed', + self::STATUS_CANCELLED => 'Cancelled', + default => ucfirst($this->status), + }; + } + + public function getOperationAttribute(): string + { + return match ($this->job_type) { + 'backup_create' => 'create', + 'backup_delete' => 'delete', + 'backup_restore' => 'restore', + 'backup_download' => 'download', + default => 'unknown', + }; + } +} \ No newline at end of file diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php deleted file mode 100644 index 49cd36ca9..000000000 --- a/app/Repositories/Eloquent/BackupRepository.php +++ /dev/null @@ -1,57 +0,0 @@ -getBuilder() - ->withTrashed() - ->where('server_id', $server) - ->where(function ($query) { - $query->whereNull('completed_at') - ->orWhere('is_successful', '=', true); - }) - ->where('created_at', '>=', Carbon::now()->subSeconds($seconds)->toDateTimeString()) - ->get() - ->toBase(); - } - - /** - * Returns a query filtering only non-failed backups for a specific server. - */ - public function getNonFailedBackups(Server $server): HasMany - { - return $server->backups()->where(function ($query) { - $query->whereNull('completed_at') - ->orWhere('is_successful', true); - }); - } - - /** - * Returns backups that are currently in progress for a specific server. - */ - public function getBackupsInProgress(int $serverId): Collection - { - return $this->getBuilder() - ->where('server_id', $serverId) - ->whereNull('completed_at') - ->get() - ->toBase(); - } -} diff --git a/app/Repositories/Elytra/ElytraRepository.php b/app/Repositories/Elytra/ElytraRepository.php new file mode 100644 index 000000000..08798f5b3 --- /dev/null +++ b/app/Repositories/Elytra/ElytraRepository.php @@ -0,0 +1,165 @@ +server = $server; + $this->setNode($this->server->node); + + return $this; + } + + /** + * Set the node model this request is stemming from. + */ + public function setNode(Node $node): self + { + $this->node = $node; + + return $this; + } + + /** + * Return an instance of the Guzzle HTTP Client to be used for requests. + */ + public function getHttpClient(array $headers = []): Client + { + Assert::isInstanceOf($this->node, Node::class); + + return new Client([ + 'verify' => $this->app->environment('production'), + 'base_uri' => $this->node->getConnectionAddress(), + 'timeout' => config('pterodactyl.guzzle.timeout'), + 'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'), + 'headers' => array_merge($headers, [ + 'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(), + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]), + ]); + } + + /** + * Create a new job on Elytra + * + * @throws DaemonConnectionException + */ + public function createJob(string $jobType, array $jobData): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient(['timeout' => 30])->post( + sprintf('/api/servers/%s/jobs', $this->server->uuid), + [ + 'json' => [ + 'job_type' => $jobType, + 'job_data' => $jobData, + ], + ] + ); + + return json_decode($response->getBody()->getContents(), true); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Gets the current status of a job from Elytra + * + * @throws DaemonConnectionException + */ + public function getJobStatus(string $jobId): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient(['timeout' => 5])->get( + sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId) + ); + + return json_decode($response->getBody()->getContents(), true); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Cancels a running job on Elytra + * + * @throws DaemonConnectionException + */ + public function cancelJob(string $jobId): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient(['timeout' => 5])->delete( + sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId) + ); + + return json_decode($response->getBody()->getContents(), true); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Update job status on Elytra + * + * @throws DaemonConnectionException + */ + public function updateJob(string $jobId, string $status, int $progress = 0, string $message = '', array $result = null): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $data = [ + 'status' => $status, + 'progress' => $progress, + 'message' => $message, + ]; + + if ($result !== null) { + $data['result'] = $result; + } + + $response = $this->getHttpClient(['timeout' => 5])->put( + sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId), + ['json' => $data] + ); + + return json_decode($response->getBody()->getContents(), true); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} \ No newline at end of file diff --git a/app/Repositories/Wings/DaemonBackupRepository.php b/app/Repositories/Wings/DaemonBackupRepository.php deleted file mode 100644 index 66590c718..000000000 --- a/app/Repositories/Wings/DaemonBackupRepository.php +++ /dev/null @@ -1,168 +0,0 @@ -adapter = $adapter; - - return $this; - } - - /** - * Tells the remote Daemon to begin generating a backup for the server (async). - * Returns the response which should contain a job_id for tracking. - * - * @throws DaemonConnectionException - */ - public function backup(Backup $backup): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - $adapterToSend = $this->adapter ?? config('backups.default'); - - return $this->getHttpClient(['timeout' => 10])->post( - sprintf('/api/servers/%s/backup', $this->server->uuid), - [ - 'json' => [ - 'adapter' => $adapterToSend, - 'uuid' => $backup->uuid, - 'ignore' => implode("\n", $backup->ignored_files), - ], - ] - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } - - /** - * Sends a request to Wings to begin restoring a backup for a server. - * Always truncates the directory for a clean restore. - * - * @throws DaemonConnectionException - */ - public function restore(Backup $backup, ?string $url = null): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - return $this->getHttpClient(['timeout' => 5])->post( - sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid), - [ - 'json' => [ - 'adapter' => $backup->disk, - 'truncate_directory' => true, - 'download_url' => $url ?? '', - ], - ] - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } - - /** - * Deletes a backup from the daemon (async). - * Returns the response which should contain a job_id for tracking. - * - * @throws DaemonConnectionException - */ - public function delete(Backup $backup): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - return $this->getHttpClient(['timeout' => 10])->delete( - sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid), - [ - 'json' => [ - 'adapter_type' => $backup->getElytraAdapterType(), - ], - ] - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } - - /** - * Gets the current status of a job from Elytra - * - * @throws DaemonConnectionException - */ - public function getJobStatus(string $jobId): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - return $this->getHttpClient(['timeout' => 5])->get( - sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId) - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } - - /** - * Cancels a running job on Elytra - * - * @throws DaemonConnectionException - */ - public function cancelJob(string $jobId): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - return $this->getHttpClient(['timeout' => 5])->delete( - sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId) - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } - - /** - * Lists all jobs for a server on Elytra - * - * @throws DaemonConnectionException - */ - public function listJobs(?string $status = null, ?string $type = null): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - $query = []; - if ($status) $query['status'] = $status; - if ($type) $query['type'] = $type; - - $url = sprintf('/api/servers/%s/jobs', $this->server->uuid); - if (!empty($query)) { - $url .= '?' . http_build_query($query); - } - - return $this->getHttpClient(['timeout' => 5])->get($url); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } -} diff --git a/app/Services/Backups/AsyncBackupService.php b/app/Services/Backups/AsyncBackupService.php deleted file mode 100644 index 3ae46a752..000000000 --- a/app/Services/Backups/AsyncBackupService.php +++ /dev/null @@ -1,343 +0,0 @@ -isLocked = $isLocked; - return $this; - } - - /** - * Set the files to be ignored by this backup - */ - public function setIgnoredFiles(?array $ignored): self - { - if (is_array($ignored)) { - $this->ignoredFiles = array_filter($ignored, fn($value) => strlen($value) > 0); - } else { - $this->ignoredFiles = []; - } - return $this; - } - - /** - * Initiate an async backup operation - * - * @throws \Throwable - * @throws TooManyBackupsException - * @throws TooManyRequestsHttpException - */ - public function initiate(Server $server, ?string $name = null, bool $override = false): Backup - { - // Validate server state before creating backup - $this->validateServerForBackup($server); - - // Check for existing backups in progress (only allow one at a time) - $inProgressBackups = $this->repository->getBackupsInProgress($server->id); - if ($inProgressBackups->count() > 0) { - throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.'); - } - - $successful = $this->repository->getNonFailedBackups($server); - - if (!$server->allowsBackups()) { - throw new TooManyBackupsException(0, 'Backups are disabled for this server'); - } - - // Block backup creation if already over storage limit - if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) { - $usage = $this->backupStorageService->getStorageUsageInfo($server); - throw new TooManyBackupsException(0, sprintf( - 'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.', - $usage['used_mb'], - $usage['limit_mb'] - )); - } - elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) { - if (!$override) { - throw new TooManyBackupsException($server->backup_limit); - } - - $oldest = $successful->where('is_locked', false)->orderBy('created_at')->first(); - if (!$oldest) { - throw new TooManyBackupsException($server->backup_limit); - } - - $this->deleteBackupService->handle($oldest); - } - - return $this->connection->transaction(function () use ($server, $name) { - $backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()); - $backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName); - $backupName = substr($backupName, 0, 191); // Limit to database field length - - $serverState = $this->serverStateService->captureServerState($server); - - // Use the configured default adapter - $adapter = $this->backupManager->getDefaultAdapter(); - - /** @var Backup $backup */ - $backup = $this->repository->create([ - 'server_id' => $server->id, - 'uuid' => Uuid::uuid4()->toString(), - 'name' => $backupName, - 'ignored_files' => array_values($this->ignoredFiles ?? []), - 'disk' => $adapter, - 'is_locked' => $this->isLocked, - 'server_state' => $serverState, - 'job_status' => Backup::JOB_STATUS_PENDING, - 'job_progress' => 0, - 'job_message' => 'Backup job queued', - ], true, true); - - try { - // Send async backup request to Elytra - $jobId = $this->requestAsyncBackup($server, $backup); - - // Update backup with job ID - $backup->update([ - 'job_id' => $jobId, - 'job_message' => 'Backup job submitted to Elytra', - ]); - - // Create job queue entry for tracking - BackupJobQueue::create([ - 'job_id' => $jobId, - 'backup_id' => $backup->id, - 'operation_type' => BackupJobQueue::OPERATION_CREATE, - 'status' => BackupJobQueue::STATUS_QUEUED, - 'job_data' => [ - 'adapter' => $backup->getElytraAdapterType(), - 'uuid' => $backup->uuid, - 'ignore' => implode("\n", $backup->ignored_files), - ], - 'expires_at' => CarbonImmutable::now()->addHours(6), // Backup jobs expire after 6 hours - ]); - - Log::info('Async backup initiated', [ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $server->uuid, - 'job_id' => $jobId, - 'adapter' => $backup->disk, - ]); - - } catch (\Exception $e) { - // If daemon backup request fails, clean up the backup record - $backup->delete(); - - Log::error('Failed to initiate async backup', [ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $server->uuid, - 'error' => $e->getMessage(), - ]); - - throw $e; - } - - return $backup; - }); - } - - /** - * Send async backup request to Elytra and return job ID - */ - private function requestAsyncBackup(Server $server, Backup $backup): string - { - try { - $response = $this->daemonBackupRepository->setServer($server) - ->setBackupAdapter($backup->getElytraAdapterType()) - ->backup($backup); - - $data = json_decode($response->getBody()->getContents(), true); - - if (!isset($data['job_id'])) { - throw new \Exception('Elytra response missing job_id field'); - } - - return $data['job_id']; - - } catch (RequestException $e) { - $response = $e->getResponse(); - $statusCode = $response ? $response->getStatusCode() : 0; - $responseBody = $response ? $response->getBody()->getContents() : ''; - - Log::error('Elytra backup request failed', [ - 'server_uuid' => $server->uuid, - 'backup_uuid' => $backup->uuid, - 'status_code' => $statusCode, - 'response' => $responseBody, - 'error' => $e->getMessage(), - ]); - - throw new \Exception("Failed to initiate backup on Elytra: HTTP {$statusCode} - " . $e->getMessage()); - } - } - - /** - * Cancel an async backup operation - */ - public function cancel(Backup $backup): bool - { - if (!$backup->canCancel()) { - return false; - } - - try { - // Send cancel request to Elytra - $this->daemonBackupRepository->setServer($backup->server); - $response = $this->daemonBackupRepository->cancelJob($backup->job_id); - - // Update backup status - $backup->updateJobStatus( - Backup::JOB_STATUS_CANCELLED, - $backup->job_progress, - 'Backup cancelled by user' - ); - - // Update job queue entry - $jobQueueEntry = BackupJobQueue::where('job_id', $backup->job_id)->first(); - if ($jobQueueEntry) { - $jobQueueEntry->update(['status' => BackupJobQueue::STATUS_CANCELLED]); - } - - Log::info('Backup cancelled', [ - 'backup_uuid' => $backup->uuid, - 'job_id' => $backup->job_id, - ]); - - return true; - - } catch (\Exception $e) { - Log::error('Failed to cancel backup', [ - 'backup_uuid' => $backup->uuid, - 'job_id' => $backup->job_id, - 'error' => $e->getMessage(), - ]); - - return false; - } - } - - /** - * Retry a failed backup - */ - public function retry(Backup $backup): bool - { - if (!$backup->canRetry()) { - return false; - } - - try { - // Reset backup status - $backup->updateJobStatus( - Backup::JOB_STATUS_PENDING, - 0, - 'Backup retry requested' - ); - - // Find and update job queue entry - $jobQueueEntry = BackupJobQueue::where('job_id', $backup->job_id)->first(); - if ($jobQueueEntry && $jobQueueEntry->canRetry()) { - $jobQueueEntry->markForRetry('Backup retry requested by user'); - } - - // Send new backup request to Elytra - $jobId = $this->requestAsyncBackup($backup->server, $backup); - - // Update backup with new job ID - $backup->update([ - 'job_id' => $jobId, - 'job_message' => 'Backup retry submitted to Elytra', - ]); - - // Create new job queue entry - BackupJobQueue::create([ - 'job_id' => $jobId, - 'backup_id' => $backup->id, - 'operation_type' => BackupJobQueue::OPERATION_CREATE, - 'status' => BackupJobQueue::STATUS_QUEUED, - 'job_data' => [ - 'adapter' => $backup->getElytraAdapterType(), - 'uuid' => $backup->uuid, - 'ignore' => implode("\n", $backup->ignored_files), - ], - 'retry_count' => $jobQueueEntry ? $jobQueueEntry->retry_count + 1 : 1, - 'expires_at' => CarbonImmutable::now()->addHours(6), - ]); - - Log::info('Backup retry initiated', [ - 'backup_uuid' => $backup->uuid, - 'old_job_id' => $jobQueueEntry?->job_id, - 'new_job_id' => $jobId, - ]); - - return true; - - } catch (\Exception $e) { - Log::error('Failed to retry backup', [ - 'backup_uuid' => $backup->uuid, - 'job_id' => $backup->job_id, - 'error' => $e->getMessage(), - ]); - - return false; - } - } - - /** - * Validate that the server is in a valid state for backup creation - */ - private function validateServerForBackup(Server $server): void - { - if ($server->isSuspended()) { - throw new TooManyBackupsException(0, 'Cannot create backup for suspended server.'); - } - - if (!$server->isInstalled()) { - throw new TooManyBackupsException(0, 'Cannot create backup for server that is not fully installed.'); - } - - if ($server->status === Server::STATUS_RESTORING_BACKUP) { - throw new TooManyBackupsException(0, 'Cannot create backup while server is restoring from another backup.'); - } - - if ($server->transfer) { - throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.'); - } - } -} \ No newline at end of file diff --git a/app/Services/Backups/BackupJobPollingService.php b/app/Services/Backups/BackupJobPollingService.php deleted file mode 100644 index 73e45ffb5..000000000 --- a/app/Services/Backups/BackupJobPollingService.php +++ /dev/null @@ -1,308 +0,0 @@ - 0, 'errors' => 0, 'completed' => 0]; - - // Get all jobs that need polling - $jobsToCheck = BackupJobQueue::needsPolling() - ->with(['backup.server']) - ->get(); - - if ($jobsToCheck->isEmpty()) { - return $results; - } - - Log::info('Polling backup job statuses', ['job_count' => $jobsToCheck->count()]); - - // Group jobs by server for efficient API calls - $jobsByServer = $jobsToCheck->groupBy('backup.server.uuid'); - - foreach ($jobsByServer as $serverUuid => $serverJobs) { - try { - $this->pollJobsForServer($serverJobs, $results); - } catch (\Exception $e) { - Log::error('Failed to poll jobs for server', [ - 'server_uuid' => $serverUuid, - 'error' => $e->getMessage(), - 'job_count' => $serverJobs->count(), - ]); - $results['errors'] += $serverJobs->count(); - } - } - - // Clean up expired jobs - $this->cleanupExpiredJobs(); - - return $results; - } - - /** - * Poll jobs for a specific server - */ - private function pollJobsForServer(Collection $serverJobs, array &$results): void - { - if ($serverJobs->isEmpty()) { - return; - } - - $server = $serverJobs->first()->backup->server; - $this->daemonRepository->setServer($server); - - foreach ($serverJobs as $jobQueue) { - try { - $this->pollSingleJob($jobQueue, $results); - } catch (\Exception $e) { - Log::error('Failed to poll single job', [ - 'job_id' => $jobQueue->job_id, - 'backup_uuid' => $jobQueue->backup->uuid, - 'error' => $e->getMessage(), - ]); - $results['errors']++; - - // Mark as failed if too many polling failures - if ($jobQueue->retry_count >= 5) { - $jobQueue->backup->updateJobStatus( - Backup::JOB_STATUS_FAILED, - null, - null, - 'Job polling failed repeatedly: ' . $e->getMessage() - ); - $jobQueue->markFailed('Job polling failed repeatedly'); - } - } - } - } - - /** - * Poll a single job and update its status - */ - private function pollSingleJob(BackupJobQueue $jobQueue, array &$results): void - { - try { - $response = $this->daemonRepository->getJobStatus($jobQueue->job_id); - $data = json_decode($response->getBody()->getContents(), true); - - if (!$this->isValidJobResponse($data)) { - throw new \Exception('Invalid job status response from Elytra'); - } - - $this->updateBackupFromJobStatus($jobQueue->backup, $data); - $this->updateJobQueueFromStatus($jobQueue, $data); - - $jobQueue->updateLastPolled(); - $results['updated']++; - - // Check if job completed - if (in_array($data['status'], ['completed', 'failed', 'cancelled'])) { - $results['completed']++; - Log::info('Backup job completed', [ - 'job_id' => $jobQueue->job_id, - 'backup_uuid' => $jobQueue->backup->uuid, - 'status' => $data['status'], - 'progress' => $data['progress'] ?? 0, - ]); - } - - } catch (RequestException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 404) { - // Job not found on Elytra - it may have been cleaned up - $this->handleJobNotFound($jobQueue); - $results['completed']++; - } else { - throw $e; - } - } - } - - /** - * Validate job response from Elytra - */ - private function isValidJobResponse(array $data): bool - { - return isset($data['job_id']) && - isset($data['status']) && - in_array($data['status'], ['pending', 'running', 'completed', 'failed', 'cancelled']); - } - - /** - * Update backup model based on job status from Elytra - */ - private function updateBackupFromJobStatus(Backup $backup, array $jobData): void - { - $status = $jobData['status']; - $progress = $jobData['progress'] ?? $backup->job_progress; - $message = $jobData['message'] ?? null; - $error = $jobData['error'] ?? null; - - // Map Elytra status to backup status - $backupStatus = match($status) { - 'pending' => Backup::JOB_STATUS_PENDING, - 'running' => Backup::JOB_STATUS_RUNNING, - 'completed' => Backup::JOB_STATUS_COMPLETED, - 'failed' => Backup::JOB_STATUS_FAILED, - 'cancelled' => Backup::JOB_STATUS_CANCELLED, - default => $backup->job_status, - }; - - // Update backup status - $backup->updateJobStatus($backupStatus, $progress, $message, $error); - - // For completed backups, update additional fields from job data - if ($status === 'completed' && isset($jobData['result'])) { - $result = $jobData['result']; - $updateData = []; - - if (isset($result['checksum'])) { - $updateData['checksum'] = $result['checksum']; - } - - if (isset($result['size'])) { - $updateData['bytes'] = (int) $result['size']; - } - - if (isset($result['snapshot_id'])) { - $updateData['snapshot_id'] = $result['snapshot_id']; - } - - if (!empty($updateData)) { - $backup->update($updateData); - } - } - } - - /** - * Update job queue status based on Elytra response - */ - private function updateJobQueueFromStatus(BackupJobQueue $jobQueue, array $jobData): void - { - $status = $jobData['status']; - - $queueStatus = match($status) { - 'pending' => BackupJobQueue::STATUS_QUEUED, - 'running' => BackupJobQueue::STATUS_PROCESSING, - 'completed' => BackupJobQueue::STATUS_COMPLETED, - 'failed' => BackupJobQueue::STATUS_FAILED, - 'cancelled' => BackupJobQueue::STATUS_CANCELLED, - default => $jobQueue->status, - }; - - if ($queueStatus !== $jobQueue->status) { - $errorMessage = isset($jobData['error']) ? $jobData['error'] : null; - - if ($queueStatus === BackupJobQueue::STATUS_COMPLETED) { - $jobQueue->markCompleted(); - } elseif ($queueStatus === BackupJobQueue::STATUS_FAILED) { - $jobQueue->markFailed($errorMessage ?? 'Job failed on Elytra'); - } else { - $jobQueue->update(['status' => $queueStatus]); - } - } - } - - /** - * Handle case where job is not found on Elytra - */ - private function handleJobNotFound(BackupJobQueue $jobQueue): void - { - // If backup is still pending/running, mark it as failed - if ($jobQueue->backup->isInProgress()) { - $jobQueue->backup->updateJobStatus( - Backup::JOB_STATUS_FAILED, - null, - null, - 'Job not found on Elytra - may have been cleaned up' - ); - } - - $jobQueue->markFailed('Job not found on Elytra'); - - Log::warning('Backup job not found on Elytra', [ - 'job_id' => $jobQueue->job_id, - 'backup_uuid' => $jobQueue->backup->uuid, - ]); - } - - /** - * Clean up expired jobs that are no longer relevant - */ - private function cleanupExpiredJobs(): int - { - $expiredJobs = BackupJobQueue::expired()->get(); - - if ($expiredJobs->isEmpty()) { - return 0; - } - - Log::info('Cleaning up expired backup jobs', ['count' => $expiredJobs->count()]); - - foreach ($expiredJobs as $jobQueue) { - // Mark associated backups as failed if still in progress - if ($jobQueue->backup->isInProgress()) { - $jobQueue->backup->updateJobStatus( - Backup::JOB_STATUS_FAILED, - null, - null, - 'Job expired - no response from Elytra' - ); - } - - $jobQueue->markFailed('Job expired'); - } - - return $expiredJobs->count(); - } - - /** - * Poll status for a specific backup - */ - public function pollBackupStatus(Backup $backup): bool - { - if (!$backup->job_id) { - return false; - } - - $jobQueue = BackupJobQueue::where('job_id', $backup->job_id) - ->where('backup_id', $backup->id) - ->first(); - - if (!$jobQueue) { - return false; - } - - try { - $this->daemonRepository->setServer($backup->server); - $results = ['updated' => 0, 'errors' => 0, 'completed' => 0]; - $this->pollSingleJob($jobQueue, $results); - return true; - } catch (\Exception $e) { - Log::error('Failed to poll backup status', [ - 'backup_uuid' => $backup->uuid, - 'job_id' => $backup->job_id, - 'error' => $e->getMessage(), - ]); - return false; - } - } -} \ No newline at end of file diff --git a/app/Services/Backups/BackupStorageService.php b/app/Services/Backups/BackupStorageService.php deleted file mode 100644 index 6fec44595..000000000 --- a/app/Services/Backups/BackupStorageService.php +++ /dev/null @@ -1,79 +0,0 @@ -repository->getNonFailedBackups($server)->sum('bytes'); - } - - public function isOverStorageLimit(Server $server): bool - { - if (!$server->hasBackupStorageLimit()) { - return false; - } - - return $this->calculateServerBackupStorage($server) > $server->getBackupStorageLimitBytes(); - } - - public function wouldExceedStorageLimit(Server $server, int $estimatedBackupSizeBytes): bool - { - if (!$server->hasBackupStorageLimit()) { - return false; - } - - $currentUsage = $this->calculateServerBackupStorage($server); - $estimatedSize = $estimatedBackupSizeBytes * 0.5; // Conservative estimate for deduplication - - return ($currentUsage + $estimatedSize) > $server->getBackupStorageLimitBytes(); - } - - public function getStorageUsageInfo(Server $server): array - { - $usedBytes = $this->calculateServerBackupStorage($server); - $limitBytes = $server->getBackupStorageLimitBytes(); - $mbDivisor = 1024 * 1024; - - $result = [ - 'used_bytes' => $usedBytes, - 'used_mb' => round($usedBytes / $mbDivisor, 2), - 'limit_bytes' => $limitBytes, - 'limit_mb' => $server->backup_storage_limit, - 'has_limit' => $server->hasBackupStorageLimit(), - ]; - - if ($limitBytes) { - $result['usage_percentage'] = round(($usedBytes / $limitBytes) * 100, 1); - $result['available_bytes'] = max(0, $limitBytes - $usedBytes); - $result['available_mb'] = round($result['available_bytes'] / $mbDivisor, 2); - $result['is_over_limit'] = $usedBytes > $limitBytes; - } - - return $result; - } - - public function getBackupsForStorageCleanup(Server $server): \Illuminate\Database\Eloquent\Collection - { - return $this->repository->getNonFailedBackups($server) - ->where('is_locked', false) - ->sortBy('created_at'); - } - - public function calculateStorageFreedByDeletion(\Illuminate\Database\Eloquent\Collection $backups): int - { - return (int) $backups->sum(function ($backup) { - return $backup->isRustic() ? $backup->bytes * 0.3 : $backup->bytes; - }); - } -} \ No newline at end of file diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php deleted file mode 100644 index d6e8c5775..000000000 --- a/app/Services/Backups/DeleteBackupService.php +++ /dev/null @@ -1,96 +0,0 @@ -is_locked && ($backup->is_successful && !is_null($backup->completed_at))) { - throw new BackupLockedException(); - } - - if ($backup->disk === Backup::ADAPTER_AWS_S3) { - $this->deleteFromS3($backup); - - return; - } - - $this->connection->transaction(function () use ($backup) { - try { - $this->daemonBackupRepository->setServer($backup->server)->delete($backup); - } catch (DaemonConnectionException $exception) { - $previous = $exception->getPrevious(); - // Don't fail the request if the Daemon responds with a 404, just assume the backup - // doesn't actually exist and remove its reference from the Panel as well. - if (!$previous instanceof ClientException || $previous->getResponse()->getStatusCode() !== Response::HTTP_NOT_FOUND) { - throw $exception; - } - } - - $backup->delete(); - }); - } - - /** - * Deletes a backup from an S3 disk. - * - * @throws \Throwable - */ - protected function deleteFromS3(Backup $backup): void - { - $this->connection->transaction(function () use ($backup) { - /** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */ - $adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3); - - $s3Key = sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid); - - // First delete from S3, then from database to prevent orphaned records - try { - $adapter->getClient()->deleteObject([ - 'Bucket' => $adapter->getBucket(), - 'Key' => $s3Key, - ]); - } catch (\Exception $e) { - // Log S3 deletion failure but continue with database cleanup - \Log::warning('Failed to delete backup from S3, continuing with database cleanup', [ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $backup->server->uuid, - 's3_key' => $s3Key, - 'error' => $e->getMessage(), - ]); - } - - // Delete from database after S3 cleanup - $backup->delete(); - }); - } -} diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php deleted file mode 100644 index 572199865..000000000 --- a/app/Services/Backups/InitiateBackupService.php +++ /dev/null @@ -1,178 +0,0 @@ -isLocked = $isLocked; - - return $this; - } - - - /** - * Sets the files to be ignored by this backup. - * - * @param string[]|null $ignored - */ - public function setIgnoredFiles(?array $ignored): self - { - if (is_array($ignored)) { - foreach ($ignored as $value) { - Assert::string($value); - } - } - - // Set the ignored files to be any values that are not empty in the array. Don't use - // the PHP empty function here incase anything that is "empty" by default (0, false, etc.) - // were passed as a file or folder name. - $this->ignoredFiles = is_null($ignored) ? [] : array_filter($ignored, function ($value) { - return strlen($value) > 0; - }); - - return $this; - } - - /** - * Initiates the backup process for a server on Wings. - * - * @throws \Throwable - * @throws TooManyBackupsException - * @throws TooManyRequestsHttpException - */ - public function handle(Server $server, ?string $name = null, bool $override = false): Backup - { - // Validate server state before creating backup - $this->validateServerForBackup($server); - - // Check for existing backups in progress (only allow one at a time) - $inProgressBackups = $this->repository->getBackupsInProgress($server->id); - if ($inProgressBackups->count() > 0) { - throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.'); - } - - $successful = $this->repository->getNonFailedBackups($server); - - if (!$server->allowsBackups()) { - throw new TooManyBackupsException(0, 'Backups are disabled for this server'); - } - - // Block backup creation if already over storage limit - if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) { - $usage = $this->backupStorageService->getStorageUsageInfo($server); - throw new TooManyBackupsException(0, sprintf( - 'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.', - $usage['used_mb'], - $usage['limit_mb'] - )); - } - elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) { - if (!$override) { - throw new TooManyBackupsException($server->backup_limit); - } - - $oldest = $successful->where('is_locked', false)->orderBy('created_at')->first(); - if (!$oldest) { - throw new TooManyBackupsException($server->backup_limit); - } - - $this->deleteBackupService->handle($oldest); - } - - return $this->connection->transaction(function () use ($server, $name) { - $backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()); - $backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName); - $backupName = substr($backupName, 0, 191); // Limit to database field length - - $serverState = $this->serverStateService->captureServerState($server); - - // Use the configured default adapter - $adapter = $this->backupManager->getDefaultAdapter(); - - - /** @var Backup $backup */ - $backup = $this->repository->create([ - 'server_id' => $server->id, - 'uuid' => Uuid::uuid4()->toString(), - 'name' => $backupName, - 'ignored_files' => array_values($this->ignoredFiles ?? []), - 'disk' => $adapter, - 'is_locked' => $this->isLocked, - 'server_state' => $serverState, - ], true, true); - - try { - $this->daemonBackupRepository->setServer($server) - ->setBackupAdapter($adapter) - ->backup($backup); - } catch (\Exception $e) { - // If daemon backup request fails, clean up the backup record - $backup->delete(); - throw $e; - } - - return $backup; - }); - } - - /** - * Validate that the server is in a valid state for backup creation - */ - private function validateServerForBackup(Server $server): void - { - if ($server->isSuspended()) { - throw new TooManyBackupsException(0, 'Cannot create backup for suspended server.'); - } - - if (!$server->isInstalled()) { - throw new TooManyBackupsException(0, 'Cannot create backup for server that is not fully installed.'); - } - - if ($server->status === Server::STATUS_RESTORING_BACKUP) { - throw new TooManyBackupsException(0, 'Cannot create backup while server is restoring from another backup.'); - } - - if ($server->transfer) { - throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.'); - } - } - -} diff --git a/app/Services/Elytra/ElytraJobService.php b/app/Services/Elytra/ElytraJobService.php new file mode 100644 index 000000000..e1f5f55d3 --- /dev/null +++ b/app/Services/Elytra/ElytraJobService.php @@ -0,0 +1,303 @@ +discoverHandlers(); + } + + private function discoverHandlers(): void + { + $handlerClasses = [ + \Pterodactyl\Services\Elytra\Jobs\BackupJob::class, + ]; + + foreach ($handlerClasses as $handlerClass) { + if (class_exists($handlerClass)) { + $handler = app($handlerClass); + if ($handler instanceof Job) { + foreach ($handler::getSupportedJobTypes() as $jobType) { + $this->jobHandlers[$jobType] = $handler; + } + } + } + } + } + + public function registerJobHandler(Job $handler): void + { + foreach ($handler::getSupportedJobTypes() as $jobType) { + $this->jobHandlers[$jobType] = $handler; + } + } + + public function submitJob(Server $server, string $jobType, array $jobData, User $user): array + { + $handler = $this->getJobHandler($jobType); + $validatedData = $handler->validateJobData($jobData); + $job = ElytraJob::create([ + 'server_id' => $server->id, + 'user_id' => $user->id, + 'job_type' => $jobType, + 'job_data' => $validatedData, + 'status' => ElytraJob::STATUS_PENDING, + 'progress' => 0, + 'created_at' => CarbonImmutable::now(), + ]); + + try { + $elytraJobId = $handler->submitToElytra($server, $job, $this->elytraRepository); + $job->update([ + 'elytra_job_id' => $elytraJobId, + 'status' => ElytraJob::STATUS_SUBMITTED, + 'submitted_at' => CarbonImmutable::now(), + ]); + + return [ + 'job_id' => $job->uuid, + 'elytra_job_id' => $elytraJobId, + 'status' => 'submitted', + 'message' => 'Job submitted to Elytra successfully', + 'data' => $handler->formatJobResponse($job), + ]; + + } catch (\Exception $e) { + $job->update([ + 'status' => ElytraJob::STATUS_FAILED, + 'error_message' => $e->getMessage(), + 'completed_at' => CarbonImmutable::now(), + ]); + + Log::error('Failed to submit job to Elytra', [ + 'job_id' => $job->uuid, + 'job_type' => $jobType, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + public function getJobStatus(Server $server, string $jobId): ?array + { + $job = ElytraJob::where('uuid', $jobId) + ->where('server_id', $server->id) + ->first(); + + if (!$job) { + return null; + } + + $handler = $this->getJobHandler($job->job_type); + + return [ + 'job_id' => $job->uuid, + 'elytra_job_id' => $job->elytra_job_id, + 'type' => $job->job_type, + 'status' => $job->status, + 'progress' => $job->progress, + 'message' => $job->status_message, + 'error' => $job->error_message, + 'created_at' => $job->created_at, + 'submitted_at' => $job->submitted_at, + 'completed_at' => $job->completed_at, + 'data' => $handler->formatJobResponse($job), + ]; + } + + public function cancelJob(Server $server, string $jobId): array + { + $job = ElytraJob::where('uuid', $jobId) + ->where('server_id', $server->id) + ->first(); + + if (!$job) { + throw new \Exception('Job not found'); + } + + if (!in_array($job->status, [ElytraJob::STATUS_PENDING, ElytraJob::STATUS_SUBMITTED, ElytraJob::STATUS_RUNNING])) { + throw new \Exception('Job cannot be cancelled in current status'); + } + + $handler = $this->getJobHandler($job->job_type); + + try { + $handler->cancelOnElytra($server, $job, $this->elytraRepository); + $job->update([ + 'status' => ElytraJob::STATUS_CANCELLED, + 'completed_at' => CarbonImmutable::now(), + ]); + + return [ + 'job_id' => $job->uuid, + 'status' => 'cancelled', + 'message' => 'Job cancelled successfully', + ]; + + } catch (\Exception $e) { + Log::error('Failed to cancel job on Elytra', [ + 'job_id' => $job->uuid, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + public function listJobs(Server $server, ?string $jobType = null, ?string $status = null): array + { + $query = ElytraJob::where('server_id', $server->id); + + if ($jobType) { + $query->where('job_type', $jobType); + } + + if ($status) { + $query->where('status', $status); + } + + $jobs = $query->orderBy('created_at', 'desc')->get(); + + return $jobs->map(function ($job) { + $handler = $this->getJobHandler($job->job_type); + + return [ + 'job_id' => $job->uuid, + 'elytra_job_id' => $job->elytra_job_id, + 'type' => $job->job_type, + 'status' => $job->status, + 'progress' => $job->progress, + 'message' => $job->status_message, + 'error' => $job->error_message, + 'created_at' => $job->created_at, + 'submitted_at' => $job->submitted_at, + 'completed_at' => $job->completed_at, + 'data' => $handler->formatJobResponse($job), + ]; + })->toArray(); + } + + public function updateJobStatus(string $elytraJobId, array $statusData): void + { + $job = ElytraJob::where('elytra_job_id', $elytraJobId)->first(); + + if (!$job) { + Log::warning('Received status update for unknown job', [ + 'elytra_job_id' => $elytraJobId, + ]); + return; + } + + $server = $job->server; + $currentStatus = $job->status; + $newStatus = $statusData['status'] ?? 'unknown'; + $job->update([ + 'status' => $newStatus, + 'progress' => $statusData['progress'] ?? $job->progress, + 'status_message' => $statusData['message'] ?? null, + 'error_message' => $statusData['error_message'] ?? null, + ]); + + if ($newStatus === 'completed' || $newStatus === 'failed') { + $handler = $this->getJobHandler($job->job_type); + $handler->processStatusUpdate($job, $statusData); + } + + Log::info('Job status updated', [ + 'job_id' => $job->uuid, + 'elytra_job_id' => $elytraJobId, + 'job_type' => $job->job_type, + 'old_status' => $currentStatus, + 'new_status' => $newStatus, + 'progress' => $statusData['progress'] ?? 0, + ]); + } + + + private function fireJobStatusEvent(ElytraJob $job, array $statusData): void + { + $server = $job->server; + $eventName = $this->getJobEventName($job->job_type, $job->status); + + $eventData = [ + 'job_id' => $job->uuid, + 'elytra_job_id' => $job->elytra_job_id, + 'job_type' => $job->job_type, + 'status' => $job->status, + 'progress' => $job->progress, + 'message' => $job->status_message, + 'error' => $job->error_message, + ]; + + if ($job->job_type === 'backup_create') { + if (isset($statusData['checksum'])) { + $eventData['checksum'] = $statusData['checksum']; + $eventData['checksum_type'] = $statusData['checksum_type'] ?? 'sha1'; + } + if (isset($statusData['size'])) { + $eventData['file_size'] = $statusData['size']; + } + if (isset($statusData['snapshot_id'])) { + $eventData['snapshot_id'] = $statusData['snapshot_id']; + } + + if ($job->job_data && isset($job->job_data['backup_uuid'])) { + $eventData['uuid'] = $job->job_data['backup_uuid']; + } + } + + $server->events()->publish($eventName, json_encode($eventData)); + + Log::debug('Fired WebSocket event for job status', [ + 'event' => $eventName, + 'server' => $server->uuid, + 'job_id' => $job->uuid, + 'status' => $job->status, + ]); + } + + private function getJobEventName(string $jobType, string $status): string + { + return match ([$jobType, $status]) { + ['backup_create', 'pending'] => 'backup.started', + ['backup_create', 'running'] => 'backup.progress', + ['backup_create', 'completed'] => 'backup.completed', + ['backup_create', 'failed'] => 'backup.failed', + + ['backup_delete', 'pending'] => 'backup.delete.started', + ['backup_delete', 'running'] => 'backup.delete.progress', + ['backup_delete', 'completed'] => 'backup.delete.completed', + ['backup_delete', 'failed'] => 'backup.delete.failed', + + ['backup_restore', 'pending'] => 'backup.restore.started', + ['backup_restore', 'running'] => 'backup.restore.progress', + ['backup_restore', 'completed'] => 'backup.restore.completed', + ['backup_restore', 'failed'] => 'backup.restore.failed', + + default => "job.{$status}", + }; + } + + public function getJobHandler(string $jobType): Job + { + if (!isset($this->jobHandlers[$jobType])) { + throw new \Exception("No handler registered for job type: {$jobType}"); + } + + return $this->jobHandlers[$jobType]; + } +} \ No newline at end of file diff --git a/app/Services/Elytra/Jobs/BackupJob.php b/app/Services/Elytra/Jobs/BackupJob.php new file mode 100644 index 000000000..bc98cdc73 --- /dev/null +++ b/app/Services/Elytra/Jobs/BackupJob.php @@ -0,0 +1,345 @@ + [Permission::ACTION_BACKUP_READ], + 'create' => [Permission::ACTION_BACKUP_CREATE], + 'show' => [Permission::ACTION_BACKUP_READ], + 'cancel' => [Permission::ACTION_BACKUP_DELETE], + default => [], + }; + } + + public function validateJobData(array $jobData): array + { + $rules = match ($jobData['operation'] ?? '') { + 'create' => [ + 'operation' => 'required|string|in:create', + 'adapter' => 'nullable|string', + 'ignored' => 'nullable|string', + 'name' => 'nullable|string|max:255', + ], + 'delete' => [ + 'operation' => 'required|string|in:delete', + 'backup_uuid' => 'required|string|uuid', + ], + 'restore' => [ + 'operation' => 'required|string|in:restore', + 'backup_uuid' => 'required|string|uuid', + 'truncate_directory' => 'boolean', + ], + 'download' => [ + 'operation' => 'required|string|in:download', + 'backup_uuid' => 'required|string|uuid', + ], + default => throw new \Exception('Invalid or missing operation'), + }; + + $validator = Validator::make($jobData, $rules); + + if ($validator->fails()) { + throw new \Exception('Invalid job data: ' . implode(', ', $validator->errors()->all())); + } + + return $validator->validated(); + } + + public function submitToElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string + { + $jobData = $job->job_data; + $operation = $jobData['operation']; + + return match ($operation) { + 'create' => $this->submitCreateJob($server, $job, $elytraRepository), + 'delete' => $this->submitDeleteJob($server, $job, $elytraRepository), + 'restore' => $this->submitRestoreJob($server, $job, $elytraRepository), + 'download' => $this->submitDownloadJob($server, $job, $elytraRepository), + default => throw new \Exception("Unsupported backup operation: {$operation}"), + }; + } + + public function cancelOnElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): void + { + if (!$job->elytra_job_id) { + throw new \Exception('No Elytra job ID to cancel'); + } + + $elytraRepository->setServer($server)->cancelJob($job->elytra_job_id); + } + + public function processStatusUpdate(ElytraJob $job, array $statusData): void + { + $successful = $statusData['successful'] ?? false; + $jobType = $statusData['job_type'] ?? ''; + $operation = $this->getOperationFromJobType($jobType); + + $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'), + 'completed_at' => CarbonImmutable::now(), + ]); + + match ($operation) { + 'create' => $this->handleCreateCompletion($job, $statusData), + 'delete' => $this->handleDeleteCompletion($job, $statusData), + 'restore' => $this->handleRestoreCompletion($job, $statusData), + 'download' => $this->handleDownloadCompletion($job, $statusData), + default => Log::warning("Unknown backup operation for status update: {$operation}"), + }; + } + + public function formatJobResponse(ElytraJob $job): array + { + $jobData = $job->job_data; + $operation = $jobData['operation'] ?? 'unknown'; + + $response = [ + 'operation' => $operation, + ]; + + if (isset($jobData['backup_uuid'])) { + $backup = Backup::where('uuid', $jobData['backup_uuid'])->first(); + if ($backup) { + $response['backup'] = $this->backupTransformer->transform($backup); + } + } + + match ($operation) { + 'create' => $response = array_merge($response, [ + 'adapter' => $jobData['adapter'] ?? null, + 'ignored' => $jobData['ignored'] ?? null, + 'name' => $jobData['name'] ?? null, + ]), + 'restore' => $response = array_merge($response, [ + 'truncate_directory' => $jobData['truncate_directory'] ?? false, + ]), + default => null, + }; + + return $response; + } + + private function submitCreateJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string + { + $jobData = $job->job_data; + + $backupUuid = $this->generateBackupUuid(); + + $job->update([ + 'job_data' => array_merge($jobData, ['backup_uuid' => $backupUuid]), + ]); + + $elytraJobData = [ + 'server_id' => $server->uuid, + 'backup_uuid' => $backupUuid, + 'name' => $jobData['name'] ?? $this->generateBackupName(), + 'ignore' => $jobData['ignored'] ?? '', + 'adapter_type' => $jobData['adapter'] ?? 'elytra', + ]; + + Log::info("Submitting backup creation job to Elytra", [ + 'server_id' => $server->id, + 'backup_uuid' => $backupUuid, + 'job_data' => $elytraJobData, + ]); + + $response = $elytraRepository->setServer($server)->createJob('backup_create', $elytraJobData); + + return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra'); + } + + private function submitDeleteJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string + { + $jobData = $job->job_data; + $backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail(); + + $elytraJobData = [ + 'server_id' => $server->uuid, + 'backup_uuid' => $backup->uuid, + 'adapter_type' => $backup->getElytraAdapterType(), + ]; + + $response = $elytraRepository->setServer($server)->createJob('backup_delete', $elytraJobData); + + return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra'); + } + + private function submitRestoreJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string + { + $jobData = $job->job_data; + $backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail(); + + $elytraJobData = [ + 'server_id' => $server->uuid, + 'backup_uuid' => $backup->uuid, + 'adapter_type' => $backup->getElytraAdapterType(), + 'truncate_directory' => $jobData['truncate_directory'] ?? false, + 'download_url' => $jobData['download_url'] ?? null, + ]; + + $response = $elytraRepository->setServer($server)->createJob('backup_restore', $elytraJobData); + + return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra'); + } + + private function submitDownloadJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string + { + throw new \Exception('Download jobs not yet implemented'); + } + + private function handleCreateCompletion(ElytraJob $job, array $statusData): void + { + $jobData = $job->job_data; + $backupUuid = $jobData['backup_uuid'] ?? null; + + if (!$backupUuid) { + Log::error("No backup UUID in job data for completed backup job", ['job_id' => $job->id]); + return; + } + + if ($statusData['successful']) { + $server = $job->server; + + $actualAdapter = $this->mapElytraAdapterToModel($statusData['adapter'] ?? 'rustic_local'); + + $backupData = [ + 'server_id' => $server->id, + 'uuid' => $backupUuid, + 'name' => $jobData['name'] ?? $this->generateBackupName(), + 'ignored_files' => $this->parseIgnoredFiles($jobData['ignored'] ?? ''), + 'disk' => $actualAdapter, + 'is_successful' => true, + 'is_locked' => false, + 'checksum' => ($statusData['checksum_type'] ?? 'sha1') . ':' . ($statusData['checksum'] ?? ''), + 'bytes' => $statusData['size'] ?? 0, + 'snapshot_id' => $statusData['snapshot_id'] ?? null, + 'completed_at' => CarbonImmutable::now(), + ]; + + $serverState = null; + try { + $serverState = $this->serverStateService->captureServerState($server); + } catch (\Exception $e) { + Log::warning("Could not capture server state for backup", [ + 'backup_uuid' => $backupUuid, + 'error' => $e->getMessage(), + ]); + } + + if ($serverState) { + $backupData['server_state'] = $serverState; + } + + $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), + ]); + } else { + Log::error("Backup job failed", [ + 'backup_uuid' => $backupUuid, + 'error' => $statusData['error_message'] ?? 'Unknown error', + 'job_id' => $job->id, + ]); + } + } + + private function handleDeleteCompletion(ElytraJob $job, array $statusData): void + { + if ($statusData['successful']) { + $jobData = $job->job_data; + $backup = Backup::where('uuid', $jobData['backup_uuid'])->first(); + + if ($backup) { + $backup->delete(); + } + } + } + + private function handleRestoreCompletion(ElytraJob $job, array $statusData): void + { + } + + private function handleDownloadCompletion(ElytraJob $job, array $statusData): void + { + } + + private function getOperationFromJobType(string $jobType): string + { + return match ($jobType) { + 'backup_create' => 'create', + 'backup_delete' => 'delete', + 'backup_restore' => 'restore', + 'backup_download' => 'download', + default => 'unknown', + }; + } + + private function generateBackupUuid(): string + { + return (string) \Illuminate\Support\Str::uuid(); + } + + private function generateBackupName(): string + { + return 'Backup at ' . now()->format('Y-m-d Hi'); + } + + private function mapElytraAdapterToModel(string $elytraAdapter): string + { + return match ($elytraAdapter) { + 'elytra', 'local' => Backup::ADAPTER_RUSTIC_LOCAL, + 'rustic_local' => Backup::ADAPTER_RUSTIC_LOCAL, + 'rustic_s3' => Backup::ADAPTER_RUSTIC_S3, + 's3' => Backup::ADAPTER_RUSTIC_S3, + 'wings' => Backup::ADAPTER_WINGS, + default => Backup::ADAPTER_RUSTIC_LOCAL, + }; + } + + private function parseIgnoredFiles(string $ignored): array + { + if (empty($ignored)) { + return []; + } + + $files = array_filter( + array_map('trim', explode("\n", $ignored)), + fn($line) => !empty($line) + ); + + return array_values($files); + } +} \ No newline at end of file diff --git a/app/Services/Servers/ServerDeletionService.php b/app/Services/Servers/ServerDeletionService.php index 01d463094..1dbeb86cd 100644 --- a/app/Services/Servers/ServerDeletionService.php +++ b/app/Services/Servers/ServerDeletionService.php @@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Log; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Services\Databases\DatabaseManagementService; -use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Exceptions\Service\Backup\BackupLockedException; @@ -23,7 +22,6 @@ class ServerDeletionService private ConnectionInterface $connection, private DaemonServerRepository $daemonServerRepository, private DatabaseManagementService $databaseManagementService, - private DeleteBackupService $deleteBackupService, ) { } @@ -63,29 +61,11 @@ class ServerDeletionService // Delete all backups associated with this server foreach ($server->backups as $backup) { try { - $this->deleteBackupService->handle($backup); - } catch (BackupLockedException $exception) { - // If the backup is locked, unlock it and try again since we're deleting the entire server - $backup->update(['is_locked' => false]); - - try { - $this->deleteBackupService->handle($backup); - } catch (\Exception $retryException) { - if (!$this->force) { - throw $retryException; - } - - // If we still can't delete the backup from storage, at least remove the database record - // to prevent orphaned backup entries - $backup->delete(); - - Log::warning('Failed to delete unlocked backup during server deletion', [ - 'backup_id' => $backup->id, - 'backup_uuid' => $backup->uuid, - 'server_id' => $server->id, - 'exception' => $retryException->getMessage(), - ]); - } + // Simply delete the backup record + // note: this used to be more complex but Elytra's changes have made a lot of logic here redundant + // so this whole thing really needs a refactor now. THAT BEING SAID I HAVE NOT TESTED LOCAL IN A MINUTE! + // - ellie + $backup->delete(); } catch (\Exception $exception) { if (!$this->force) { throw $exception; diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index a094448fc..4c4c9eaed 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -33,17 +33,6 @@ class BackupTransformer extends BaseClientTransformer 'snapshot_id' => $backup->snapshot_id, 'created_at' => $backup->created_at->toAtomString(), 'completed_at' => $backup->completed_at ? $backup->completed_at->toAtomString() : null, - // Async job fields - 'job_id' => $backup->job_id, - 'job_status' => $backup->job_status, - 'job_progress' => $backup->job_progress, - 'job_message' => $backup->job_message, - 'job_error' => $backup->job_error, - 'job_started_at' => $backup->job_started_at ? $backup->job_started_at->toAtomString() : null, - 'job_last_updated_at' => $backup->job_last_updated_at ? $backup->job_last_updated_at->toAtomString() : null, - 'can_cancel' => $backup->canCancel(), - 'can_retry' => $backup->canRetry(), - 'is_in_progress' => $backup->isInProgress(), ]; // Add server state information if available diff --git a/config/backups.php b/config/backups.php index cedfa429c..bc315ba02 100644 --- a/config/backups.php +++ b/config/backups.php @@ -6,7 +6,7 @@ return [ // The backup driver to use for this Panel instance. All client generated server backups // will be stored in this location by default. It is possible to change this once backups // have been made, without losing data. - // Options: wings, s3, rustic_local, rustic_s3 + // Options: elytra, wings (legacy), s3, rustic_local, rustic_s3 'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL), // This value is used to determine the lifespan of UploadPart presigned urls that wings @@ -29,6 +29,11 @@ return [ 'adapter' => Backup::ADAPTER_WINGS, ], + // Elytra local backups (preferred over wings) + 'elytra' => [ + 'adapter' => Backup::ADAPTER_ELYTRA, + ], + // Configuration for storing backups in Amazon S3. This uses the same credentials // specified in filesystems.php but does include some more specific settings for // backups, notably bucket, location, and use_accelerate_endpoint. diff --git a/database/migrations/2025_09_26_000000_add_async_backup_jobs_support.php b/database/migrations/2025_09_26_000000_add_async_backup_jobs_support.php deleted file mode 100644 index de66615f6..000000000 --- a/database/migrations/2025_09_26_000000_add_async_backup_jobs_support.php +++ /dev/null @@ -1,89 +0,0 @@ -string('job_id')->nullable()->after('uuid')->index()->comment('Elytra job ID for async operations'); - $table->enum('job_status', ['pending', 'running', 'completed', 'failed', 'cancelled']) - ->default('pending') - ->after('job_id') - ->comment('Current status of the backup job'); - $table->tinyInteger('job_progress')->default(0)->after('job_status')->comment('Job progress percentage (0-100)'); - $table->text('job_message')->nullable()->after('job_progress')->comment('Current job status message'); - $table->text('job_error')->nullable()->after('job_message')->comment('Error message if job failed'); - $table->timestamp('job_started_at')->nullable()->after('job_error')->comment('When the job started processing'); - $table->timestamp('job_last_updated_at')->nullable()->after('job_started_at')->comment('Last job status update'); - - // Add indexes for efficient querying - $table->index(['server_id', 'job_status'], 'backups_server_job_status_index'); - $table->index(['job_status', 'job_last_updated_at'], 'backups_job_status_updated_index'); - }); - - // Create backup job queue table for tracking operations that need retry/cleanup - Schema::create('backup_job_queue', function (Blueprint $table) { - $table->id(); - $table->string('job_id')->unique()->comment('Elytra job ID'); - $table->unsignedBigInteger('backup_id')->index()->comment('Associated backup record'); - $table->enum('operation_type', ['create', 'delete', 'restore'])->comment('Type of backup operation'); - $table->enum('status', ['queued', 'processing', 'completed', 'failed', 'cancelled', 'retry'])->default('queued'); - $table->json('job_data')->nullable()->comment('Original job request data'); - $table->text('error_message')->nullable()->comment('Error details if failed'); - $table->tinyInteger('retry_count')->default(0)->comment('Number of retry attempts'); - $table->timestamp('last_polled_at')->nullable()->comment('Last time job status was checked'); - $table->timestamp('expires_at')->nullable()->comment('When to stop polling for this job'); - $table->timestamps(); - - $table->foreign('backup_id')->references('id')->on('backups')->onDelete('cascade'); - $table->index(['status', 'last_polled_at'], 'job_queue_status_polled_index'); - $table->index(['expires_at'], 'job_queue_expires_index'); - }); - - // Update existing backups to have default job status - DB::table('backups')->update([ - 'job_status' => DB::raw('CASE - WHEN is_successful = 1 AND completed_at IS NOT NULL THEN "completed" - WHEN is_successful = 0 AND completed_at IS NOT NULL THEN "failed" - ELSE "completed" - END'), - 'job_progress' => DB::raw('CASE - WHEN is_successful = 1 AND completed_at IS NOT NULL THEN 100 - WHEN is_successful = 0 AND completed_at IS NOT NULL THEN 0 - ELSE 100 - END'), - 'job_last_updated_at' => DB::raw('COALESCE(completed_at, updated_at)') - ]); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('backup_job_queue'); - - Schema::table('backups', function (Blueprint $table) { - $table->dropIndex('backups_server_job_status_index'); - $table->dropIndex('backups_job_status_updated_index'); - $table->dropColumn([ - 'job_id', - 'job_status', - 'job_progress', - 'job_message', - 'job_error', - 'job_started_at', - 'job_last_updated_at' - ]); - }); - } -} \ No newline at end of file diff --git a/database/migrations/2025_09_27_101200_create_elytra_jobs_table.php b/database/migrations/2025_09_27_101200_create_elytra_jobs_table.php new file mode 100644 index 000000000..b96374296 --- /dev/null +++ b/database/migrations/2025_09_27_101200_create_elytra_jobs_table.php @@ -0,0 +1,44 @@ +id(); + $table->uuid()->unique(); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('user_id'); + + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + $table->string('job_type'); // backup_create, backup_delete, etc. + $table->json('job_data'); // Operation-specific data + $table->string('status')->default('pending'); // pending, submitted, running, completed, failed, cancelled + $table->integer('progress')->default(0); // 0-100 + $table->text('status_message')->nullable(); + $table->text('error_message')->nullable(); + $table->string('elytra_job_id')->nullable(); // Job ID from Elytra daemon + + $table->timestampTz('created_at'); + $table->timestampTz('submitted_at')->nullable(); + $table->timestampTz('completed_at')->nullable(); + $table->timestampTz('updated_at'); + + $table->index(['server_id', 'status']); + $table->index(['server_id', 'job_type']); + $table->index(['elytra_job_id']); + $table->index(['status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('elytra_jobs'); + } +}; \ No newline at end of file diff --git a/resources/scripts/api/server/backups/cancelBackup.ts b/resources/scripts/api/server/backups/cancelBackup.ts deleted file mode 100644 index d7e29b4cd..000000000 --- a/resources/scripts/api/server/backups/cancelBackup.ts +++ /dev/null @@ -1,12 +0,0 @@ -import http from '@/api/http'; - -export interface CancelBackupResponse { - message: string; - status: string; -} - -export default async (uuid: string, backupUuid: string): Promise => { - const { data } = await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/cancel`); - - return data; -}; \ No newline at end of file diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index 241e86cf4..2bc5c8384 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -25,11 +25,69 @@ export default async (uuid: string, params: RequestParameters): Promise<{ backup is_locked: params.isLocked, }); - return { - backup: rawDataToServerBackup(response.data.data), - jobId: response.data.meta.job_id, - status: response.data.meta.status, - progress: response.data.meta.progress, - message: response.data.meta.message, - }; + if (!response.data) { + throw new Error('Invalid response: missing data'); + } + + if (response.data.data && response.data.meta) { + const backupData = rawDataToServerBackup(response.data.data); + + return { + backup: backupData, + jobId: response.data.meta.job_id, + status: response.data.meta.status, + progress: response.data.meta.progress, + message: response.data.meta.message, + }; + } + + if (response.data.job_id && response.data.status) { + + // Create a minimal backup object for the async job + // note: I really don't like this implementation but I really can't be fucked right now to do this better - ellie + const tempBackup: ServerBackup = { + uuid: '', // Will be filled when WebSocket events arrive + name: params.name || 'Pending...', + isSuccessful: false, + isLocked: params.isLocked, + checksum: '', + bytes: 0, + createdAt: new Date(), + completedAt: null, + canRetry: false, + jobStatus: response.data.status, + jobProgress: 0, + jobMessage: response.data.message || '', + jobId: response.data.job_id, + jobError: null, + object: 'backup' + }; + + return { + backup: tempBackup, + jobId: response.data.job_id, + status: response.data.status, + progress: 0, + message: response.data.message || '', + }; + } + + if (response.data.uuid || response.data.object === 'backup') { + + try { + const backupData = rawDataToServerBackup(response.data); + + return { + backup: backupData, + jobId: backupData.jobId || '', + status: backupData.jobStatus || 'pending', + progress: backupData.jobProgress || 0, + message: backupData.jobMessage || '', + }; + } catch (transformError) { + throw new Error(`Failed to process backup response: ${transformError.message}`); + } + } + + throw new Error('Invalid response: unknown structure'); }; diff --git a/resources/scripts/api/server/backups/deleteServerBackup.ts b/resources/scripts/api/server/backups/deleteServerBackup.ts index 1d9d0b0ca..3cbf809fb 100644 --- a/resources/scripts/api/server/backups/deleteServerBackup.ts +++ b/resources/scripts/api/server/backups/deleteServerBackup.ts @@ -1,5 +1,17 @@ import http from '@/api/http'; -export default async (uuid: string, backup: string): Promise => { - await http.delete(`/api/client/servers/${uuid}/backups/${backup}`); +interface DeleteBackupResponse { + job_id: string; + status: string; + message: string; +} + +export default async (uuid: string, backup: string): Promise<{ jobId: string; status: string; message: string }> => { + const response = await http.delete(`/api/client/servers/${uuid}/backups/${backup}`); + + return { + jobId: response.data.job_id, + status: response.data.status, + message: response.data.message, + }; }; diff --git a/resources/scripts/api/server/backups/index.ts b/resources/scripts/api/server/backups/index.ts index 32a386a28..9eb24dcf3 100644 --- a/resources/scripts/api/server/backups/index.ts +++ b/resources/scripts/api/server/backups/index.ts @@ -1,13 +1,28 @@ import http from '@/api/http'; -export const restoreServerBackup = async (uuid: string, backup: string): Promise => { - await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, {}); +interface RestoreBackupResponse { + job_id: string; + status: string; + message: string; +} + +export const restoreServerBackup = async (uuid: string, backup: string): Promise<{ jobId: string; status: string; message: string }> => { + const response = await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, { + adapter: 'rustic_s3', + truncate_directory: true, + download_url: '' + }); + + return { + jobId: response.data.job_id, + status: response.data.status, + message: response.data.message, + }; }; export { default as createServerBackup } from './createServerBackup'; export { default as deleteServerBackup } from './deleteServerBackup'; export { default as getServerBackupDownloadUrl } from './getServerBackupDownloadUrl'; export { default as renameServerBackup } from './renameServerBackup'; -export { default as getBackupStatus } from './getBackupStatus'; -export { default as cancelBackup } from './cancelBackup'; export { default as retryBackup } from './retryBackup'; +export type { BackupJobStatus } from './getBackupStatus'; diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index d9016eb9a..eca25dec9 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -22,13 +22,12 @@ export interface ServerBackup { completedAt: Date | null; // Async job fields jobId: string | null; - jobStatus: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + jobStatus: 'pending' | 'running' | 'completed' | 'failed'; jobProgress: number; jobMessage: string | null; jobError: string | null; jobStartedAt: Date | null; jobLastUpdatedAt: Date | null; - canCancel: boolean; canRetry: boolean; isInProgress: boolean; } diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts index 02076d090..4a6d845ac 100644 --- a/resources/scripts/api/swr/getServerBackups.ts +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -47,5 +47,8 @@ export default () => { storage: data.meta.storage, limits: data.meta.limits, }; + }, { + revalidateOnFocus: false, + revalidateOnReconnect: true, }); }; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 872e4a3ae..23d3ed5f1 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -76,7 +76,6 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv jobError: attributes.job_error || null, jobStartedAt: attributes.job_started_at ? new Date(attributes.job_started_at) : null, jobLastUpdatedAt: attributes.job_last_updated_at ? new Date(attributes.job_last_updated_at) : null, - canCancel: attributes.can_cancel || false, canRetry: attributes.can_retry || false, isInProgress: ['pending', 'running'].includes(attributes.job_status || ''), }); diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 3666c4901..5e5021090 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -15,14 +15,14 @@ import Pagination from '@/components/elements/Pagination'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; import Spinner from '@/components/elements/Spinner'; import { PageListContainer } from '@/components/elements/pages/PageList'; -import BackupRow from '@/components/server/backups/BackupRow'; -import createServerBackup from '@/api/server/backups/createServerBackup'; -import getServerBackups, { Context as ServerBackupContext } from '@/api/swr/getServerBackups'; +import { Context as ServerBackupContext } from '@/api/swr/getServerBackups'; import { ServerContext } from '@/state/server'; import useFlash from '@/plugins/useFlash'; +import { useUnifiedBackups } from './useUnifiedBackups'; +import BackupItem from './BackupItem'; // Helper function to format storage values const formatStorage = (mb: number | undefined | null): string => { @@ -96,71 +96,45 @@ const ModalContent = ({ ...props }: RequiredModalProps) => { const BackupContainer = () => { const { page, setPage } = useContext(ServerBackupContext); const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); - const { data: backups, error, isValidating, mutate } = getServerBackups(); const [createModalVisible, setCreateModalVisible] = useState(false); - const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const { + backups, + backupCount, + storage, + error, + isValidating, + createBackup + } = useUnifiedBackups(); + const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups); const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb); - - const hasBackupsInProgress = backups?.items.some((backup) => backup.isInProgress) || false; - - useEffect(() => { - let interval: NodeJS.Timeout; - - if (hasBackupsInProgress) { - interval = setInterval(() => { - mutate(); - }, 2000); - } - - return () => { - if (interval) { - clearInterval(interval); - } - }; - }, [hasBackupsInProgress, mutate]); - useEffect(() => { clearFlashes('backups:create'); }, [createModalVisible]); - const submitBackup = (values: BackupValues, { setSubmitting }: FormikHelpers) => { + const submitBackup = async (values: BackupValues, { setSubmitting }: FormikHelpers) => { clearFlashes('backups:create'); - createServerBackup(uuid, values) - .then(async (result) => { - // Add the new backup to the list immediately - await mutate( - (data) => ({ - ...data!, - items: data!.items.concat(result.backup), - backupCount: data!.backupCount + 1 - }), - false, - ); - // Show success message - clearFlashes('backups'); - addFlash({ - type: 'success', - key: 'backups', - message: `Backup "${result.backup.name}" has been started successfully.`, - }); + try { + await createBackup(values.name, values.ignored, values.isLocked); - setSubmitting(false); - setCreateModalVisible(false); - }) - .catch((error) => { - clearAndAddHttpError({ key: 'backups:create', error }); - setSubmitting(false); - }); + // Clear any existing flash messages + clearFlashes('backups'); + clearFlashes('backups:create'); + + setSubmitting(false); + setCreateModalVisible(false); + } catch (error) { + clearAndAddHttpError({ key: 'backups:create', error }); + setSubmitting(false); + } }; useEffect(() => { if (!error) { clearFlashes('backups'); - return; } @@ -197,12 +171,12 @@ const BackupContainer = () => { {/* Backup Count Display */} {backupLimit === null && (

- {backups.backupCount} backups + {backupCount} backups

)} {backupLimit > 0 && (

- {backups.backupCount} of {backupLimit} backups + {backupCount} of {backupLimit} backups

)} {backupLimit === 0 && ( @@ -212,22 +186,22 @@ const BackupContainer = () => { )} {/* Storage Usage Display */} - {backups.storage && ( + {storage && (
{backupStorageLimit === null ? (

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

) : ( <>

- {formatStorage(backups.storage.used_mb)} {' '} + {formatStorage(storage.used_mb)} {' '} {backupStorageLimit === null ? "used" : (of {formatStorage(backupStorageLimit)} used)} @@ -238,8 +212,8 @@ const BackupContainer = () => {

)} - {(backupLimit === null || backupLimit > backups.backupCount) && - (!backupStorageLimit || !backups.storage?.is_over_limit) && ( + {(backupLimit === null || backupLimit > backupCount) && + (!backupStorageLimit || !storage?.is_over_limit) && ( setCreateModalVisible(true)}> New Backup @@ -268,39 +242,35 @@ const BackupContainer = () => { )} - - {({ items }) => - !items.length ? ( -
-
-
- - - -
-

- {backupLimit === 0 ? 'Backups unavailable' : 'No backups found'} -

-

- {backupLimit === 0 - ? 'Backups cannot be created for this server.' - : 'Your server does not have any backups. Create one to get started.'} -

-
+ {backups.length === 0 ? ( +
+
+
+ + +
- ) : ( - - {items.map((backup) => ( - - ))} - - ) - } - +

+ {backupLimit === 0 ? 'Backups unavailable' : 'No backups found'} +

+

+ {backupLimit === 0 + ? 'Backups cannot be created for this server.' + : 'Your server does not have any backups. Create one to get started.'} +

+
+
+ ) : ( + + {backups.map((backup) => ( + + ))} + + )} ); }; @@ -314,4 +284,4 @@ const BackupContainerWrapper = () => { ); }; -export default BackupContainerWrapper; +export default BackupContainerWrapper; \ No newline at end of file diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index d76f775ca..4abe0f0a0 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -21,17 +21,14 @@ import HugeIconsHamburger from '@/components/elements/hugeicons/hamburger'; import http, { httpErrorToHuman } from '@/api/http'; import { - deleteServerBackup, getServerBackupDownloadUrl, - renameServerBackup, - restoreServerBackup, } from '@/api/server/backups'; import { ServerBackup } from '@/api/server/types'; -import getServerBackups from '@/api/swr/getServerBackups'; import { ServerContext } from '@/state/server'; import useFlash from '@/plugins/useFlash'; +import { useUnifiedBackups } from './useUnifiedBackups'; interface Props { backup: ServerBackup; @@ -45,7 +42,7 @@ const BackupContextMenu = ({ backup }: Props) => { const [countdown, setCountdown] = useState(5); const [newName, setNewName] = useState(backup.name); const { clearFlashes, clearAndAddHttpError } = useFlash(); - const { mutate } = getServerBackups(); + const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups(); const doDownload = () => { setLoading(true); @@ -56,111 +53,75 @@ const BackupContextMenu = ({ backup }: Props) => { window.location = url; }) .catch((error) => { - console.error(error); clearAndAddHttpError({ key: 'backups', error }); }) .then(() => setLoading(false)); }; - const doDeletion = () => { + const doDeletion = async () => { setLoading(true); clearFlashes('backups'); - deleteServerBackup(uuid, backup.uuid) - .then( - async () => - await mutate( - (data) => ({ - ...data!, - items: data!.items.filter((b) => b.uuid !== backup.uuid), - backupCount: data!.backupCount - 1, - }), - false, - ), - ) - .catch((error) => { - console.error(error); - clearAndAddHttpError({ key: 'backups', error }); - setLoading(false); - setModal(''); - }); + + try { + await deleteBackup(backup.uuid); + setLoading(false); + setModal(''); + } catch (error) { + clearAndAddHttpError({ key: 'backups', error }); + setLoading(false); + setModal(''); + } }; - const doRestorationAction = () => { + const doRestorationAction = async () => { setLoading(true); clearFlashes('backups'); - restoreServerBackup(uuid, backup.uuid) - .then(() => - setServerFromState((s) => ({ - ...s, - status: 'restoring_backup', - })), - ) - .catch((error) => { - console.error(error); - clearAndAddHttpError({ key: 'backups', error }); - }) - .then(() => setLoading(false)) - .then(() => setModal('')); + + try { + await restoreBackup(backup.uuid); + + // Set server status to restoring + setServerFromState((s) => ({ + ...s, + status: 'restoring_backup', + })); + + setLoading(false); + setModal(''); + } catch (error) { + clearAndAddHttpError({ key: 'backups', error }); + setLoading(false); + setModal(''); + } }; - const onLockToggle = () => { + const onLockToggle = async () => { if (backup.isLocked && modal !== 'unlock') { return setModal('unlock'); } - http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`) - .then( - async () => - await mutate( - (data) => ({ - ...data!, - items: data!.items.map((b) => - b.uuid !== backup.uuid - ? b - : { - ...b, - isLocked: !b.isLocked, - }, - ), - }), - false, - ), - ) - .catch((error) => alert(httpErrorToHuman(error))) - .then(() => setModal('')); + try { + await toggleBackupLock(backup.uuid); + setModal(''); + } catch (error) { + alert(httpErrorToHuman(error)); + } }; - const doRename = () => { + const doRename = async () => { setLoading(true); clearFlashes('backups'); - renameServerBackup(uuid, backup.uuid, newName.trim()) - .then( - async () => - await mutate( - (data) => ({ - ...data!, - items: data!.items.map((b) => - b.uuid !== backup.uuid - ? b - : { - ...b, - name: newName.trim(), - }, - ), - }), - false, - ), - ) - .catch((error) => { - console.error(error); - clearAndAddHttpError({ key: 'backups', error }); - }) - .then(() => { - setLoading(false); - setModal(''); - }); + + try { + await renameBackup(backup.uuid, newName.trim()); + setLoading(false); + setModal(''); + } catch (error) { + clearAndAddHttpError({ key: 'backups', error }); + setLoading(false); + setModal(''); + } }; - // Countdown effect for restore modal useEffect(() => { let interval: NodeJS.Timeout; if (modal === 'restore' && countdown > 0) { @@ -173,14 +134,12 @@ const BackupContextMenu = ({ backup }: Props) => { }; }, [modal, countdown]); - // Reset countdown when modal opens useEffect(() => { if (modal === 'restore') { setCountdown(5); } }, [modal]); - // Reset name when modal opens useEffect(() => { if (modal === 'rename') { setNewName(backup.name); diff --git a/resources/scripts/components/server/backups/BackupItem.tsx b/resources/scripts/components/server/backups/BackupItem.tsx new file mode 100644 index 000000000..29559c6df --- /dev/null +++ b/resources/scripts/components/server/backups/BackupItem.tsx @@ -0,0 +1,244 @@ +import { format, formatDistanceToNow } from 'date-fns'; + +import Can from '@/components/elements/Can'; +import Spinner from '@/components/elements/Spinner'; +import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock'; +import HugeIconsStorage from '@/components/elements/hugeicons/Storage'; +import HugeIconsRefresh from '@/components/elements/hugeicons/Refresh'; +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'; + +export interface UnifiedBackup { + uuid: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + progress: number; + message: string; + isSuccessful?: boolean; + isLocked: boolean; + checksum?: string; + bytes?: number; + createdAt: Date; + completedAt?: Date | null; + canRetry: boolean; + canDelete: boolean; + canDownload: boolean; + canRestore: boolean; + isLiveOnly: boolean; + isDeletion?: boolean; +} + +interface Props { + backup: UnifiedBackup; +} + +const BackupItem = ({ backup }: Props) => { + const { addFlash, clearFlashes } = useFlash(); + const { retryBackup } = useUnifiedBackups(); + + + const handleRetry = async () => { + if (!backup.canRetry) return; + + try { + clearFlashes('backup'); + await retryBackup(backup.uuid); + addFlash({ + type: 'success', + title: 'Success', + key: 'backup', + message: 'Backup is being retried.', + }); + } catch (error) { + addFlash({ + type: 'error', + title: 'Error', + key: 'backup', + message: error instanceof Error ? error.message : 'Failed to retry backup.', + }); + } + }; + + const getStatusIcon = () => { + const isActive = backup.status === 'running' || backup.status === 'pending'; + + if (isActive) { + return ; + } else if (backup.isLocked) { + return ; + } else if (backup.status === 'completed' || backup.isSuccessful) { + return ; + } else { + return ; + } + }; + + const getStatusBadge = () => { + switch (backup.status) { + case 'failed': + return ( + + Failed + + ); + case 'pending': + return ( + + Pending + + ); + case 'running': + return ( + + Running ({backup.progress}%) + + ); + case 'completed': + // Don't show "Completed" badge for deletion operations + if (backup.isDeletion) { + return null; + } + return backup.isLiveOnly ? ( + + Completed + + ) : null; + case 'cancelled': + return ( + + Cancelled + + ); + default: + return null; + } + }; + + const isActive = backup.status === 'running' || backup.status === 'pending'; + const showProgressBar = isActive || (backup.status === 'completed' && backup.isLiveOnly); + + return ( + +
+
+ {getStatusIcon()} +
+ +
+
+ {getStatusBadge()} +

{backup.name}

+ + 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.checksum &&

{backup.checksum}

} + +
+ + {/* Size info for completed backups */} +
+ {backup.completedAt && backup.isSuccessful && backup.bytes ? ( + <> +

Size

+

{bytesToString(backup.bytes)}

+ + ) : ( + <> +

Size

+

-

+ + )} +
+ + {/* Created time */} +
+

Created

+

+ {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} +

+
+ + {/* Actions */} +
+ {/* Retry button for failed backups */} + {backup.status === 'failed' && backup.canRetry && ( + + + + )} + + {/* Context menu for actionable backups */} + + {!isActive && !backup.isLiveOnly && ( + + )} + +
+
+ + ); +}; + +export default BackupItem; \ No newline at end of file diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx deleted file mode 100644 index 0eff260c2..000000000 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { format, formatDistanceToNow } from 'date-fns'; - -import Can from '@/components/elements/Can'; -import Spinner from '@/components/elements/Spinner'; -import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock'; -import HugeIconsStorage from '@/components/elements/hugeicons/Storage'; -import HugeIconsX from '@/components/elements/hugeicons/X'; -import HugeIconsRefresh from '@/components/elements/hugeicons/Refresh'; -import { PageListItem } from '@/components/elements/pages/PageList'; -import { SocketEvent } from '@/components/server/events'; - -import { bytesToString } from '@/lib/formatters'; - -import { ServerBackup } from '@/api/server/types'; -import getServerBackups from '@/api/swr/getServerBackups'; -import { cancelBackup, retryBackup } from '@/api/server/backups'; - -import useWebsocketEvent from '@/plugins/useWebsocketEvent'; -import useFlash from '@/plugins/useFlash'; -import { useBackupStatus } from './useBackupStatus'; - -import BackupContextMenu from './BackupContextMenu'; - -interface Props { - backup: ServerBackup; -} - -const BackupRow = ({ backup }: Props) => { - const { mutate } = getServerBackups(); - const { addFlash, clearFlashes } = useFlash(); - const { status: liveStatus } = useBackupStatus(backup.server?.uuid || '', backup, { - enabled: backup.isInProgress, - }); - - // Use live status if available, otherwise use backup data - const currentStatus = liveStatus || { - job_id: backup.jobId, - status: backup.jobStatus, - progress: backup.jobProgress, - message: backup.jobMessage, - error: backup.jobError, - can_cancel: backup.canCancel, - can_retry: backup.canRetry, - }; - - const handleCancel = async () => { - if (!backup.server?.uuid || !currentStatus.can_cancel) return; - - try { - clearFlashes('backup'); - await cancelBackup(backup.server.uuid, backup.uuid); - addFlash({ - type: 'success', - key: 'backup', - message: 'Backup has been cancelled.', - }); - await mutate(); - } catch (error) { - addFlash({ - type: 'error', - key: 'backup', - message: error instanceof Error ? error.message : 'Failed to cancel backup.', - }); - } - }; - - const handleRetry = async () => { - if (!backup.server?.uuid || !currentStatus.can_retry) return; - - try { - clearFlashes('backup'); - await retryBackup(backup.server.uuid, backup.uuid); - addFlash({ - type: 'success', - key: 'backup', - message: 'Backup is being retried.', - }); - await mutate(); - } catch (error) { - addFlash({ - type: 'error', - key: 'backup', - message: error instanceof Error ? error.message : 'Failed to retry backup.', - }); - } - }; - - useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => { - try { - await mutate(); - } catch (e) { - console.warn(e); - } - }); - - const getStatusIcon = () => { - if (backup.isInProgress) { - return ; - } else if (backup.isLocked) { - return ; - } else if (backup.isSuccessful) { - return ; - } else { - return ; - } - }; - - const getStatusBadge = () => { - if (currentStatus.status === 'failed') { - return ( - - Failed - - ); - } else if (currentStatus.status === 'pending') { - return ( - - Pending - - ); - } else if (currentStatus.status === 'running') { - return ( - - Running ({currentStatus.progress}%) - - ); - } else if (currentStatus.status === 'cancelled') { - return ( - - Cancelled - - ); - } - return null; - }; - - return ( - -
-
- {getStatusIcon()} -
- -
-
- {getStatusBadge()} -

{backup.name}

- - Locked - -
- - {/* Progress bar for running backups */} - {backup.isInProgress && ( -
-
- {currentStatus.message || 'Processing...'} - {currentStatus.progress}% -
-
-
-
-
- )} - - {/* Error message for failed backups */} - {currentStatus.status === 'failed' && currentStatus.error && ( -

{currentStatus.error}

- )} - - {backup.checksum &&

{backup.checksum}

} -
- - {backup.completedAt !== null && backup.isSuccessful && ( -
-

Size

-

{bytesToString(backup.bytes)}

-
- )} - -
-

Created

-

- {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} -

-
- -
- {/* Cancel button for running backups */} - {backup.isInProgress && currentStatus.can_cancel && ( - - - - )} - - {/* Retry button for failed backups */} - {currentStatus.status === 'failed' && currentStatus.can_retry && ( - - - - )} - - {/* Context menu for completed backups */} - - {!backup.isInProgress ? : null} - -
-
- - ); -}; - -export default BackupRow; diff --git a/resources/scripts/components/server/backups/useBackupStatus.ts b/resources/scripts/components/server/backups/useBackupStatus.ts deleted file mode 100644 index dc7d82809..000000000 --- a/resources/scripts/components/server/backups/useBackupStatus.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ServerBackup } from '@/api/server/types'; -import getBackupStatus, { BackupJobStatus } from '@/api/server/backups/getBackupStatus'; -import useWebsocketEvent from '@/plugins/useWebsocketEvent'; - -interface UseBackupStatusReturn { - status: BackupJobStatus | null; - loading: boolean; - error: string | null; - pollNow: () => void; - stopPolling: () => void; - startPolling: () => void; -} - -export const useBackupStatus = ( - serverUuid: string, - backup: ServerBackup, - options: { - enabled?: boolean; - pollInterval?: number; - stopWhenComplete?: boolean; - } = {} -): UseBackupStatusReturn => { - const { - enabled = true, - pollInterval = 3000, // 3 seconds - stopWhenComplete = true, - } = options; - - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isPolling, setIsPolling] = useState(false); - - const intervalRef = useRef(null); - const mountedRef = useRef(true); - - // Determine if we should be actively polling - const shouldPoll = enabled && - backup.isInProgress && - backup.jobId && - isPolling; - - const pollStatus = useCallback(async () => { - if (!backup.jobId || !mountedRef.current) return; - - try { - setLoading(true); - setError(null); - - const newStatus = await getBackupStatus(serverUuid, backup.uuid); - - if (mountedRef.current) { - setStatus(newStatus); - - // Stop polling if job is complete and option is enabled - if (stopWhenComplete && !['pending', 'running'].includes(newStatus.status)) { - setIsPolling(false); - } - } - } catch (err) { - if (mountedRef.current) { - setError(err instanceof Error ? err.message : 'Failed to fetch backup status'); - // Stop polling on error - setIsPolling(false); - } - } finally { - if (mountedRef.current) { - setLoading(false); - } - } - }, [serverUuid, backup.uuid, backup.jobId, stopWhenComplete]); - - const pollNow = useCallback(() => { - pollStatus(); - }, [pollStatus]); - - const stopPolling = useCallback(() => { - setIsPolling(false); - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); - - const startPolling = useCallback(() => { - if (backup.isInProgress && backup.jobId) { - setIsPolling(true); - } - }, [backup.isInProgress, backup.jobId]); - - // Set up polling interval - useEffect(() => { - if (shouldPoll) { - // Poll immediately when starting - pollStatus(); - - // Set up interval for future polls - intervalRef.current = setInterval(pollStatus, pollInterval); - } else { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - } - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [shouldPoll, pollStatus, pollInterval]); - - // Auto-start polling for in-progress backups - useEffect(() => { - if (enabled && backup.isInProgress) { - startPolling(); - } - }, [enabled, backup.isInProgress, startPolling]); - - // Listen for WebSocket events for real-time updates - useWebsocketEvent('backup.status', (data: any) => { - if (data.backup_uuid === backup.uuid && mountedRef.current) { - setStatus({ - job_id: data.job_id, - status: data.status, - progress: data.progress, - message: data.message, - error: data.error, - is_successful: data.is_successful, - can_cancel: data.can_cancel, - can_retry: data.can_retry, - started_at: data.started_at, - last_updated_at: data.last_updated_at, - completed_at: data.completed_at, - }); - - // Stop polling if job completed via WebSocket - if (stopWhenComplete && !['pending', 'running'].includes(data.status)) { - setIsPolling(false); - } - } - }); - - // Cleanup on unmount - useEffect(() => { - return () => { - mountedRef.current = false; - stopPolling(); - }; - }, [stopPolling]); - - return { - status, - loading, - error, - pollNow, - stopPolling, - startPolling, - }; -}; \ No newline at end of file diff --git a/resources/scripts/components/server/backups/useUnifiedBackups.ts b/resources/scripts/components/server/backups/useUnifiedBackups.ts new file mode 100644 index 000000000..82e52769a --- /dev/null +++ b/resources/scripts/components/server/backups/useUnifiedBackups.ts @@ -0,0 +1,240 @@ +import { useCallback, useState } from 'react'; +import { SocketEvent } from '@/components/server/events'; +import { ServerContext } from '@/state/server'; +import useWebsocketEvent from '@/plugins/useWebsocketEvent'; +import getServerBackups from '@/api/swr/getServerBackups'; +import { UnifiedBackup } from './BackupItem'; + +export const useUnifiedBackups = () => { + const { data: backups, error, isValidating, mutate } = getServerBackups(); + const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + + const [liveProgress, setLiveProgress] = useState>({}); + + 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 createBackup = useCallback(async (name: string, ignored: string, isLocked: boolean) => { + const { default: createServerBackup } = await import('@/api/server/backups/createServerBackup'); + const result = await createServerBackup(uuid, { name, ignored, isLocked }); + mutate(); + return result; + }, [uuid, mutate]); + + const deleteBackup = useCallback(async (backupUuid: string) => { + const { deleteServerBackup } = await import('@/api/server/backups'); + const result = await deleteServerBackup(uuid, backupUuid); + mutate(); + return result; + }, [uuid, mutate]); + + const retryBackup = useCallback(async (backupUuid: string) => { + const { retryBackup: retryBackupApi } = await import('@/api/server/backups'); + await retryBackupApi(uuid, backupUuid); + mutate(); + }, [uuid, mutate]); + + const restoreBackup = useCallback(async (backupUuid: string) => { + const { restoreServerBackup } = await import('@/api/server/backups'); + const result = await restoreServerBackup(uuid, backupUuid); + mutate(); + return result; + }, [uuid, mutate]); + + const renameBackup = useCallback(async (backupUuid: string, newName: string) => { + const http = (await import('@/api/http')).default; + await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/rename`, { name: newName }); + mutate(); + }, [uuid, mutate]); + + const toggleBackupLock = useCallback(async (backupUuid: string) => { + const http = (await import('@/api/http')).default; + await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/lock`); + mutate(); + }, [uuid, mutate]); + + const unifiedBackups: UnifiedBackup[] = []; + + if (backups?.items) { + for (const backup of backups.items) { + const live = liveProgress[backup.uuid]; + + unifiedBackups.push({ + uuid: backup.uuid, + name: live?.backupName || backup.name, + status: live ? live.status as any : (backup.isSuccessful ? 'completed' : 'failed'), + progress: live ? live.progress : (backup.isSuccessful ? 100 : 0), + message: live ? live.message : (backup.isSuccessful ? 'Completed' : 'Failed'), + isSuccessful: backup.isSuccessful, + isLocked: backup.isLocked, + checksum: backup.checksum, + bytes: backup.bytes, + createdAt: backup.createdAt, + completedAt: backup.completedAt, + canRetry: live ? live.canRetry : backup.canRetry, + canDelete: live ? false : true, + canDownload: backup.isSuccessful && !live, + canRestore: backup.isSuccessful && !live, + isLiveOnly: false, + isDeletion: live?.isDeletion || false, + }); + } + } + + // Add live-only backups (new operations not yet in SWR) + for (const [backupUuid, live] of Object.entries(liveProgress)) { + const existsInSwr = unifiedBackups.some(b => b.uuid === backupUuid); + + if (!existsInSwr && !live.isDeletion) { + unifiedBackups.push({ + uuid: backupUuid, + name: live.backupName || live.message || 'Processing...', + status: live.status as any, + progress: live.progress, + message: live.message, + isSuccessful: false, + isLocked: false, + checksum: undefined, + bytes: undefined, + createdAt: new Date(), + completedAt: null, + canRetry: live.canRetry, + canDelete: false, + canDownload: false, + canRestore: false, + isLiveOnly: true, + isDeletion: false, + }); + } + } + + unifiedBackups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return { + backups: unifiedBackups, + backupCount: backups?.backupCount || 0, + storage: backups?.storage, + error, + isValidating, + createBackup, + deleteBackup, + retryBackup, + restoreBackup, + renameBackup, + toggleBackupLock, + refresh: () => mutate(), + }; +}; \ No newline at end of file diff --git a/resources/scripts/components/server/events.ts b/resources/scripts/components/server/events.ts index 5c9c38ac8..116c0937e 100644 --- a/resources/scripts/components/server/events.ts +++ b/resources/scripts/components/server/events.ts @@ -10,6 +10,7 @@ export enum SocketEvent { TRANSFER_LOGS = 'transfer logs', TRANSFER_STATUS = 'transfer status', BACKUP_COMPLETED = 'backup completed', + BACKUP_STATUS = 'backup.status', BACKUP_RESTORE_COMPLETED = 'backup restore completed', } diff --git a/resources/scripts/plugins/useWebsocketEvent.ts b/resources/scripts/plugins/useWebsocketEvent.ts index 0e96b214f..3d3e0393a 100644 --- a/resources/scripts/plugins/useWebsocketEvent.ts +++ b/resources/scripts/plugins/useWebsocketEvent.ts @@ -4,7 +4,7 @@ import { SocketEvent } from '@/components/server/events'; import { ServerContext } from '@/state/server'; -const useWebsocketEvent = (event: SocketEvent, callback: (data: string) => void) => { +const useWebsocketEvent = (event: SocketEvent, callback: (data: any) => void) => { const { connected, instance } = ServerContext.useStoreState((state) => state.socket); const savedCallback = useRef(null); @@ -13,7 +13,7 @@ const useWebsocketEvent = (event: SocketEvent, callback: (data: string) => void) }, [callback]); return useEffect(() => { - const eventListener = (event: SocketEvent) => savedCallback.current(event); + const eventListener = (data: any) => savedCallback.current(data); if (connected && instance) { instance.addListener(event, eventListener); } diff --git a/routes/api-client.php b/routes/api-client.php index 81aa4f588..88fb39dce 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -137,21 +137,27 @@ Route::group([ Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']); }); + // Elytra Jobs API + Route::group(['prefix' => '/jobs'], function () { + Route::get('/', [Client\Servers\ElytraJobsController::class, 'index']); + Route::post('/', [Client\Servers\ElytraJobsController::class, 'create']) + ->middleware('server.operation.rate-limit'); + Route::get('/{jobId}', [Client\Servers\ElytraJobsController::class, 'show']); + Route::delete('/{jobId}', [Client\Servers\ElytraJobsController::class, 'cancel']); + }); + + // Backups API Route::group(['prefix' => '/backups'], function () { - Route::get('/', [Client\Servers\BackupController::class, 'index']); - Route::post('/', [Client\Servers\BackupController::class, 'store']) + Route::get('/', [Client\Servers\BackupsController::class, 'index']); + Route::post('/', [Client\Servers\BackupsController::class, 'store']) ->middleware('server.operation.rate-limit'); - Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']); - Route::get('/{backup}/status', [Client\Servers\BackupController::class, 'status']); - Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']); - Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']); - Route::post('/{backup}/rename', [Client\Servers\BackupController::class, 'rename']); - Route::post('/{backup}/cancel', [Client\Servers\BackupController::class, 'cancel']); - Route::post('/{backup}/retry', [Client\Servers\BackupController::class, 'retry']) + 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']) ->middleware('server.operation.rate-limit'); - Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']) - ->middleware('server.operation.rate-limit'); - Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']); + Route::post('/{backup}/rename', [Client\Servers\BackupsController::class, 'rename']); + Route::post('/{backup}/lock', [Client\Servers\BackupsController::class, 'toggleLock']); + Route::delete('/{backup}', [Client\Servers\BackupsController::class, 'destroy']); }); Route::group(['prefix' => '/startup'], function () { diff --git a/routes/api-remote.php b/routes/api-remote.php index 4f9b4f14b..b1ea3c8e9 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -7,7 +7,7 @@ use Pterodactyl\Http\Controllers\Api\Remote\SftpAuthenticationController; use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupDeleteController; use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController; use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupSizeController; -use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupStatusController; +use Pterodactyl\Http\Controllers\Api\Remote\ElytraJobCompletionController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerDetailsController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerInstallController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerTransferController; @@ -35,7 +35,9 @@ Route::group(['prefix' => '/servers/{uuid}'], function () { Route::group(['prefix' => '/backups'], function () { Route::get('/{backup}', BackupRemoteUploadController::class); - Route::post('/{backup}', [BackupStatusController::class, 'index']); - Route::post('/{backup}/restore', [BackupStatusController::class, 'restore']); Route::delete('/{backup}', BackupDeleteController::class); }); + +Route::group(['prefix' => '/elytra-jobs'], function () { + Route::put('/{jobId}', [ElytraJobCompletionController::class, 'update']); +}); diff --git a/vagrant/provision.sh b/vagrant/provision.sh index 60c229c90..28375f728 100755 --- a/vagrant/provision.sh +++ b/vagrant/provision.sh @@ -89,7 +89,7 @@ cat >/etc/nginx/sites-available/pterodactyl.conf <<'EOF' server { listen 3000; server_name localhost; - root /var/www/pterodactyl/public; + root /home/vagrant/pyrodactyl/public; index index.php index.html; charset utf-8; location / { @@ -163,11 +163,7 @@ if ! command -v composer >/dev/null 2>&1; then rm -f /tmp/composer-setup.php fi -log Preparing /var/www/pterodactyl -chown -R vagrant:vagrant /var/www/pterodactyl -chmod -R u=rwX,g=rX,o=rX /var/www/pterodactyl - -pushd /var/www/pterodactyl >/dev/null +pushd /home/vagrant/pyrodactyl >/dev/null [ -f .env ] || cp .env.example .env sudo -u vagrant mkdir -p storage/framework/cache @@ -177,13 +173,13 @@ sudo -u vagrant mkdir -p storage/logs sudo -u vagrant mkdir -p bootstrap/cache log Composer install -sudo -u vagrant -H bash -lc 'cd /var/www/pterodactyl && composer install --no-dev --optimize-autoloader' +sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && composer install --no-dev --optimize-autoloader' chmod -R 755 storage bootstrap/cache setfacl -Rm u:vagrant:rwX storage bootstrap/cache >/dev/null 2>&1 || true chown -R vagrant:vagrant storage bootstrap/cache # helper (append --no-interaction automatically; avoid quoted, spaced values) -artisan() { sudo -u vagrant -H bash -lc "cd /var/www/pterodactyl && php artisan $* --no-interaction"; } +artisan() { sudo -u vagrant -H bash -lc "cd /home/vagrant/pyrodactyl && php artisan $* --no-interaction"; } # generate key only if empty/missing if ! grep -qE '^APP_KEY=base64:.+' .env; then @@ -246,7 +242,7 @@ popd >/dev/null log Installing Laravel scheduler cron ( crontab -l 2>/dev/null | grep -v 'pterodactyl/artisan schedule:run' || true - echo '* * * * * php /var/www/pterodactyl/artisan schedule:run >> /dev/null 2>&1' + echo '* * * * * php /home/vagrant/pyrodactyl/artisan schedule:run >> /dev/null 2>&1' ) | crontab - log Creating pteroq.service @@ -258,9 +254,9 @@ Requires=redis-server.service [Service] User=vagrant Group=vagrant -WorkingDirectory=/var/www/pterodactyl +WorkingDirectory=/home/vagrant/pyrodactyl Restart=always -ExecStart=/usr/bin/php /var/www/pterodactyl/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3 +ExecStart=/usr/bin/php /home/vagrant/pyrodactyl/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3 StartLimitBurst=30 RestartSec=5s [Install] @@ -305,7 +301,7 @@ else fi if [ ! -f /etc/pterodactyl/config.yml ]; then - sudo -u vagrant -H bash -lc 'cd /var/www/pterodactyl && php artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true + sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && php artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true else log "Elytra config already exists, skipping" fi @@ -408,7 +404,7 @@ EOF ' || true # Configure MinIO in .env for rustic_s3 backups - pushd /var/www/pterodactyl >/dev/null + pushd /home/vagrant/pyrodactyl >/dev/null if [ -f .env ]; then # Set rustic_s3 backup configuration for MinIO sed -i '/^APP_BACKUP_DRIVER=/c\APP_BACKUP_DRIVER=rustic_s3' .env @@ -558,7 +554,7 @@ EOF systemctl enable --now mailpit # Configure Mailpit in .env for mail testing - pushd /var/www/pterodactyl >/dev/null + pushd /home/vagrant/pyrodactyl >/dev/null if [ -f .env ]; then # Set mail configuration for Mailpit if grep -q "^MAIL_MAILER=" .env; then @@ -615,7 +611,7 @@ systemctl restart php8.4-fpm systemctl reload nginx || systemctl restart nginx || true log Generating Application API Key -pushd /var/www/pterodactyl >/dev/null +pushd /home/vagrant/pyrodactyl >/dev/null API_KEY_RESULT=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute=" use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\User; @@ -688,7 +684,7 @@ if [ -n "${API_KEY:-}" ]; then EOF # Check if a Minecraft server already exists using Laravel - pushd /var/www/pterodactyl >/dev/null + pushd /home/vagrant/pyrodactyl >/dev/null EXISTING_SERVER_CHECK=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute=" use Pterodactyl\Models\Server; \$server = Server::where(\"name\", \"Minecraft Vanilla Dev Server\")->first();