From f7a12cb5b6a68a83d265bdcc88c08c9ebebddc74 Mon Sep 17 00:00:00 2001 From: Elizabeth Date: Fri, 15 Aug 2025 00:49:35 -0500 Subject: [PATCH] feat: asynchronous server operations. --- .../ServerOperationException.php | 57 + .../Service/Backup/BackupFailedException.php | 12 + .../Api/Client/Servers/BackupController.php | 74 +- .../Api/Client/Servers/SettingsController.php | 333 +++-- .../Remote/Backups/BackupStatusController.php | 75 +- .../Server/ServerOperationRateLimit.php | 90 ++ .../Settings/ApplyEggChangeRequest.php | 67 + .../Servers/Settings/PreviewEggRequest.php | 43 + .../Settings/ServerOperationRequest.php | 43 + .../Resources/ServerOperationResource.php | 44 + app/Jobs/Server/ApplyEggChangeJob.php | 341 +++++ app/Models/ServerOperation.php | 165 +++ .../ServerOperationServiceProvider.php | 52 + app/Services/Backups/DeleteBackupService.php | 26 +- .../Backups/InitiateBackupService.php | 44 +- .../ServerOperations/EggChangeService.php | 205 +++ .../ServerOperationService.php | 173 +++ .../ServerStateValidationService.php | 73 ++ .../Servers/VariableValidatorService.php | 12 +- config/app.php | 1 + config/server_operations.php | 41 + ..._025040_create_server_operations_table.php | 52 + .../scripts/api/server/applyEggChange.ts | 27 + .../scripts/api/server/previewEggChange.ts | 35 + .../scripts/api/server/serverOperations.ts | 175 +++ .../scripts/api/server/setSelectedEggImage.ts | 11 - .../dashboard/AccountApiContainer.tsx | 2 +- .../dashboard/ssh/AccountSSHContainer.tsx | 2 +- .../dashboard/ssh/CreateSSHKeyForm.tsx | 4 +- .../dashboard/ssh/DeleteSSHKeyButton.tsx | 2 +- .../components/elements/MainPageHeader.tsx | 21 +- .../elements/activity/ActivityLogEntry.tsx | 6 - .../components/elements/pages/PageList.tsx | 2 +- .../server/ServerActivityLogContainer.tsx | 54 +- .../server/backups/BackupContainer.tsx | 46 +- .../components/server/backups/BackupRow.tsx | 6 +- .../server/console/ServerConsoleContainer.tsx | 27 +- .../server/databases/DatabaseRow.tsx | 7 +- .../server/databases/DatabasesContainer.tsx | 40 +- .../server/files/FileManagerContainer.tsx | 26 +- .../server/network/AllocationRow.tsx | 9 +- .../server/network/NetworkContainer.tsx | 40 +- .../operations/OperationProgressModal.tsx | 242 ++++ .../server/schedules/ScheduleContainer.tsx | 44 +- .../server/settings/SettingsContainer.tsx | 6 +- .../server/shell/ShellContainer.tsx | 1122 ++++++++++------- .../server/startup/StartupContainer.tsx | 2 +- .../server/users/UsersContainer.tsx | 74 +- resources/scripts/lib/server-operations.ts | 123 ++ routes/api-client.php | 214 ++-- 50 files changed, 3527 insertions(+), 865 deletions(-) create mode 100644 app/Exceptions/ServerOperations/ServerOperationException.php create mode 100644 app/Exceptions/Service/Backup/BackupFailedException.php create mode 100644 app/Http/Middleware/Api/Client/Server/ServerOperationRateLimit.php create mode 100644 app/Http/Requests/Api/Client/Servers/Settings/ApplyEggChangeRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/Settings/PreviewEggRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/Settings/ServerOperationRequest.php create mode 100644 app/Http/Resources/ServerOperationResource.php create mode 100644 app/Jobs/Server/ApplyEggChangeJob.php create mode 100644 app/Models/ServerOperation.php create mode 100644 app/Providers/ServerOperationServiceProvider.php create mode 100644 app/Services/ServerOperations/EggChangeService.php create mode 100644 app/Services/ServerOperations/ServerOperationService.php create mode 100644 app/Services/ServerOperations/ServerStateValidationService.php create mode 100644 config/server_operations.php create mode 100644 database/migrations/2025_08_14_025040_create_server_operations_table.php create mode 100644 resources/scripts/api/server/applyEggChange.ts create mode 100644 resources/scripts/api/server/previewEggChange.ts create mode 100644 resources/scripts/api/server/serverOperations.ts delete mode 100644 resources/scripts/api/server/setSelectedEggImage.ts create mode 100644 resources/scripts/components/server/operations/OperationProgressModal.tsx create mode 100644 resources/scripts/lib/server-operations.ts diff --git a/app/Exceptions/ServerOperations/ServerOperationException.php b/app/Exceptions/ServerOperations/ServerOperationException.php new file mode 100644 index 000000000..f97061ffc --- /dev/null +++ b/app/Exceptions/ServerOperations/ServerOperationException.php @@ -0,0 +1,57 @@ +status)) { - throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'); - } + $this->validateServerForRestore($server); - if (!$backup->is_successful && is_null($backup->completed_at)) { - throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'); - } + $this->validateBackupForRestore($backup); $log = Activity::event('server:backup.restore') ->subject($backup) ->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]); $log->transaction(function () use ($backup, $server, $request) { + // 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 we need to generate a unique Download link for // it that will allow Wings to actually access the file. + $url = null; if ($backup->disk === Backup::ADAPTER_AWS_S3) { - $url = $this->downloadLinkService->handle($backup, $request->user()); + 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]); - $this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate')); + try { + $this->daemonRepository->setServer($server)->restore($backup, $url, $request->input('truncate')); + } catch (\Exception $e) { + // If daemon request 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/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/SettingsController.php index 195485579..376739d9d 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/SettingsController.php @@ -2,169 +2,238 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Exception; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Log; use Pterodactyl\Facades\Activity; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Services\Servers\ReinstallServerService; +use Pterodactyl\Services\Backups\InitiateBackupService; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; +use Pterodactyl\Services\ServerOperations\ServerOperationService; +use Pterodactyl\Services\ServerOperations\ServerStateValidationService; +use Pterodactyl\Services\ServerOperations\EggChangeService; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RevertDockerImageRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetEggRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\PreviewEggRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ApplyEggChangeRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; +use Pterodactyl\Services\Servers\StartupModificationService; +use Pterodactyl\Repositories\Wings\DaemonFileRepository; class SettingsController extends ClientApiController { - /** - * SettingsController constructor. - */ - public function __construct( - private ServerRepository $repository, - private ReinstallServerService $reinstallServerService, - ) { - parent::__construct(); - } + public function __construct( + private ServerRepository $repository, + private ReinstallServerService $reinstallServerService, + private StartupModificationService $startupModificationService, + private InitiateBackupService $backupService, + private DaemonFileRepository $fileRepository, + private ServerOperationService $operationService, + private ServerStateValidationService $validationService, + private EggChangeService $eggChangeService, + ) { + parent::__construct(); + } - /** - * Renames a server. - * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function rename(RenameServerRequest $request, Server $server): JsonResponse + public function rename(RenameServerRequest $request, Server $server): JsonResponse + { + $name = $request->input('name'); + $description = $request->has('description') ? (string) $request->input('description') : $server->description; + $this->repository->update($server->id, [ + 'name' => $name, + 'description' => $description, + ]); + + if ($server->name !== $name) { + Activity::event('server:settings.rename') + ->property(['old' => $server->name, 'new' => $name]) + ->log(); + } + + if ($server->description !== $description) { + Activity::event('server:settings.description') + ->property(['old' => $server->description, 'new' => $description]) + ->log(); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + public function reinstall(ReinstallServerRequest $request, Server $server): JsonResponse + { + $this->reinstallServerService->handle($server); + Activity::event('server:reinstall')->log(); + return new JsonResponse([], Response::HTTP_ACCEPTED); + } + + public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse + { + if (!in_array($request->input('docker_image'), array_values($server->egg->docker_images))) { + throw new BadRequestHttpException('The requested Docker image is not allowed for this server.'); + } + + $original = $server->image; + $server->forceFill(['image' => $request->input('docker_image')])->saveOrFail(); + + if ($original !== $server->image) { + Activity::event('server:startup.image') + ->property(['old' => $original, 'new' => $request->input('docker_image')]) + ->log(); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + public function revertDockerImage(RevertDockerImageRequest $request, Server $server): JsonResponse + { + $server->validateCurrentState(); + + $original = $server->image; + $defaultImage = $server->getDefaultDockerImage(); + + if (empty($defaultImage)) { + throw new BadRequestHttpException('No default docker image available for this server\'s egg.'); + } + + $server->forceFill(['image' => $defaultImage])->saveOrFail(); + + Activity::event('server:startup.image.reverted') + ->property([ + 'old' => $original, + 'new' => $defaultImage, + 'reverted_to_egg_default' => true, + ]) + ->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + private function resetStartupCommand(Server $server): JsonResponse + { + $server->startup = $server->egg->startup; + $server->save(); + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + public function changeEgg(SetEggRequest $request, Server $server): JsonResponse + { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + $originalEggId = $server->egg_id; + $originalNestId = $server->nest_id; + + if ($originalEggId !== $eggId || $originalNestId !== $nestId) { + $server->egg_id = $eggId; + $server->nest_id = $nestId; + $server->save(); + + Activity::event('server:settings.egg') + ->property(['original_egg_id' => $originalEggId, 'new_egg_id' => $eggId, 'original_nest_id' => $originalNestId, 'new_nest_id' => $nestId]) + ->log(); + + $this->resetStartupCommand($server); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse { - $name = $request->input('name'); - $description = $request->has('description') ? (string) $request->input('description') : $server->description; - $this->repository->update($server->id, [ - 'name' => $name, - 'description' => $description, - ]); - - if ($server->name !== $name) { - Activity::event('server:settings.rename') - ->property(['old' => $server->name, 'new' => $name]) - ->log(); - } - - if ($server->description !== $description) { - Activity::event('server:settings.description') - ->property(['old' => $server->description, 'new' => $description]) - ->log(); - } - - return new JsonResponse([], Response::HTTP_NO_CONTENT); + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + + $previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId); + + // Log the preview action + Activity::event('server:settings.egg-preview') + ->property([ + 'current_egg_id' => $server->egg_id, + 'preview_egg_id' => $eggId, + 'preview_nest_id' => $nestId, + ]) + ->log(); + + return new JsonResponse($previewData); + } catch (Exception $e) { + Log::error('Failed to preview egg change', [ + 'server_id' => $server->id, + 'egg_id' => $request->input('egg_id'), + 'nest_id' => $request->input('nest_id'), + 'error' => $e->getMessage(), + ]); + + throw $e; + } } /** - * Reinstalls the server on the daemon. + * Apply egg configuration changes asynchronously. + * This dispatches a background job to handle the complete egg change process. * * @throws \Throwable */ - public function reinstall(ReinstallServerRequest $request, Server $server): JsonResponse + public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse { - $this->reinstallServerService->handle($server); - - Activity::event('server:reinstall')->log(); - - return new JsonResponse([], Response::HTTP_ACCEPTED); + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + $dockerImage = $request->input('docker_image'); + $startupCommand = $request->input('startup_command'); + $environment = $request->input('environment', []); + $shouldBackup = $request->input('should_backup', false); + $shouldWipe = $request->input('should_wipe', false); + + $result = $this->eggChangeService->applyEggChangeAsync( + $server, + $request->user(), + $eggId, + $nestId, + $dockerImage, + $startupCommand, + $environment, + $shouldBackup, + $shouldWipe + ); + + Activity::event('server:software.change-queued') + ->property([ + 'operation_id' => $result['operation_id'], + 'from_egg' => $server->egg_id, + 'to_egg' => $eggId, + 'should_backup' => $shouldBackup, + 'should_wipe' => $shouldWipe, + ]) + ->log(); + + return new JsonResponse($result, Response::HTTP_ACCEPTED); + } catch (Exception $e) { + Log::error('Failed to apply egg change', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } } - /** - * Changes the Docker image in use by the server. - * - * @throws \Throwable - */ - public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse + public function getOperationStatus(Server $server, string $operationId): JsonResponse { - if (!in_array($request->input('docker_image'), array_values($server->egg->docker_images))) { - throw new BadRequestHttpException('The requested Docker image is not allowed for this server.'); - } - - $original = $server->image; - $server->forceFill(['image' => $request->input('docker_image')])->saveOrFail(); - - if ($original !== $server->image) { - Activity::event('server:startup.image') - ->property(['old' => $original, 'new' => $request->input('docker_image')]) - ->log(); - } - - return new JsonResponse([], Response::HTTP_NO_CONTENT); + $operation = $this->operationService->getOperation($server, $operationId); + return new JsonResponse($this->operationService->formatOperationResponse($operation)); } - /** - * Reverts the Docker image back to the egg specification. - * - * @throws \Throwable - */ - public function revertDockerImage(RevertDockerImageRequest $request, Server $server): JsonResponse + public function getServerOperations(Server $server): JsonResponse { - // Validate server state before making changes - $server->validateCurrentState(); - - $original = $server->image; - $defaultImage = $server->getDefaultDockerImage(); - - // Ensure we have a valid default image - if (empty($defaultImage)) { - throw new BadRequestHttpException('No default docker image available for this server\'s egg.'); - } - - $server->forceFill(['image' => $defaultImage])->saveOrFail(); - - Activity::event('server:startup.image.reverted') - ->property([ - 'old' => $original, - 'new' => $defaultImage, - 'reverted_to_egg_default' => true, - ]) - ->log(); - - return new JsonResponse([], Response::HTTP_NO_CONTENT); + $operations = $this->operationService->getServerOperations($server); + return new JsonResponse(['operations' => $operations]); } - /** - * Reset Startup Command - */ - private function resetStartupCommand(Server $server): JsonResponse - { - $server->startup = $server->egg->startup; - $server->save(); - - return new JsonResponse([], Response::HTTP_NO_CONTENT); - } - - /** - * Changes the egg for a server. - * - * @throws \Throwable - */ - public function changeEgg(SetEggRequest $request, Server $server): JsonResponse - { - $eggId = $request->input('egg_id'); - $nestId = $request->input('nest_id'); - $originalEggId = $server->egg_id; - $originalNestId = $server->nest_id; - - // Check if the new Egg and Nest IDs are different from the current ones - if ($originalEggId !== $eggId || $originalNestId !== $nestId) { - // Update the server's Egg and Nest IDs - $server->egg_id = $eggId; - $server->nest_id = $nestId; - $server->save(); - - // Log an activity event for the Egg change - Activity::event('server:settings.egg') - ->property(['original_egg_id' => $originalEggId, 'new_egg_id' => $eggId, 'original_nest_id' => $originalNestId, 'new_nest_id' => $nestId]) - ->log(); - - // Reset the server's startup command - $this->resetStartupCommand($server); - } - - return new JsonResponse([], Response::HTTP_NO_CONTENT); - } + } + diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index 60eefd1e1..2492800ed 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -134,9 +134,21 @@ class BackupStatusController extends Controller ]; $client = $adapter->getClient(); + if (!$successful) { - $client->execute($client->getCommand('AbortMultipartUpload', $params)); - + try { + $client->execute($client->getCommand('AbortMultipartUpload', $params)); + \Log::info('Aborted multipart upload for failed backup', [ + 'backup_uuid' => $backup->uuid, + 'upload_id' => $backup->upload_id, + ]); + } catch (\Exception $e) { + \Log::warning('Failed to abort multipart upload', [ + 'backup_uuid' => $backup->uuid, + 'upload_id' => $backup->upload_id, + 'error' => $e->getMessage(), + ]); + } return; } @@ -145,17 +157,56 @@ class BackupStatusController extends Controller 'Parts' => [], ]; - if (is_null($parts)) { - $params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts']; - } else { - foreach ($parts as $part) { - $params['MultipartUpload']['Parts'][] = [ - 'ETag' => $part['etag'], - 'PartNumber' => $part['part_number'], - ]; + try { + if (is_null($parts)) { + $listPartsResult = $client->execute($client->getCommand('ListParts', $params)); + $params['MultipartUpload']['Parts'] = $listPartsResult['Parts'] ?? []; + } else { + foreach ($parts as $part) { + // Validate part data + if (!isset($part['etag']) || !isset($part['part_number'])) { + throw new DisplayException('Invalid part data provided for multipart upload completion.'); + } + + $params['MultipartUpload']['Parts'][] = [ + 'ETag' => $part['etag'], + 'PartNumber' => (int) $part['part_number'], + ]; + } } - } - $client->execute($client->getCommand('CompleteMultipartUpload', $params)); + // Ensure we have parts to complete + if (empty($params['MultipartUpload']['Parts'])) { + throw new DisplayException('No parts found for multipart upload completion.'); + } + + $client->execute($client->getCommand('CompleteMultipartUpload', $params)); + + \Log::info('Successfully completed multipart upload', [ + 'backup_uuid' => $backup->uuid, + 'upload_id' => $backup->upload_id, + 'parts_count' => count($params['MultipartUpload']['Parts']), + ]); + + } catch (\Exception $e) { + \Log::error('Failed to complete multipart upload', [ + 'backup_uuid' => $backup->uuid, + 'upload_id' => $backup->upload_id, + 'error' => $e->getMessage(), + ]); + + // Try to abort the upload to clean up + try { + $client->execute($client->getCommand('AbortMultipartUpload', $params)); + } catch (\Exception $abortException) { + \Log::warning('Failed to abort multipart upload after completion failure', [ + 'backup_uuid' => $backup->uuid, + 'upload_id' => $backup->upload_id, + 'abort_error' => $abortException->getMessage(), + ]); + } + + throw $e; + } } } diff --git a/app/Http/Middleware/Api/Client/Server/ServerOperationRateLimit.php b/app/Http/Middleware/Api/Client/Server/ServerOperationRateLimit.php new file mode 100644 index 000000000..8df6249f2 --- /dev/null +++ b/app/Http/Middleware/Api/Client/Server/ServerOperationRateLimit.php @@ -0,0 +1,90 @@ +route('server'); + $user = $request->user(); + + $this->checkActiveOperations($server); + $this->logOperationAttempt($server, $user, $operationType); + + return $next($request); + } + + /** + * Check for active operations on the same server. + */ + private function checkActiveOperations(Server $server): void + { + try { + if (!$this->tableExists('server_operations')) { + return; + } + + $activeOperations = ServerOperation::forServer($server)->active()->count(); + + if ($activeOperations > 0) { + throw new TooManyRequestsHttpException( + 300, + 'Another operation is currently in progress for this server. Please wait for it to complete.' + ); + } + } catch (\Exception $e) { + Log::warning('Failed to check for active operations', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Check if a database table exists. + */ + private function tableExists(string $tableName): bool + { + try { + return \Schema::hasTable($tableName); + } catch (\Exception $e) { + Log::warning('Failed to check if table exists', [ + 'table' => $tableName, + 'error' => $e->getMessage(), + ]); + return false; + } + } + + /** + * Log operation attempt for monitoring. + */ + private function logOperationAttempt(Server $server, $user, string $operationType): void + { + Log::info('Server operation attempt', [ + 'server_id' => $server->id, + 'server_uuid' => $server->uuid, + 'user_id' => $user->id, + 'operation_type' => $operationType, + 'timestamp' => now()->toISOString(), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Client/Servers/Settings/ApplyEggChangeRequest.php b/app/Http/Requests/Api/Client/Servers/Settings/ApplyEggChangeRequest.php new file mode 100644 index 000000000..2bf6de269 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Settings/ApplyEggChangeRequest.php @@ -0,0 +1,67 @@ + 'required|integer|exists:eggs,id', + 'nest_id' => 'required|integer|exists:nests,id', + 'docker_image' => 'sometimes|string|max:255', + 'startup_command' => 'sometimes|string|max:2048', + 'environment' => 'sometimes|array|max:50', + 'environment.*' => 'nullable|string|max:1024', + 'should_backup' => 'sometimes|boolean', + 'should_wipe' => 'sometimes|boolean', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if ($this->filled(['egg_id', 'nest_id'])) { + $egg = Egg::where('id', $this->input('egg_id')) + ->where('nest_id', $this->input('nest_id')) + ->first(); + + if (!$egg) { + $validator->errors()->add('egg_id', 'The selected egg does not belong to the specified nest.'); + return; + } + + if ($this->filled('docker_image')) { + $dockerImages = array_values($egg->docker_images ?? []); + if (!empty($dockerImages) && !in_array($this->input('docker_image'), $dockerImages)) { + $validator->errors()->add('docker_image', 'The selected Docker image is not allowed for this egg.'); + } + } + + if ($this->filled('environment')) { + $eggVariables = $egg->variables()->pluck('env_variable')->toArray(); + foreach ($this->input('environment', []) as $key => $value) { + if (!in_array($key, $eggVariables)) { + $validator->errors()->add("environment.{$key}", 'This environment variable is not valid for the selected egg.'); + } + } + } + } + }); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Client/Servers/Settings/PreviewEggRequest.php b/app/Http/Requests/Api/Client/Servers/Settings/PreviewEggRequest.php new file mode 100644 index 000000000..ea9c93f5e --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Settings/PreviewEggRequest.php @@ -0,0 +1,43 @@ + 'required|integer|exists:eggs,id', + 'nest_id' => 'required|integer|exists:nests,id', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if ($this->filled(['egg_id', 'nest_id'])) { + $egg = Egg::where('id', $this->input('egg_id')) + ->where('nest_id', $this->input('nest_id')) + ->first(); + + if (!$egg) { + $validator->errors()->add('egg_id', 'The selected egg does not belong to the specified nest.'); + } + } + }); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Client/Servers/Settings/ServerOperationRequest.php b/app/Http/Requests/Api/Client/Servers/Settings/ServerOperationRequest.php new file mode 100644 index 000000000..8fb365efe --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Settings/ServerOperationRequest.php @@ -0,0 +1,43 @@ +user()->can('settings.egg', $this->route()->parameter('server')); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'operation_id' => 'required|string|uuid', + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'operation_id.required' => 'An operation ID is required.', + 'operation_id.uuid' => 'The operation ID must be a valid UUID.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/ServerOperationResource.php b/app/Http/Resources/ServerOperationResource.php new file mode 100644 index 000000000..33939e6ca --- /dev/null +++ b/app/Http/Resources/ServerOperationResource.php @@ -0,0 +1,44 @@ +resource; + + return [ + 'operation_id' => $operation->operation_id, + 'type' => $operation->type, + 'status' => $operation->status, + 'message' => $operation->message, + 'created_at' => $operation->created_at->toISOString(), + 'updated_at' => $operation->updated_at->toISOString(), + 'started_at' => $operation->started_at?->toISOString(), + 'duration_seconds' => $operation->getDurationInSeconds(), + 'parameters' => $operation->parameters, + 'meta' => [ + 'is_active' => $operation->isActive(), + 'is_completed' => $operation->isCompleted(), + 'has_failed' => $operation->hasFailed(), + 'has_timed_out' => $operation->hasTimedOut(), + 'can_be_cancelled' => $operation->isActive() && !$operation->hasFailed(), + ], + ]; + } +} \ No newline at end of file diff --git a/app/Jobs/Server/ApplyEggChangeJob.php b/app/Jobs/Server/ApplyEggChangeJob.php new file mode 100644 index 000000000..10f00ad3a --- /dev/null +++ b/app/Jobs/Server/ApplyEggChangeJob.php @@ -0,0 +1,341 @@ +queue = 'standard'; + $this->timeout = config('server_operations.timeouts.egg_change', 1800); + } + + /** + * Execute the egg change job. + */ + public function handle( + InitiateBackupService $backupService, + ReinstallServerService $reinstallServerService, + StartupModificationService $startupModificationService, + DaemonFileRepository $fileRepository, + ServerOperationService $operationService + ): void { + $operation = null; + + try { + $operation = ServerOperation::where('operation_id', $this->operationId)->firstOrFail(); + $operation->markAsStarted(); + + Activity::actor($this->user)->event('server:software.change-started') + ->property([ + 'operation_id' => $this->operationId, + 'from_egg' => $this->server->egg_id, + 'to_egg' => $this->eggId, + 'should_backup' => $this->shouldBackup, + 'should_wipe' => $this->shouldWipe, + ]) + ->log(); + + $egg = Egg::query() + ->with(['variables', 'nest']) + ->findOrFail($this->eggId); + + $backup = null; + + if ($this->shouldBackup) { + $backup = $this->createBackup($backupService, $operation); + } + + if ($this->shouldWipe) { + $this->wipeServerFiles($fileRepository, $operation, $backup); + } + + $this->applyServerChanges($egg, $startupModificationService, $reinstallServerService, $operation); + + $this->logSuccessfulChange(); + + $operation->markAsCompleted('Software configuration applied successfully. Server installation completed.'); + + } catch (Exception $e) { + $this->handleJobFailure($e, $operation); + throw $e; + } + } + + /** + * Create backup before proceeding with changes. + */ + private function createBackup(InitiateBackupService $backupService, ServerOperation $operation): Backup + { + $operation->updateProgress('Creating backup before proceeding...'); + + $backupName = "Software Change Backup - " . now()->format('Y-m-d H:i:s'); + $backup = $backupService + ->setIsLocked(false) + ->handle($this->server, $backupName); + + Activity::actor($this->user)->event('server:backup.software-change') + ->property([ + 'backup_name' => $backupName, + 'backup_uuid' => $backup->uuid, + 'operation_id' => $this->operationId, + 'from_egg' => $this->server->egg_id, + 'to_egg' => $this->eggId, + ]) + ->log(); + + $operation->updateProgress('Waiting for backup to complete...'); + $this->waitForBackupCompletion($backup, $operation); + + $backup->refresh(); + if (!$backup->is_successful) { + throw new BackupFailedException('Backup failed. Aborting software change to prevent data loss.'); + } + + return $backup; + } + + /** + * Wipe server files if requested. + */ + private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation, ?Backup $backup): void + { + $operation->updateProgress('Wiping server files...'); + + try { + $contents = $fileRepository->setServer($this->server)->getDirectory('/'); + + if (!empty($contents)) { + $filesToDelete = array_map(function($item) { + return $item['name']; + }, $contents); + + if (count($filesToDelete) > 1000) { + Log::warning('Large number of files to delete', [ + 'server_id' => $this->server->id, + 'file_count' => count($filesToDelete), + ]); + } + + $fileRepository->setServer($this->server)->deleteFiles('/', $filesToDelete); + + Activity::actor($this->user)->event('server:files.software-change-wipe') + ->property([ + 'operation_id' => $this->operationId, + 'from_egg' => $this->server->egg_id, + 'to_egg' => $this->eggId, + 'files_deleted' => count($filesToDelete), + 'backup_verified' => $backup ? true : false, + ]) + ->log(); + } + } catch (Exception $e) { + Log::error('Failed to wipe files', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + if (!$backup) { + throw new \RuntimeException('File wipe failed and no backup was created. Aborting operation to prevent data loss.'); + } + } + } + + /** + * Apply server configuration changes. + */ + private function applyServerChanges( + Egg $egg, + StartupModificationService $startupModificationService, + ReinstallServerService $reinstallServerService, + ServerOperation $operation + ): void { + $operation->updateProgress('Applying software configuration...'); + + DB::transaction(function () use ($egg, $startupModificationService, $reinstallServerService, $operation) { + if ($this->server->egg_id !== $this->eggId || $this->server->nest_id !== $this->nestId) { + $this->server->update([ + 'egg_id' => $this->eggId, + 'nest_id' => $this->nestId, + ]); + } + + $updateData = [ + 'startup' => $this->startupCommand ?: $egg->startup, + 'docker_image' => $this->dockerImage, + 'environment' => $this->environment, + ]; + + $updatedServer = $startupModificationService + ->setUserLevel(User::USER_LEVEL_ADMIN) + ->handle($this->server, $updateData); + + $operation->updateProgress('Reinstalling server...'); + $reinstallServerService->handle($updatedServer); + + $operation->updateProgress('Finalizing installation...'); + }); + } + + /** + * Log successful software change. + */ + private function logSuccessfulChange(): void + { + Activity::actor($this->user)->event('server:software.changed') + ->property([ + 'operation_id' => $this->operationId, + 'original_egg_id' => $this->server->getOriginal('egg_id'), + 'new_egg_id' => $this->eggId, + 'original_nest_id' => $this->server->getOriginal('nest_id'), + 'new_nest_id' => $this->nestId, + 'original_image' => $this->server->getOriginal('image'), + 'new_image' => $this->dockerImage, + 'backup_created' => $this->shouldBackup, + 'files_wiped' => $this->shouldWipe, + ]) + ->log(); + } + + /** + * Handle job failure. + */ + public function failed(\Throwable $exception): void + { + try { + $operation = ServerOperation::where('operation_id', $this->operationId)->first(); + + Log::error('Egg change job failed', [ + 'server_id' => $this->server->id, + 'operation_id' => $this->operationId, + 'error' => $exception->getMessage(), + ]); + + if ($operation) { + $operation->markAsFailed('Job failed: ' . $exception->getMessage()); + } + + Activity::actor($this->user)->event('server:software.change-job-failed') + ->property([ + 'operation_id' => $this->operationId, + 'error' => $exception->getMessage(), + 'attempted_egg_id' => $this->eggId, + ]) + ->log(); + } catch (\Throwable $e) { + Log::critical('Failed to handle job failure properly', [ + 'operation_id' => $this->operationId, + 'original_error' => $exception->getMessage(), + 'handler_error' => $e->getMessage(), + ]); + } + } + + /** + * Wait for backup completion with timeout monitoring. + */ + private function waitForBackupCompletion(Backup $backup, ServerOperation $operation, int $timeoutMinutes = 30): void + { + $startTime = Carbon::now(); + $timeout = $startTime->addMinutes($timeoutMinutes); + $lastProgressUpdate = 0; + + while (Carbon::now()->lt($timeout)) { + $backup->refresh(); + + if ($backup->is_successful && !is_null($backup->completed_at)) { + $operation->updateProgress('Backup completed successfully'); + return; + } + + if (!is_null($backup->completed_at) && !$backup->is_successful) { + throw new BackupFailedException('Backup failed during creation process.'); + } + + $elapsed = Carbon::now()->diffInSeconds($startTime); + if ($elapsed - $lastProgressUpdate >= 30) { + $minutes = floor($elapsed / 60); + $seconds = $elapsed % 60; + $timeStr = $minutes > 0 ? "{$minutes}m {$seconds}s" : "{$seconds}s"; + $operation->updateProgress("Backup in progress... ({$timeStr} elapsed)"); + $lastProgressUpdate = $elapsed; + } + + sleep(5); + } + + throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.'); + } + + /** + * Handle job failure with error logging. + */ + private function handleJobFailure(\Throwable $exception, ?ServerOperation $operation): void + { + Log::error('Egg change job failed', [ + 'operation_id' => $this->operationId, + 'error' => $exception->getMessage(), + 'server_id' => $this->server->id, + 'user_id' => $this->user->id, + ]); + + if ($operation) { + $operation->markAsFailed('Operation failed: ' . $exception->getMessage()); + } + + Activity::actor($this->user)->event('server:software.change-failed') + ->property([ + 'operation_id' => $this->operationId, + 'error' => $exception->getMessage(), + 'attempted_egg_id' => $this->eggId, + 'attempted_nest_id' => $this->nestId, + ]) + ->log(); + } +} \ No newline at end of file diff --git a/app/Models/ServerOperation.php b/app/Models/ServerOperation.php new file mode 100644 index 000000000..c0089e761 --- /dev/null +++ b/app/Models/ServerOperation.php @@ -0,0 +1,165 @@ + 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'started_at' => 'datetime', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isActive(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_RUNNING]); + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function hasFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + public function scopeForServer($query, Server $server) + { + return $query->where('server_id', $server->id); + } + + public function scopeActive($query) + { + return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_RUNNING]); + } + + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + public function scopeTimedOut($query, int $timeoutMinutes = 30) + { + return $query->where('status', self::STATUS_RUNNING) + ->whereNotNull('started_at') + ->where('started_at', '<', now()->subMinutes($timeoutMinutes)); + } + + public function scopeForCleanup($query, int $daysOld = 30) + { + return $query->whereIn('status', [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED]) + ->where('created_at', '<', now()->subDays($daysOld)); + } + + /** + * Check if the operation has exceeded the timeout threshold. + */ + public function hasTimedOut(int $timeoutMinutes = 30): bool + { + if (!$this->isActive() || !$this->started_at) { + return false; + } + + return $this->started_at->diffInMinutes(now()) > $timeoutMinutes; + } + + /** + * Mark operation as started and update status. + */ + public function markAsStarted(): bool + { + return $this->update([ + 'status' => self::STATUS_RUNNING, + 'started_at' => now(), + 'message' => 'Operation started...', + ]); + } + + /** + * Mark operation as completed with optional message. + */ + public function markAsCompleted(string $message = 'Operation completed successfully'): bool + { + return $this->update([ + 'status' => self::STATUS_COMPLETED, + 'message' => $message, + ]); + } + + /** + * Mark operation as failed with error message. + */ + public function markAsFailed(string $message): bool + { + return $this->update([ + 'status' => self::STATUS_FAILED, + 'message' => $message, + ]); + } + + /** + * Update operation progress message. + */ + public function updateProgress(string $message): bool + { + return $this->update(['message' => $message]); + } + + /** + * Get operation duration in seconds if started. + */ + public function getDurationInSeconds(): ?int + { + if (!$this->started_at) { + return null; + } + + return $this->started_at->diffInSeconds(now()); + } +} \ No newline at end of file diff --git a/app/Providers/ServerOperationServiceProvider.php b/app/Providers/ServerOperationServiceProvider.php new file mode 100644 index 000000000..78d4fbb3f --- /dev/null +++ b/app/Providers/ServerOperationServiceProvider.php @@ -0,0 +1,52 @@ +commands([ + CleanupServerOperationsCommand::class, + ]); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + $router = $this->app['router']; + $router->aliasMiddleware('server.operation.rate-limit', ServerOperationRateLimit::class); + + if (config('server_operations.cleanup.enabled', true)) { + $this->app->booted(function () { + $schedule = $this->app->make(Schedule::class); + + $schedule->command('p:server:cleanup-operations --force') + ->daily() + ->at('02:00') + ->withoutOverlapping() + ->runInBackground(); + }); + } + + $this->publishes([ + __DIR__ . '/../../config/server_operations.php' => config_path('server_operations.php'), + ], 'server-operations-config'); + } +} \ No newline at end of file diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php index b77ac8e1c..d6e8c5775 100644 --- a/app/Services/Backups/DeleteBackupService.php +++ b/app/Services/Backups/DeleteBackupService.php @@ -68,15 +68,29 @@ class DeleteBackupService protected function deleteFromS3(Backup $backup): void { $this->connection->transaction(function () use ($backup) { - $backup->delete(); - /** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */ $adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3); - $adapter->getClient()->deleteObject([ - 'Bucket' => $adapter->getBucket(), - 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), - ]); + $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 index c00b46c8a..a49ee2cc4 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -75,6 +75,9 @@ class InitiateBackupService */ public function handle(Server $server, ?string $name = null, bool $override = false): Backup { + // Validate server state before creating backup + $this->validateServerForBackup($server); + $limit = config('backups.throttles.limit'); $period = config('backups.throttles.period'); if ($period > 0) { @@ -108,21 +111,54 @@ class InitiateBackupService } return $this->connection->transaction(function () use ($server, $name) { + // Sanitize backup name to prevent injection + $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 + /** @var Backup $backup */ $backup = $this->repository->create([ 'server_id' => $server->id, 'uuid' => Uuid::uuid4()->toString(), - 'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()), + 'name' => $backupName, 'ignored_files' => array_values($this->ignoredFiles ?? []), 'disk' => $this->backupManager->getDefaultAdapter(), 'is_locked' => $this->isLocked, ], true, true); - $this->daemonBackupRepository->setServer($server) - ->setBackupAdapter($this->backupManager->getDefaultAdapter()) - ->backup($backup); + try { + $this->daemonBackupRepository->setServer($server) + ->setBackupAdapter($this->backupManager->getDefaultAdapter()) + ->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/ServerOperations/EggChangeService.php b/app/Services/ServerOperations/EggChangeService.php new file mode 100644 index 000000000..2988fdc1e --- /dev/null +++ b/app/Services/ServerOperations/EggChangeService.php @@ -0,0 +1,205 @@ +validationService->validateServerState($server); + + $egg = Egg::query() + ->with(['variables', 'nest']) + ->findOrFail($eggId); + + if ($egg->nest_id !== $nestId) { + throw new BadRequestHttpException('The specified egg does not belong to the specified nest.'); + } + + $variables = $egg->variables()->orderBy('name')->get(); + $dockerImages = $egg->docker_images ?? []; + + return [ + 'egg' => [ + 'id' => $egg->id, + 'name' => e($egg->name), + 'description' => e($egg->description), + 'startup' => $egg->startup, + ], + 'variables' => $variables->map(function ($variable) { + return [ + 'id' => $variable->id, + 'name' => e($variable->name), + 'description' => e($variable->description), + 'env_variable' => $variable->env_variable, + 'default_value' => $variable->default_value, + 'user_viewable' => $variable->user_viewable, + 'user_editable' => $variable->user_editable, + 'rules' => $variable->rules, + ]; + }), + 'docker_images' => $dockerImages, + 'default_docker_image' => !empty($dockerImages) ? array_keys($dockerImages)[0] : null, + ]; + } + + /** + * Validate egg change parameters. + */ + public function validateEggChangeParameters( + Server $server, + int $eggId, + int $nestId, + ?string $dockerImage = null, + ?string $startupCommand = null + ): array { + $this->validationService->validateCanAcceptOperation($server, 'egg_change'); + + $egg = Egg::query() + ->with(['variables', 'nest']) + ->findOrFail($eggId); + + if ($egg->nest_id !== $nestId) { + throw new BadRequestHttpException('The specified egg does not belong to the specified nest.'); + } + + $startupCommand = $startupCommand ? trim($startupCommand) : null; + $dockerImage = $dockerImage ? trim($dockerImage) : null; + + if ($startupCommand && strlen($startupCommand) > 2048) { + throw new BadRequestHttpException('Startup command is too long (max 2048 characters).'); + } + + if ($dockerImage) { + $allowedImages = array_values($egg->docker_images ?? []); + if (!empty($allowedImages) && !in_array($dockerImage, $allowedImages)) { + throw new BadRequestHttpException('The specified Docker image is not allowed for this egg.'); + } + } + + if (!$dockerImage && !empty($egg->docker_images)) { + $dockerImage = array_values($egg->docker_images)[0]; + } + + return [ + 'egg' => $egg, + 'docker_image' => $dockerImage, + 'startup_command' => $startupCommand, + ]; + } + + /** + * Apply egg change asynchronously. + */ + public function applyEggChangeAsync( + Server $server, + User $user, + int $eggId, + int $nestId, + ?string $dockerImage = null, + ?string $startupCommand = null, + array $environment = [], + bool $shouldBackup = false, + bool $shouldWipe = false + ): array { + $validated = $this->validateEggChangeParameters( + $server, + $eggId, + $nestId, + $dockerImage, + $startupCommand + ); + + $dockerImage = $validated['docker_image']; + $startupCommand = $validated['startup_command']; + + $operation = $this->operationService->createOperation( + $server, + $user, + 'egg_change', + [ + 'from_egg_id' => $server->egg_id, + 'to_egg_id' => $eggId, + 'from_nest_id' => $server->nest_id, + 'to_nest_id' => $nestId, + 'docker_image' => $dockerImage, + 'startup_command' => $startupCommand, + 'environment' => $environment, + 'should_backup' => $shouldBackup, + 'should_wipe' => $shouldWipe, + ] + ); + + try { + ApplyEggChangeJob::dispatch( + $server, + $user, + $eggId, + $nestId, + $dockerImage, + $startupCommand, + $environment, + $shouldBackup, + $shouldWipe, + $operation->operation_id + ); + } catch (Exception $e) { + $operation->delete(); + + Log::error('Failed to dispatch egg change job', [ + 'server_id' => $server->id, + 'operation_id' => $operation->operation_id, + 'error' => $e->getMessage(), + ]); + + throw new \RuntimeException('Failed to queue egg change operation. Please try again.'); + } + + return [ + 'message' => 'Egg change operation has been queued for processing.', + 'operation_id' => $operation->operation_id, + 'status' => 'pending', + 'estimated_duration' => 'This operation may take several minutes to complete.', + ]; + } + + /** + * Get estimated duration for egg change operation. + */ + public function getEstimatedDuration(bool $shouldBackup, bool $shouldWipe): string + { + $baseTime = 2; + + if ($shouldBackup) { + $baseTime += 5; + } + + if ($shouldWipe) { + $baseTime += 2; + } + + return "Estimated duration: {$baseTime}-" . ($baseTime + 3) . " minutes"; + } +} \ No newline at end of file diff --git a/app/Services/ServerOperations/ServerOperationService.php b/app/Services/ServerOperations/ServerOperationService.php new file mode 100644 index 000000000..0222733b8 --- /dev/null +++ b/app/Services/ServerOperations/ServerOperationService.php @@ -0,0 +1,173 @@ +active()->count(); + return $activeOperations === 0; + } catch (Exception $e) { + Log::warning('Failed to check server operation capacity', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return true; + } + } + + /** + * Create a new server operation. + */ + public function createOperation( + Server $server, + User $user, + string $type, + array $parameters = [], + ?string $message = null + ): ServerOperation { + if (!$this->canAcceptOperation($server)) { + throw new ConflictHttpException('Server cannot accept new operations at this time.'); + } + + $operationId = Str::uuid()->toString(); + + return ServerOperation::create([ + 'operation_id' => $operationId, + 'server_id' => $server->id, + 'user_id' => $user->id, + 'type' => $type, + 'status' => ServerOperation::STATUS_PENDING, + 'message' => $message ?? 'Operation queued for processing...', + 'parameters' => $parameters, + ]); + } + + /** + * Get operation by ID for server. + */ + public function getOperation(Server $server, string $operationId): ServerOperation + { + $operation = ServerOperation::where('operation_id', $operationId) + ->where('server_id', $server->id) + ->firstOrFail(); + + if ($operation->hasTimedOut()) { + $operation->markAsFailed('Operation timed out'); + } + + return $operation; + } + + /** + * Get recent operations for server. + */ + public function getServerOperations(Server $server, int $limit = 20): array + { + $this->updateTimedOutOperations($server); + + $operations = ServerOperation::forServer($server) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return $operations->map(function ($operation) { + return $this->formatOperationResponse($operation); + })->toArray(); + } + + /** + * Update timed out operations for a server. + */ + public function updateTimedOutOperations(Server $server): int + { + try { + $timedOutOperations = ServerOperation::forServer($server)->timedOut()->get(); + + foreach ($timedOutOperations as $operation) { + $operation->markAsFailed('Operation timed out'); + } + + return $timedOutOperations->count(); + } catch (Exception $e) { + Log::warning('Failed to update timed out operations', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return 0; + } + } + + /** + * Format operation for API response. + */ + public function formatOperationResponse(ServerOperation $operation): array + { + return [ + 'operation_id' => $operation->operation_id, + 'type' => $operation->type, + 'status' => $operation->status, + 'message' => $operation->message, + 'created_at' => $operation->created_at->toDateTimeString(), + 'updated_at' => $operation->updated_at->toDateTimeString(), + 'started_at' => $operation->started_at?->toDateTimeString(), + 'duration' => $operation->getDurationInSeconds(), + 'parameters' => $operation->parameters, + 'is_active' => $operation->isActive(), + 'is_completed' => $operation->isCompleted(), + 'has_failed' => $operation->hasFailed(), + 'has_timed_out' => $operation->hasTimedOut(), + ]; + } + + /** + * Clean up old completed operations. + */ + public function cleanupOldOperations(int $daysOld = null): int + { + $daysOld = $daysOld ?? config('server_operations.cleanup.retain_days', 30); + $chunkSize = config('server_operations.cleanup.chunk_size', 100); + + try { + $deletedCount = 0; + + ServerOperation::forCleanup($daysOld) + ->chunk($chunkSize, function ($operations) use (&$deletedCount) { + foreach ($operations as $operation) { + $operation->delete(); + $deletedCount++; + } + }); + + return $deletedCount; + } catch (Exception $e) { + Log::error('Failed to cleanup old server operations', [ + 'error' => $e->getMessage(), + 'days_old' => $daysOld, + ]); + + return 0; + } + } +} \ No newline at end of file diff --git a/app/Services/ServerOperations/ServerStateValidationService.php b/app/Services/ServerOperations/ServerStateValidationService.php new file mode 100644 index 000000000..837b5b22c --- /dev/null +++ b/app/Services/ServerOperations/ServerStateValidationService.php @@ -0,0 +1,73 @@ +status === Server::STATUS_INSTALLING) { + throw new ConflictHttpException('Server is currently being installed and cannot be modified.'); + } + + if ($server->status === Server::STATUS_SUSPENDED) { + throw new ConflictHttpException('Server is suspended and cannot be modified.'); + } + + if ($server->transfer) { + throw new ConflictHttpException('Server is currently being transferred and cannot be modified.'); + } + + $server->refresh(); + } catch (\Exception $e) { + Log::error('Failed to validate server state', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + if ($e instanceof ConflictHttpException) { + throw $e; + } + + Log::warning('Server state validation failed, allowing request to proceed', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Check for active operations on server. + */ + public function checkForActiveOperations(Server $server): void + { + $activeOperation = ServerOperation::forServer($server)->active()->first(); + if ($activeOperation) { + throw new ConflictHttpException('Another operation is currently in progress for this server. Please wait for it to complete.'); + } + } + + /** + * Validate server can accept the operation. + */ + public function validateCanAcceptOperation(Server $server, string $operationType): void + { + $this->validateServerState($server); + $this->checkForActiveOperations($server); + } +} \ No newline at end of file diff --git a/app/Services/Servers/VariableValidatorService.php b/app/Services/Servers/VariableValidatorService.php index 676a84aba..e4d94bb41 100644 --- a/app/Services/Servers/VariableValidatorService.php +++ b/app/Services/Servers/VariableValidatorService.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Services\Servers; +use Illuminate\Support\Arr; use Pterodactyl\Models\User; use Illuminate\Support\Collection; use Pterodactyl\Models\EggVariable; @@ -39,8 +40,15 @@ class VariableValidatorService $data = $rules = $customAttributes = []; foreach ($variables as $variable) { - $data['environment'][$variable->env_variable] = array_get($fields, $variable->env_variable); - $rules['environment.' . $variable->env_variable] = $variable->rules; + $value = Arr::get($fields, $variable->env_variable); + $data['environment'][$variable->env_variable] = $value; + + // Make rules nullable to handle empty environment variables, but don't duplicate if already nullable + $rules_string = $variable->rules; + if (!str_starts_with($rules_string, 'nullable')) { + $rules_string = 'nullable|' . $rules_string; + } + $rules['environment.' . $variable->env_variable] = $rules_string; $customAttributes['environment.' . $variable->env_variable] = trans('validation.internal.variable_value', ['env' => $variable->name]); } diff --git a/config/app.php b/config/app.php index 87e1ac093..f702a44a4 100644 --- a/config/app.php +++ b/config/app.php @@ -201,6 +201,7 @@ return [ Pterodactyl\Providers\HashidsServiceProvider::class, Pterodactyl\Providers\RouteServiceProvider::class, Pterodactyl\Providers\RepositoryServiceProvider::class, + Pterodactyl\Providers\ServerOperationServiceProvider::class, Pterodactyl\Providers\ViewComposerServiceProvider::class, /* diff --git a/config/server_operations.php b/config/server_operations.php new file mode 100644 index 000000000..ef7a80458 --- /dev/null +++ b/config/server_operations.php @@ -0,0 +1,41 @@ + [ + 'egg_change' => 1800, // 30 minutes + 'reinstall' => 1200, // 20 minutes + 'backup_restore' => 2400, // 40 minutes + 'default' => 900, // 15 minutes + ], + + /* + |-------------------------------------------------------------------------- + | Operation Cleanup + |-------------------------------------------------------------------------- + | + | Configuration for automatic cleanup of old completed operations + | to prevent database bloat and maintain performance. + | + */ + 'cleanup' => [ + 'enabled' => true, // Enable automatic cleanup + 'retain_days' => 30, // Days to retain completed operations + 'chunk_size' => 100, // Records to process per cleanup batch + ], +]; \ No newline at end of file diff --git a/database/migrations/2025_08_14_025040_create_server_operations_table.php b/database/migrations/2025_08_14_025040_create_server_operations_table.php new file mode 100644 index 000000000..1fd6c8227 --- /dev/null +++ b/database/migrations/2025_08_14_025040_create_server_operations_table.php @@ -0,0 +1,52 @@ +id(); + $table->string('operation_id', 36)->unique(); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('user_id'); + $table->string('type', 50); + $table->string('status', 20)->default('pending'); + $table->text('message')->nullable(); + $table->json('parameters')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamps(); + + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + $table->index(['server_id', 'status', 'created_at'], 'server_operations_server_status_created'); + $table->index(['type', 'status', 'created_at'], 'server_operations_type_status_created'); + $table->index(['status', 'created_at'], 'server_operations_status_created'); + $table->index(['server_id', 'status'], 'server_operations_server_status'); + $table->index(['status', 'started_at'], 'server_operations_status_started'); + $table->index(['user_id', 'type', 'created_at'], 'server_operations_user_type_created'); + $table->index(['operation_id', 'server_id'], 'server_operations_operation_server'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('server_operations'); + } +}; \ No newline at end of file diff --git a/resources/scripts/api/server/applyEggChange.ts b/resources/scripts/api/server/applyEggChange.ts new file mode 100644 index 000000000..00c8c74f3 --- /dev/null +++ b/resources/scripts/api/server/applyEggChange.ts @@ -0,0 +1,27 @@ +import http from '@/api/http'; + +export interface ApplyEggChangeRequest { + egg_id: number; + nest_id: number; + docker_image?: string; + startup_command?: string; + environment?: Record; + should_backup?: boolean; + should_wipe?: boolean; +} + +export interface ApplyEggChangeResponse { + message: string; + operation_id: string; + status: string; + estimated_duration: string; +} + +/** + * Apply egg configuration changes to a server asynchronously. + * This initiates a background operation to change the server's egg configuration. + */ +export default async (uuid: string, data: ApplyEggChangeRequest): Promise => { + const { data: response } = await http.post(`/api/client/servers/${uuid}/settings/egg/apply`, data); + return response; +}; \ No newline at end of file diff --git a/resources/scripts/api/server/previewEggChange.ts b/resources/scripts/api/server/previewEggChange.ts new file mode 100644 index 000000000..e92c6b6fa --- /dev/null +++ b/resources/scripts/api/server/previewEggChange.ts @@ -0,0 +1,35 @@ +import http from '@/api/http'; + +export interface EggPreview { + egg: { + id: number; + name: string; + description: string; + startup: string; + }; + variables: Array<{ + id: number; + name: string; + description: string; + env_variable: string; + default_value: string; + user_viewable: boolean; + user_editable: boolean; + rules: string; + }>; + docker_images: Record; + default_docker_image: string | null; +} + +/** + * Preview egg configuration changes before applying them. + * Returns egg details, variables, and available Docker images. + */ +export default async (uuid: string, eggId: number, nestId: number): Promise => { + const { data } = await http.post(`/api/client/servers/${uuid}/settings/egg/preview`, { + egg_id: eggId, + nest_id: nestId, + }); + + return data; +}; \ No newline at end of file diff --git a/resources/scripts/api/server/serverOperations.ts b/resources/scripts/api/server/serverOperations.ts new file mode 100644 index 000000000..3f071608d --- /dev/null +++ b/resources/scripts/api/server/serverOperations.ts @@ -0,0 +1,175 @@ +import React from 'react'; +import http from '@/api/http'; + +/** + * Server operation status constants. + */ +export const OPERATION_STATUS = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + FAILED: 'failed', + CANCELLED: 'cancelled', +} as const; + +export type OperationStatus = typeof OPERATION_STATUS[keyof typeof OPERATION_STATUS]; + +/** + * Polling configuration for operation status updates. + */ +export const POLLING_CONFIG = { + INITIAL_INTERVAL: 2000, + MAX_INTERVAL: 8000, + MAX_ATTEMPTS: 90, + JITTER_RANGE: 500, + BACKOFF_MULTIPLIER: 1.05, + BACKOFF_THRESHOLD: 5, +}; + +export interface ServerOperation { + operation_id: string; + type: string; + status: OperationStatus; + message: string; + created_at: string; + updated_at: string; + parameters?: Record; + is_active: boolean; + is_completed: boolean; + has_failed: boolean; +} + +export interface ApplyEggChangeAsyncResponse { + message: string; + operation_id: string; + status: string; + estimated_duration: string; +} + +/** + * Get specific operation status by ID. + */ +export const getOperationStatus = async (uuid: string, operationId: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/operations/${operationId}`); + return data; +}; + +/** + * Get all operations for a server. + */ +export const getServerOperations = async (uuid: string): Promise<{ operations: ServerOperation[] }> => { + const { data } = await http.get(`/api/client/servers/${uuid}/operations`); + return data; +}; + +/** + * Poll operation status with exponential backoff and jitter. + */ +export const pollOperationStatus = ( + uuid: string, + operationId: string, + onUpdate: (operation: ServerOperation) => void, + onComplete: (operation: ServerOperation) => void, + onError: (error: Error) => void +): (() => void) => { + let timeoutId: NodeJS.Timeout | null = null; + let intervalMs = POLLING_CONFIG.INITIAL_INTERVAL; + const maxInterval = POLLING_CONFIG.MAX_INTERVAL; + let attempts = 0; + let stopped = false; + + const poll = async () => { + if (stopped) return; + + try { + attempts++; + + if (attempts > POLLING_CONFIG.MAX_ATTEMPTS) { + onError(new Error('Operation polling timed out after 15 minutes')); + return; + } + + const operation = await getOperationStatus(uuid, operationId); + + if (stopped) return; + + onUpdate(operation); + + if (operation.is_completed || operation.has_failed) { + onComplete(operation); + return; + } + + if (operation.is_active) { + if (attempts > POLLING_CONFIG.BACKOFF_THRESHOLD) { + intervalMs = Math.min(intervalMs * POLLING_CONFIG.BACKOFF_MULTIPLIER, maxInterval); + } + + const jitter = Math.random() * POLLING_CONFIG.JITTER_RANGE; + timeoutId = setTimeout(poll, intervalMs + jitter); + } else { + onError(new Error('Operation is no longer active')); + } + } catch (error) { + if (!stopped) { + onError(error as Error); + } + } + }; + + timeoutId = setTimeout(poll, 1000); + + return () => { + stopped = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; +}; + +/** + * React hook for managing operation polling lifecycle. + */ +export const useOperationPolling = () => { + const activePollers = React.useRef(new Map void>()).current; + + React.useEffect(() => { + return () => { + activePollers.forEach(cleanup => cleanup()); + activePollers.clear(); + }; + }, [activePollers]); + + const startPolling = React.useCallback(( + uuid: string, + operationId: string, + onUpdate: (operation: ServerOperation) => void, + onComplete: (operation: ServerOperation) => void, + onError: (error: Error) => void + ) => { + stopPolling(operationId); + const cleanup = pollOperationStatus(uuid, operationId, onUpdate, onComplete, onError); + activePollers.set(operationId, cleanup); + }, [activePollers]); + + const stopPolling = React.useCallback((operationId: string) => { + const cleanup = activePollers.get(operationId); + if (cleanup) { + cleanup(); + activePollers.delete(operationId); + } + }, [activePollers]); + + const stopAllPolling = React.useCallback(() => { + activePollers.forEach(cleanup => cleanup()); + activePollers.clear(); + }, [activePollers]); + + return { + startPolling, + stopPolling, + stopAllPolling, + hasActivePolling: (operationId: string) => activePollers.has(operationId) + }; +}; \ No newline at end of file diff --git a/resources/scripts/api/server/setSelectedEggImage.ts b/resources/scripts/api/server/setSelectedEggImage.ts deleted file mode 100644 index 0c23a0d00..000000000 --- a/resources/scripts/api/server/setSelectedEggImage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import http from '@/api/http'; - -export default async (uuid: string, eggid: number, nestid: number): Promise => { - await http.put(`/api/client/servers/${uuid}/settings/egg`, { egg_id: eggid, nest_id: nestid }); - - const { data } = await http.get(`/api/client/servers/${uuid}/startup`); - const docker_images = data.meta.docker_images || {}; - const image = Object.values(docker_images)[0] as string; - - await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image }); -}; diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index bc41dae90..798a2348f 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -39,7 +39,7 @@ const AccountApiContainer = () => { const [apiKey, setApiKey] = useState(''); const [showKeys, setShowKeys] = useState>({}); - const { clearAndAddHttpError } = useFlashKey('account'); + const { clearAndAddHttpError } = useFlashKey('api-keys'); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); useEffect(() => { diff --git a/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx b/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx index 4cef4abd8..7c105b6ff 100644 --- a/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx +++ b/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx @@ -62,7 +62,7 @@ const AccountSSHContainer = () => { }; const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers) => { - clearFlashes('account'); + clearFlashes('ssh-keys'); createSSHKey(values.name, values.publicKey) .then((key) => { resetForm(); diff --git a/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx b/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx index b825cdb33..d0bc6347e 100644 --- a/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx +++ b/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx @@ -27,7 +27,7 @@ const CreateSSHKeyForm = () => { const { mutate } = useSSHKeys(); const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers) => { - clearFlashes('account'); + clearFlashes('ssh-keys'); createSSHKey(values.name, values.publicKey) .then((key) => { resetForm(); @@ -37,7 +37,7 @@ const CreateSSHKeyForm = () => { }) .catch((error) => { console.error(error); - addError({ key: 'account', message: httpErrorToHuman(error) }); + addError({ key: 'ssh-keys', message: httpErrorToHuman(error) }); setSubmitting(false); }); }; diff --git a/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx b/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx index ce57eaf55..0321b970d 100644 --- a/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx +++ b/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx @@ -10,7 +10,7 @@ import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys'; import { useFlashKey } from '@/plugins/useFlash'; const DeleteSSHKeyButton = ({ name, fingerprint }: { name: string; fingerprint: string }) => { - const { clearAndAddHttpError } = useFlashKey('account'); + const { clearAndAddHttpError } = useFlashKey('ssh-keys'); const [visible, setVisible] = useState(false); const { mutate } = useSSHKeys(); diff --git a/resources/scripts/components/elements/MainPageHeader.tsx b/resources/scripts/components/elements/MainPageHeader.tsx index c0845affc..fd592eca2 100644 --- a/resources/scripts/components/elements/MainPageHeader.tsx +++ b/resources/scripts/components/elements/MainPageHeader.tsx @@ -20,17 +20,26 @@ export const MainPageHeader: React.FC = ({ return ( -
-

{title}

+
+
+

{title}

+
{titleChildren}
- {children} + {direction === 'column' && children && ( +
+ {children} +
+ )} ); }; diff --git a/resources/scripts/components/elements/activity/ActivityLogEntry.tsx b/resources/scripts/components/elements/activity/ActivityLogEntry.tsx index 996965289..e0f71c93a 100644 --- a/resources/scripts/components/elements/activity/ActivityLogEntry.tsx +++ b/resources/scripts/components/elements/activity/ActivityLogEntry.tsx @@ -58,12 +58,6 @@ const ActivityLogEntry = ({ activity, children }: Props) => { API )} - {activity.event.startsWith('server:sftp.') && ( - - - SFTP - - )} {children}
diff --git a/resources/scripts/components/elements/pages/PageList.tsx b/resources/scripts/components/elements/pages/PageList.tsx index 803f15d2b..118cd9e4b 100644 --- a/resources/scripts/components/elements/pages/PageList.tsx +++ b/resources/scripts/components/elements/pages/PageList.tsx @@ -24,7 +24,7 @@ const PageListItem = ({ className, children }: Props) => {
{children} diff --git a/resources/scripts/components/server/ServerActivityLogContainer.tsx b/resources/scripts/components/server/ServerActivityLogContainer.tsx index e22feb005..2732c9a30 100644 --- a/resources/scripts/components/server/ServerActivityLogContainer.tsx +++ b/resources/scripts/components/server/ServerActivityLogContainer.tsx @@ -164,29 +164,37 @@ const ServerActivityLogContainer = () => { 'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)', }} > - -
- setShowFilters(!showFilters)} - className='flex items-center gap-2' - title='Toggle Filters (Ctrl+F)' - > - - Filters - {hasActiveFilters && } - - - - Export - -
+ + setShowFilters(!showFilters)} + className='flex items-center gap-2' + title='Toggle Filters (Ctrl+F)' + > + + Filters + {hasActiveFilters && } + + + + Export + +
+ } + > +

