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/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/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 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->initiateBackupService - ->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->handle($server, $request->input('name')); - - Activity::event('server:backup.start') - ->subject($backup) - ->property([ - 'name' => $backup->name, - 'locked' => (bool) $request->input('is_locked'), - 'adapter' => $backup->disk - ]) - ->log(); - - return $this->fractal->item($backup) - ->transformWith($this->getTransformer(BackupTransformer::class)) - ->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(); - } - - /** - * 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 6f7a254cd..8dbecd09c 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -4,9 +4,12 @@ namespace Pterodactyl\Models; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; /** + * Backup model + * * @property int $id * @property int $server_id * @property string $uuid @@ -25,6 +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\ElytraJob[] $elytraJobs * @property \Pterodactyl\Models\AuditLog[] $audits */ class Backup extends Model @@ -35,7 +39,9 @@ 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'; @@ -78,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]); } /** @@ -101,6 +107,14 @@ 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', @@ -109,7 +123,7 @@ class Backup extends Model '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', @@ -120,4 +134,77 @@ class Backup extends Model { return $this->belongsTo(Server::class); } -} + + /** + * Get all Elytra jobs related to this backup + */ + public function elytraJobs(): HasMany + { + return $this->hasMany(ElytraJob::class, 'server_id', 'server_id') + ->where('job_data->backup_uuid', $this->uuid); + } + + /** + * Get the latest Elytra job for this backup + */ + public function latestElytraJob() + { + return $this->elytraJobs()->latest('created_at')->first(); + } + + /** + * Get the adapter type formatted for Elytra API + */ + public function getElytraAdapterType(): string + { + return match($this->disk) { + 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', + default => $this->disk, + }; + } + + /** + * Scope to get successful backups + */ + public function scopeSuccessful($query) + { + return $query->where('is_successful', true); + } + + /** + * Scope to get failed backups + */ + public function scopeFailed($query) + { + 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/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 e9b611109..000000000 --- a/app/Repositories/Wings/DaemonBackupRepository.php +++ /dev/null @@ -1,100 +0,0 @@ -adapter = $adapter; - - return $this; - } - - /** - * Tells the remote Daemon to begin generating a backup for the server. - * - * @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' => 5])->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. - * - * @throws DaemonConnectionException - */ - public function delete(Backup $backup): ResponseInterface - { - Assert::isInstanceOf($this->server, Server::class); - - try { - return $this->getHttpClient(['timeout' => 5])->delete( - sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid) - ); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); - } - } -} 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/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_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..49d5fbc38 --- /dev/null +++ b/database/migrations/2025_09_27_101200_create_elytra_jobs_table.php @@ -0,0 +1,44 @@ +id(); + $table->char('uuid', 36)->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'); + } +}; diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index 3167b5b46..2bc5c8384 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -8,12 +8,86 @@ interface RequestParameters { isLocked: boolean; } -export default async (uuid: string, params: RequestParameters): Promise => { - const { data } = await http.post(`/api/client/servers/${uuid}/backups`, { +interface CreateBackupResponse { + data: any; + meta: { + job_id: string; + status: string; + progress: number; + message?: string; + }; +} + +export default async (uuid: string, params: RequestParameters): Promise<{ backup: ServerBackup; jobId: string; status: string; progress: number; message?: string }> => { + const response = await http.post(`/api/client/servers/${uuid}/backups`, { name: params.name, ignored: params.ignored, is_locked: params.isLocked, }); - return rawDataToServerBackup(data); + 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/getBackupStatus.ts b/resources/scripts/api/server/backups/getBackupStatus.ts new file mode 100644 index 000000000..f7791426e --- /dev/null +++ b/resources/scripts/api/server/backups/getBackupStatus.ts @@ -0,0 +1,21 @@ +import http from '@/api/http'; + +export interface BackupJobStatus { + job_id: string | null; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + progress: number; + message?: string; + error?: string; + is_successful: boolean; + can_cancel: boolean; + can_retry: boolean; + started_at?: string; + last_updated_at?: string; + completed_at?: string; +} + +export default async (uuid: string, backupUuid: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/backups/${backupUuid}/status`); + + return data; +}; \ No newline at end of file diff --git a/resources/scripts/api/server/backups/index.ts b/resources/scripts/api/server/backups/index.ts index 9e84a1f61..9eb24dcf3 100644 --- a/resources/scripts/api/server/backups/index.ts +++ b/resources/scripts/api/server/backups/index.ts @@ -1,10 +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 retryBackup } from './retryBackup'; +export type { BackupJobStatus } from './getBackupStatus'; diff --git a/resources/scripts/api/server/backups/retryBackup.ts b/resources/scripts/api/server/backups/retryBackup.ts new file mode 100644 index 000000000..9a6f76ad5 --- /dev/null +++ b/resources/scripts/api/server/backups/retryBackup.ts @@ -0,0 +1,14 @@ +import http from '@/api/http'; + +export interface RetryBackupResponse { + message: string; + job_id: string; + status: string; + progress: number; +} + +export default async (uuid: string, backupUuid: string): Promise => { + const { data } = await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/retry`); + + return data; +}; \ No newline at end of file diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index 3ffa5111d..eca25dec9 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -20,6 +20,16 @@ export interface ServerBackup { snapshotId: string | null; createdAt: Date; completedAt: Date | null; + // Async job fields + jobId: string | null; + jobStatus: 'pending' | 'running' | 'completed' | 'failed'; + jobProgress: number; + jobMessage: string | null; + jobError: string | null; + jobStartedAt: Date | null; + jobLastUpdatedAt: Date | null; + canRetry: boolean; + isInProgress: boolean; } export interface ServerEggVariable { 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 794f52f4d..23d3ed5f1 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -68,6 +68,16 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv snapshotId: attributes.snapshot_id, createdAt: new Date(attributes.created_at), completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, + // Async job fields + jobId: attributes.job_id || null, + jobStatus: attributes.job_status || 'completed', + jobProgress: attributes.job_progress || (attributes.is_successful ? 100 : 0), + jobMessage: attributes.job_message || null, + 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, + canRetry: attributes.can_retry || false, + isInProgress: ['pending', 'running'].includes(attributes.job_status || ''), }); export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({ diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index d833d97c5..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 => { @@ -95,58 +95,46 @@ const ModalContent = ({ ...props }: RequiredModalProps) => { const BackupContainer = () => { const { page, setPage } = useContext(ServerBackupContext); - const { clearFlashes, clearAndAddHttpError } = useFlash(); - const { data: backups, error, isValidating, mutate } = getServerBackups(); + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); 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.completedAt === null) || 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 (backup) => { - await mutate( - (data) => ({ ...data!, items: data!.items.concat(backup), backupCount: data!.backupCount + 1 }), - false, - ); - setSubmitting(false); - setCreateModalVisible(false); - }) - .catch((error) => { - clearAndAddHttpError({ key: 'backups:create', error }); - setSubmitting(false); - }); + + try { + await createBackup(values.name, values.ignored, values.isLocked); + + // 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; } @@ -183,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 && ( @@ -198,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)} @@ -224,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 @@ -254,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) => ( + + ))} + + )} ); }; @@ -300,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 572b72aab..000000000 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ /dev/null @@ -1,95 +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 { 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 useWebsocketEvent from '@/plugins/useWebsocketEvent'; - -import BackupContextMenu from './BackupContextMenu'; - -interface Props { - backup: ServerBackup; -} - -const BackupRow = ({ backup }: Props) => { - const { mutate } = getServerBackups(); - - useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => { - try { - await mutate(); - } catch (e) { - console.warn(e); - } - }); - - return ( - -
-
- {backup.completedAt === null ? ( - - ) : backup.isLocked ? ( - - ) : backup.isSuccessful ? ( - - ) : ( - - )} -
- -
-
- {backup.completedAt !== null && !backup.isSuccessful && ( - - Failed - - )} -

{backup.name}

- - Locked - -
- {backup.checksum &&

{backup.checksum}

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

Size

-

{bytesToString(backup.bytes)}

-
- )} - -
-

Created

-

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

-
- -
- - {backup.completedAt ? : null} - -
-
-
- ); -}; - -export default BackupRow; 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 0b894efeb..88fb39dce 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -137,17 +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}/download', [Client\Servers\BackupController::class, 'download']); - Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']); - Route::post('/{backup}/rename', [Client\Servers\BackupController::class, 'rename']); - Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']) + 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::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();