+ Monitor all server activity and track user actions. Filter events, search for specific activities, and export logs for audit purposes. +

diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index a23977339..438e8fef1 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -142,7 +142,11 @@ const BackupContainer = () => { return ( - + +

+ Create and manage server backups to protect your data. Schedule automated backups, download existing ones, and restore when needed. +

+
@@ -153,21 +157,29 @@ const BackupContainer = () => { return ( - - -
- {backupLimit > 0 && ( -

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

- )} - {backupLimit > 0 && backupLimit > backups.backupCount && ( - setCreateModalVisible(true)}> - New Backup - - )} -
-
+ +
+ {backupLimit > 0 && ( +

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

+ )} + {backupLimit > 0 && backupLimit > backups.backupCount && ( + setCreateModalVisible(true)}> + New Backup + + )} +
+ + } + > +

+ Create and manage server backups to protect your data. Schedule automated backups, download existing ones, and restore when needed. +

{createModalVisible && ( @@ -187,7 +199,7 @@ const BackupContainer = () => { {({ items }) => !items.length ? ( -
+
diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 9a7515d64..238ae685d 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -53,8 +53,8 @@ const BackupRow = ({ backup }: Props) => { }); return ( -
-
+ +
@@ -113,7 +113,7 @@ const BackupRow = ({ backup }: Props) => {
-
+ ); }; diff --git a/resources/scripts/components/server/console/ServerConsoleContainer.tsx b/resources/scripts/components/server/console/ServerConsoleContainer.tsx index e51e448cf..477749b05 100644 --- a/resources/scripts/components/server/console/ServerConsoleContainer.tsx +++ b/resources/scripts/components/server/console/ServerConsoleContainer.tsx @@ -58,18 +58,21 @@ const ServerConsoleContainer = () => { 'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)', }} > - -
- -
-
+ + +
+ } + />
{description && ( diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx index 990703fd7..bc14b5ff4 100644 --- a/resources/scripts/components/server/databases/DatabaseRow.tsx +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -13,6 +13,7 @@ import CopyOnClick from '@/components/elements/CopyOnClick'; import Field from '@/components/elements/Field'; import Input from '@/components/elements/Input'; import Modal from '@/components/elements/Modal'; +import { PageListItem } from '@/components/elements/pages/PageList'; import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton'; import { httpErrorToHuman } from '@/api/http'; @@ -166,8 +167,8 @@ const DatabaseRow = ({ database }: Props) => {
-
-
+ +
@@ -225,7 +226,7 @@ const DatabaseRow = ({ database }: Props) => {
-
+ ); }; diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 8003621bb..74807e1a0 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -86,21 +86,29 @@ const DatabasesContainer = () => { return ( - - -
- {databaseLimit > 0 && ( -

- {databases.length} of {databaseLimit} databases -

- )} - {databaseLimit > 0 && databaseLimit !== databases.length && ( - setCreateModalVisible(true)}> - New Database - - )} -
-
+ +
+ {databaseLimit > 0 && ( +

+ {databases.length} of {databaseLimit} databases +

+ )} + {databaseLimit > 0 && databaseLimit !== databases.length && ( + setCreateModalVisible(true)}> + New Database + + )} +
+ + } + > +

+ Create and manage MySQL databases for your server. Configure database access, manage users, and view connection details. +

{ ) : ( -
+
diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 852fb5a86..6b974db1a 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -102,15 +102,23 @@ const FileManagerContainer = () => {
- - -
- - - - -
-
+ +
+ + + + +
+ + } + > +

+ Manage your server files and directories. Upload, download, edit, and organize your server's file system with our integrated file manager. +

{ const [loading, setLoading] = useState(false); const [isEditingNotes, setIsEditingNotes] = useState(false); const [notesValue, setNotesValue] = useState(allocation.notes || ''); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const textareaRef = useRef(null); const { clearFlashes, clearAndAddHttpError } = useFlashKey('server:network'); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); @@ -100,8 +103,8 @@ const AllocationRow = ({ allocation }: Props) => { }; return ( -
-
+ +
@@ -206,7 +209,7 @@ const AllocationRow = ({ allocation }: Props) => {
-
+ ); }; diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index 903774019..99cf5037d 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -58,21 +58,29 @@ const NetworkContainer = () => { return ( - - {data && allocationLimit > 0 && ( - -
-

- {data.length} of {allocationLimit} allowed allocations -

- {allocationLimit > data.length && ( - - New Allocation - - )} -
-
- )} + 0 ? ( + +
+

+ {data.filter(allocation => !allocation.isDefault).length} of {allocationLimit} allowed allocations +

+ {allocationLimit > data.filter(allocation => !allocation.isDefault).length && ( + + New Allocation + + )} +
+
+ ) : undefined + } + > +

+ Configure network allocations for your server. Manage IP addresses and ports that your server can bind to for incoming connections. +

{!data ? ( @@ -88,7 +96,7 @@ const NetworkContainer = () => { ) : ( -
+
diff --git a/resources/scripts/components/server/operations/OperationProgressModal.tsx b/resources/scripts/components/server/operations/OperationProgressModal.tsx new file mode 100644 index 000000000..647c95baa --- /dev/null +++ b/resources/scripts/components/server/operations/OperationProgressModal.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '@/components/elements/Modal'; +import { ServerOperation, useOperationPolling } from '@/api/server/serverOperations'; +import { ServerContext } from '@/state/server'; +import Spinner from '@/components/elements/Spinner'; +import HugeIconsAlert from '@/components/elements/hugeicons/Alert'; +import { + UI_CONFIG, + getStatusStyling, + getStatusIconType, + formatDuration, + canCloseOperation, + formatOperationId, + isActiveStatus, + isCompletedStatus, + isFailedStatus, +} from '@/lib/server-operations'; + +interface Props { + visible: boolean; + operationId: string | null; + operationType: string; + onClose: () => void; + onComplete?: (operation: ServerOperation) => void; + onError?: (error: Error) => void; +} + +/** + * Modal component for displaying server operation progress in real-time. + * Handles polling, auto-close, and status updates for long-running operations. + */ +const OperationProgressModal: React.FC = ({ + visible, + operationId, + operationType, + onClose, + onComplete, + onError +}) => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const [operation, setOperation] = useState(null); + const [error, setError] = useState(null); + const [autoCloseTimer, setAutoCloseTimer] = useState(null); + const { startPolling, stopPolling } = useOperationPolling(); + + useEffect(() => { + if (!visible || !operationId) { + stopPolling(operationId || ''); + setOperation(null); + setError(null); + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + setAutoCloseTimer(null); + } + return; + } + + const handleUpdate = (op: ServerOperation) => { + setOperation(op); + }; + + const handleComplete = (op: ServerOperation) => { + setOperation(op); + stopPolling(operationId); + + if (onComplete) { + onComplete(op); + } + + if (op.is_completed) { + const timer = setTimeout(() => { + onClose(); + }, UI_CONFIG.AUTO_CLOSE_DELAY); + setAutoCloseTimer(timer); + } + }; + + const handleError = (err: Error) => { + setError(err.message); + stopPolling(operationId); + + if (onError) { + onError(err); + } + }; + + startPolling(uuid, operationId, handleUpdate, handleComplete, handleError); + + return () => { + stopPolling(operationId); + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + } + }; + }, [visible, operationId, uuid, startPolling, stopPolling, onComplete, onError, onClose, autoCloseTimer]); + + const renderStatusIcon = (status: string) => { + const iconType = getStatusIconType(status as any); + + switch (iconType) { + case 'spinner': + return ; + case 'success': + return ( +
+
+
+ ); + case 'error': + return ; + default: + return ; + } + }; + + const canClose = canCloseOperation(operation, error); + const statusStyling = operation ? getStatusStyling(operation.status) : null; + + const handleClose = () => { + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + setAutoCloseTimer(null); + } + onClose(); + }; + + return ( + {}} + closeOnEscape={canClose} + closeOnBackground={canClose} + > +
+
+
+
+

+ {operationType} in Progress +

+ {operationId && ( +

+ Operation ID: {formatOperationId(operationId)} +

+ )} +
+ {error ? ( +
+
+ + Error +
+
+

{error}

+
+
+ ) : operation ? ( +
+
+ {renderStatusIcon(operation.status)} + + {operation.status} + +
+ +
+

+ {operation.message || 'Processing...'} +

+
+ +
+ Duration: {formatDuration(operation.created_at, operation.updated_at)} +
+ + {isActiveStatus(operation.status) && ( +
+
+
+ )} + + {isCompletedStatus(operation.status) && ( +
+

+ ✓ Operation completed successfully +

+ {autoCloseTimer && ( +

+ This window will close automatically in 3 seconds +

+ )} +
+ )} + + {isFailedStatus(operation.status) && ( +
+

+ ✗ Operation failed +

+ {operation.message && ( +

+ {operation.message} +

+ )} +
+ )} +
+ ) : ( +
+
+ + Initializing... +
+
+ )} + +
+ {canClose && ( + + )} + + {operation && isActiveStatus(operation.status) && ( +
+ This window will close automatically when complete +
+ )} +
+
+
+
+ + ); +}; + +export default OperationProgressModal; \ No newline at end of file diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 788a145ce..904f30bef 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -41,20 +41,44 @@ function ScheduleContainer() { return ( - - - setVisible(false)} /> - setVisible(true)}> - New Schedule - - + + setVisible(true)}> + New Schedule + + + } + > +

+ Automate server tasks with scheduled commands. Create recurring tasks to manage your server, run backups, or execute custom commands. +

+ + setVisible(false)} /> + {!schedules.length && loading ? null : ( <> {schedules.length === 0 ? ( -

- There are no schedules configured for this server. -

+
+
+
+ + + +
+

No schedules found

+

+ Your server does not have any scheduled tasks. Create one to automate server management. +

+
+
) : ( {schedules.map((schedule) => ( diff --git a/resources/scripts/components/server/settings/SettingsContainer.tsx b/resources/scripts/components/server/settings/SettingsContainer.tsx index 3df59b6ab..3a9ac28a2 100644 --- a/resources/scripts/components/server/settings/SettingsContainer.tsx +++ b/resources/scripts/components/server/settings/SettingsContainer.tsx @@ -27,7 +27,11 @@ const SettingsContainer = () => { return ( - + +

+ Configure your server settings, manage SFTP access, and access debug information. Make changes to server name and reinstall when needed. +

+
diff --git a/resources/scripts/components/server/shell/ShellContainer.tsx b/resources/scripts/components/server/shell/ShellContainer.tsx index fb9ebb89c..7db434973 100644 --- a/resources/scripts/components/server/shell/ShellContainer.tsx +++ b/resources/scripts/components/server/shell/ShellContainer.tsx @@ -3,22 +3,33 @@ import isEqual from 'react-fast-compare'; import { toast } from 'sonner'; import ActionButton from '@/components/elements/ActionButton'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; import { MainPageHeader } from '@/components/elements/MainPageHeader'; -import Pagination from '@/components/elements/Pagination'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; import { Switch } from '@/components/elements/SwitchV2'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/elements/DropdownMenu'; import { Dialog } from '@/components/elements/dialog'; import HugeIconsAlert from '@/components/elements/hugeicons/Alert'; import HugeIconsEggs from '@/components/elements/hugeicons/Egg'; -import VariableBox from '@/components/server/startup/VariableBox'; +import Spinner from '@/components/elements/Spinner'; import { httpErrorToHuman } from '@/api/http'; import getNests from '@/api/nests/getNests'; import createServerBackup from '@/api/server/backups/createServerBackup'; import deleteFiles from '@/api/server/files/deleteFiles'; import reinstallServer from '@/api/server/reinstallServer'; -import setSelectedEggImage from '@/api/server/setSelectedEggImage'; +import previewEggChange, { EggPreview } from '@/api/server/previewEggChange'; +import applyEggChange from '@/api/server/applyEggChange'; import getServerBackups from '@/api/swr/getServerBackups'; +import { ServerOperation } from '@/api/server/serverOperations'; +import OperationProgressModal from '@/components/server/operations/OperationProgressModal'; import getServerStartup from '@/api/swr/getServerStartup'; import { ServerContext } from '@/state/server'; @@ -55,24 +66,12 @@ interface Nest { }; } -const MAX_DESCRIPTION_LENGTH = 100; -const steps = [ - { - slug: 'game', - title: 'Game', - }, - { - slug: 'software', - title: 'Software', - }, - { - slug: 'options-variables', - title: 'Options & Variables', - }, -]; +const MAX_DESCRIPTION_LENGTH = 150; const hidden_nest_prefix = '!'; const blank_egg_prefix = '@'; +type FlowStep = 'overview' | 'select-game' | 'select-software' | 'configure' | 'review'; + const SoftwareContainer = () => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); const [nests, setNests] = useState(); @@ -89,16 +88,34 @@ const SoftwareContainer = () => { ?.attributes.relationships.eggs.data.find((egg) => egg.attributes.uuid === currentEgg)?.attributes.name; const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups); const { data: backups } = getServerBackups(); - const directory = ServerContext.useStoreState((state) => state.files.directory); - const { data: files, mutate: filemutate } = useFileManagerSwr(); const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState); + // Flow state + const [currentStep, setCurrentStep] = useState('overview'); + const [isLoading, setIsLoading] = useState(false); + const [selectedNest, setSelectedNest] = useState(null); + const [selectedEgg, setSelectedEgg] = useState(null); + const [eggPreview, setEggPreview] = useState(null); + const [pendingVariables, setPendingVariables] = useState>({}); + const [currentOperationId, setCurrentOperationId] = useState(null); + const [showOperationModal, setShowOperationModal] = useState(false); + const [showWipeConfirmation, setShowWipeConfirmation] = useState(false); + + // Configuration options + const [shouldBackup, setShouldBackup] = useState(false); + const [shouldWipe, setShouldWipe] = useState(false); + const [showFullDescriptions, setShowFullDescriptions] = useState>({}); + + // Startup and Docker configuration + const [customStartup, setCustomStartup] = useState(''); + const [selectedDockerImage, setSelectedDockerImage] = useState(''); + + // Data loading useEffect(() => { const fetchData = async () => { const data = await getNests(); setNests(data); }; - fetchData(); }, []); @@ -117,14 +134,8 @@ const SoftwareContainer = () => { rawStartupCommand: variables.invocation, }); - useEffect(() => { - mutate(); - filemutate(); - }, []); - useDeepCompareEffect(() => { if (!data) return; - setServerFromState((s) => ({ ...s, invocation: data.invocation, @@ -132,447 +143,706 @@ const SoftwareContainer = () => { })); }, [data]); - const ITEMS_PER_PAGE = 6; - const [currentPage, setCurrentPage] = useState(1); - - let paginatedVariables; - - const updateVarsData = () => { - paginatedVariables = data - ? data.variables.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE) - : []; - }; - - updateVarsData(); - - const [step, setStep] = useState(0); - const [modalVisible, setModalVisible] = useState(false); - const [visible, setVisible] = useState(false); - - const [shouldBackup, setShouldBackup] = useState(false); - const [shouldWipe, setShouldWipe] = useState(false); - const [selectedNest, setSelectedNest] = useState(null); - const [selectedEgg, setSelectedEgg] = useState(null); - const [showFullDescriptions, setShowFullDescriptions] = useState([]); - + // Initialize backup setting based on limits useEffect(() => { if (backups) { - if (backupLimit <= 0 || backups.backupCount >= backupLimit) { - setShouldBackup(false); - } + setShouldBackup(backupLimit > 0 && backups.backupCount < backupLimit); } }, [backups, backupLimit]); - const restoreOriginalEgg = () => { - if (!nests || !eggs) return; - const originalNestId = - nests?.findIndex((nest) => - nest.attributes.relationships.eggs.data.find((egg) => egg.attributes.uuid === originalEgg), - ) + 1 || 0; - const originalEggId = eggs?.find((eo) => eo.attributes.uuid === originalEgg)?.attributes.id || 0; - - setSelectedEggImage(uuid, originalEggId, originalNestId).catch((error) => { - console.error(error); - toast.error(httpErrorToHuman(error)); - }); + // Flow control functions + const resetFlow = () => { + setCurrentStep('overview'); + setSelectedNest(null); + setSelectedEgg(null); + setEggPreview(null); + setPendingVariables({}); + setShouldBackup(backupLimit > 0 && (backups?.backupCount || 0) < backupLimit); + setShouldWipe(false); + setCustomStartup(''); + setSelectedDockerImage(''); }; - const reinstall = () => { - reinstallServer(uuid) - .then(() => { - toast.success('Server has been reinstalled successfully.'); - }) - .catch((error) => { - console.error(error); - toast.error(httpErrorToHuman(error)); - }); - }; - - const wipeFiles = async () => { - filemutate(); - const selectedFiles = files?.map((file) => file.name) || []; - if (selectedFiles.length === 0) return; - - await deleteFiles(uuid, directory, selectedFiles).catch((error) => { - console.error(error); - toast.error(httpErrorToHuman(error)); - }); - return; - }; - - const handleNestSelect = (nest: Nest) => { + const handleNestSelection = (nest: Nest) => { setSelectedNest(nest); setSelectedEgg(null); - setStep(1); + setEggPreview(null); + setPendingVariables({}); + setCustomStartup(''); + setSelectedDockerImage(''); + setCurrentStep('select-software'); }; - const confirmSelection = async () => { - if (shouldBackup) { - await createServerBackup(uuid, { - name: `${currentEggName} -> ${selectedEgg?.attributes.name} Migration - ${new Date().toLocaleString()}`, - isLocked: false, - }).catch((error) => { - toast.error(httpErrorToHuman(error)); - return; - }); - } - if (shouldWipe) { - wipeFiles(); - } + const handleEggSelection = async (egg: Egg) => { + if (!selectedNest) return; - reinstall(); - }; - - const handleEggSelect = async (egg: Egg) => { - if (!eggs || !nests || !selectedNest) return; - setStep(2); - - // Use the passed egg directly instead of selectedEgg state - const nestId = selectedNest.attributes.id; - const eggId = egg.attributes.id; + setIsLoading(true); + setSelectedEgg(egg); try { - await setSelectedEggImage(uuid, eggId, nestId); - await mutate(); - updateVarsData(); - setTimeout(() => setStep(2), 500); + const preview = await previewEggChange(uuid, egg.attributes.id, selectedNest.attributes.id); + setEggPreview(preview); + + // Initialize variables with current values or defaults + const initialVariables: Record = {}; + preview.variables.forEach(variable => { + const existingVar = data?.variables.find(v => v.envVariable === variable.env_variable); + initialVariables[variable.env_variable] = existingVar?.serverValue || variable.default_value || ''; + }); + setPendingVariables(initialVariables); + + // Set default startup command and docker image + setCustomStartup(preview.egg.startup); + + // Automatically select the default docker image if available + // Backend returns: {"Display Name": "actual/image:tag"} + const availableDisplayNames = Object.keys(preview.docker_images || {}); + if (preview.default_docker_image && availableDisplayNames.includes(preview.default_docker_image)) { + setSelectedDockerImage(preview.default_docker_image); + } else if (availableDisplayNames.length > 0 && availableDisplayNames[0]) { + setSelectedDockerImage(availableDisplayNames[0]); + } + + setCurrentStep('configure'); } catch (error) { console.error(error); toast.error(httpErrorToHuman(error)); + } finally { + setIsLoading(false); } }; - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = ''; - restoreOriginalEgg(); - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, []); - - const toggleDescriptionVisibility = (index: number) => { - setShowFullDescriptions((prev) => { - const newVisibility = [...prev]; - newVisibility[index] = !newVisibility[index]; - return newVisibility; - }); + const handleVariableChange = (envVariable: string, value: string) => { + setPendingVariables(prev => ({ ...prev, [envVariable]: value })); }; - const renderEggDescription = (description: string, index: number) => { - const isLongDescription = description.length > MAX_DESCRIPTION_LENGTH; - const shouldShowFull = showFullDescriptions[index]; + const proceedToReview = () => { + setCurrentStep('review'); + }; + + const applyChanges = async () => { + if (!selectedEgg || !selectedNest || !eggPreview) return; + + // Show final confirmation if wipe files is selected without backup + if (shouldWipe && !shouldBackup) { + setShowWipeConfirmation(true); + return; + } + + // Proceed with the operation + executeApplyChanges(); + }; + + const executeApplyChanges = async () => { + if (!selectedEgg || !selectedNest || !eggPreview) return; + + setIsLoading(true); + + try { + // Validate required variables (excluding nullable variables) + const missingVariables = eggPreview.variables + .filter(v => v.user_editable && !v.default_value && !pendingVariables[v.env_variable] && !v.rules.includes('nullable')) + .map(v => v.name); + + if (missingVariables.length > 0) { + throw new Error(`Please fill in required variables: ${missingVariables.join(', ')}`); + } + + // Convert display name back to actual image for backend + const actualDockerImage = selectedDockerImage && eggPreview.docker_images + ? eggPreview.docker_images[selectedDockerImage] + : (eggPreview.default_docker_image && eggPreview.docker_images + ? eggPreview.docker_images[eggPreview.default_docker_image] + : ''); + + // Filter out empty environment variables to prevent validation issues + const filteredEnvironment: Record = {}; + Object.entries(pendingVariables).forEach(([key, value]) => { + if (value && value.trim() !== '') { + filteredEnvironment[key] = value; + } + }); + + // Start the async operation + const response = await applyEggChange(uuid, { + egg_id: selectedEgg.attributes.id, + nest_id: selectedNest.attributes.id, + docker_image: actualDockerImage, + startup_command: customStartup, + environment: filteredEnvironment, + should_backup: shouldBackup, + should_wipe: shouldWipe, + }); + + // Operation started successfully - show progress modal + setCurrentOperationId(response.operation_id); + setShowOperationModal(true); + + toast.success('Software change operation started successfully'); + + // Reset the configuration flow but keep the modal open + resetFlow(); + + } catch (error) { + console.error('Failed to start egg change operation:', error); + toast.error(httpErrorToHuman(error)); + } finally { + setIsLoading(false); + } + }; + + const handleWipeConfirm = () => { + setShowWipeConfirmation(false); + executeApplyChanges(); + }; + + const handleOperationComplete = (operation: ServerOperation) => { + if (operation.is_completed) { + toast.success('Your software configuration has been applied successfully'); + + // Refresh server data to reflect changes + mutate(); + } else if (operation.has_failed) { + toast.error(operation.message || 'The software configuration change failed'); + } + }; + + const handleOperationError = (error: Error) => { + toast.error(error.message || 'An error occurred while monitoring the operation'); + }; + + const closeOperationModal = () => { + setShowOperationModal(false); + setCurrentOperationId(null); + }; + + const toggleDescription = (id: string) => { + setShowFullDescriptions(prev => ({ ...prev, [id]: !prev[id] })); + }; + + const renderDescription = (description: string, id: string) => { + const isLong = description.length > MAX_DESCRIPTION_LENGTH; + const showFull = showFullDescriptions[id]; return ( -
- {isLongDescription && !shouldShowFull ? ( +

+ {isLong && !showFull ? ( <> - {`${description.slice(0, MAX_DESCRIPTION_LENGTH)}... `} + {description.slice(0, MAX_DESCRIPTION_LENGTH)}...{' '} ) : ( <> {description} - {isLongDescription && ( - + {isLong && ( + <> + {' '} + + )} )} -

+

); }; - return ( - - -

- Welcome to the software management page. Here you can change the game or software that is running on - your server. -

-
- - {!visible && ( -
-
-
-
- -
-

Current Egg

- {currentEggName && - (currentEggName?.includes(blank_egg_prefix) ? ( -

Please select an egg

- ) : ( -

{currentEggName}

- ))} -
+ const renderOverview = () => ( + +
+
+
+ +
+
+ {currentEggName ? ( + currentEggName.includes(blank_egg_prefix) ? ( +

No software selected

+ ) : ( +

{currentEggName}

+ ) + ) : ( +
+ + Loading...
- setVisible(true)}> - Change Egg - -
-
-
- )} - - {visible && ( -
-
-
- {steps.map((cstep, index) => ( -
-
setStep(index)} - style={{ cursor: 'pointer' }} - > -
- {index + 1} -
-

- {cstep.title} -

-
- {index !== steps.length - 1 && ( - - )} -
- ))} -
- -
- -
- {step == 0 && ( -
-
- {nests?.map((nest) => - nest.attributes.name.includes(hidden_nest_prefix) ? null : ( -
-
-

- {nest.attributes.name} -

- handleNestSelect(nest)} - > - Select - -
-

- {nest.attributes.description} -

-
- ), - )} -
-
- )} - - {(step == 1 && selectedNest && ( -
-
- {selectedNest.attributes.relationships.eggs.data.map((egg, eggIndex) => ( -
-
-

{egg.attributes.name}

- { - setSelectedEgg(egg); - await handleEggSelect(egg); - }} - > - Select - -
-

- {renderEggDescription(egg.attributes.description, eggIndex)} -

-
- ))} -
-
- )) || - (step == 1 && ( -
-
-

Please select a game first

-
-
- ))} - - {(step == 2 && selectedEgg && !currentEggName?.includes(blank_egg_prefix) && ( -
-
-
- {backups && backupLimit > 0 && backups.backupCount < backupLimit ? ( - <> -
- - -
- setShouldBackup(!shouldBackup)} - /> - - ) : ( - <> -
- - -
- - - )} -
-
-
- - -
- setShouldWipe(!shouldWipe)} - /> -
-
- -
- - {data && ( -
-
- {paginatedVariables.map((variable) => ( - - ))} -
- - {() => <>} - -
- )} - -
- -
- confirmSelection()}> - Confirm Selection - -
-
- )) || - (step == 2 && !currentEggName?.includes(blank_egg_prefix) && ( -
-
-

Please select an egg first

-
-
- ))} -
-
-
- )} - - {!visible && ( -
-
-
- -

Danger Zone

-
-

- During this process some files may be deleted or modified. Either make a backup beforehand - or select the backup option when prompted. + )} +

+ Manage your server's game or software configuration

- )} +
+ setCurrentStep('select-game')} + className="w-full sm:w-auto" + > + Change Software + +
+
+ + ); + + const renderGameSelection = () => ( + +
+

+ Choose the type of game or software you want to run +

+ +
+ {nests?.map((nest) => + nest.attributes.name.includes(hidden_nest_prefix) ? null : ( + + ) + )} +
+ +
+ setCurrentStep('overview')} className="w-full sm:w-auto"> + Back to Overview + +
+
+
+ ); + + const renderSoftwareSelection = () => ( + +
+

+ Choose the specific software version for your server +

+ + {isLoading ? ( +
+
+ +

Loading software options...

+
+
+ ) : ( +
+ {selectedNest?.attributes.relationships.eggs.data.map((egg) => ( + + ))} +
+ )} + +
+ setCurrentStep('select-game')} className="w-full sm:w-auto"> + Back to Games + + setCurrentStep('overview')} className="w-full sm:w-auto"> + Cancel + +
+
+
+ ); + + const renderConfiguration = () => ( +
+ + {eggPreview && ( +
+ {/* Software Configuration */} +
+

Software Configuration

+
+
+ +