feat: asynchronous server operations.

This commit is contained in:
Elizabeth
2025-08-15 00:49:35 -05:00
parent 993573ab93
commit f7a12cb5b6
50 changed files with 3527 additions and 865 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace Pterodactyl\Exceptions\ServerOperations;
use Exception;
class ServerOperationException extends Exception
{
/**
* Create a new server operation exception.
*/
public function __construct(string $message = '', int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Create exception for when server cannot accept operations
*/
public static function serverBusy(string $serverUuid): self
{
return new self("Server {$serverUuid} is currently busy and cannot accept new operations.");
}
/**
* Create exception for operation timeout
*/
public static function operationTimedOut(string $operationId): self
{
return new self("Operation {$operationId} has timed out.");
}
/**
* Create exception for invalid operation state
*/
public static function invalidOperationState(string $operationId, string $currentState): self
{
return new self("Operation {$operationId} is in an invalid state: {$currentState}");
}
/**
* Create exception for operation not found
*/
public static function operationNotFound(string $operationId): self
{
return new self("Operation {$operationId} was not found.");
}
/**
* Create exception for rate limit exceeded
*/
public static function rateLimitExceeded(string $operationType, int $windowSeconds): self
{
$minutes = ceil($windowSeconds / 60);
return new self("Rate limit exceeded for {$operationType} operations. Please wait {$minutes} minutes before trying again.");
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Pterodactyl\Exceptions\Service\Backup;
use Pterodactyl\Exceptions\DisplayException;
class BackupFailedException extends DisplayException
{
/**
* Exception thrown when a backup fails to complete successfully.
*/
}

View File

@@ -191,34 +191,88 @@ class BackupController extends ClientApiController
*/
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
{
// 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.');
}
$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.');
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ServerOperation;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
* Middleware to rate limit server operations.
*
* Prevents concurrent operations on the same server and provides monitoring
* of operation attempts for analytics and troubleshooting.
*/
class ServerOperationRateLimit
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string $operationType = 'general')
{
/** @var Server $server */
$server = $request->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(),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
use Pterodactyl\Models\Egg;
use Illuminate\Validation\Rule;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
/**
* Request validation for applying egg configuration changes.
*
* Validates egg selection, Docker images, startup commands, and environment
* variables with comprehensive cross-validation.
*/
class ApplyEggChangeRequest extends ClientApiRequest
{
public function permission(): string
{
return 'startup.software';
}
public function rules(): array
{
return [
'egg_id' => '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.');
}
}
}
}
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
use Pterodactyl\Models\Egg;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
/**
* Request validation for previewing egg configuration changes.
*
* Validates egg and nest selection to ensure proper relationship
* before showing preview information.
*/
class PreviewEggRequest extends ClientApiRequest
{
public function permission(): string
{
return 'startup.software';
}
public function rules(): array
{
return [
'egg_id' => '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.');
}
}
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
/**
* Request validation for server operation queries.
*
* Validates operation ID format and ensures proper authorization
* for accessing server operation information.
*/
class ServerOperationRequest extends ClientApiRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->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.',
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Pterodactyl\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Pterodactyl\Models\ServerOperation;
/**
* Resource for transforming server operations for API responses.
*
* Provides comprehensive operation information including status, timing,
* and metadata for frontend consumption.
*/
class ServerOperationResource extends JsonResource
{
/**
* Transform the server operation into an array.
*/
public function toArray(Request $request): array
{
/** @var ServerOperation $operation */
$operation = $this->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(),
],
];
}
}

View File

@@ -0,0 +1,341 @@
<?php
namespace Pterodactyl\Jobs\Server;
use Exception;
use Carbon\Carbon;
use Pterodactyl\Jobs\Job;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\ServerOperation;
use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Services\Servers\StartupModificationService;
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
use Pterodactyl\Exceptions\Service\Backup\BackupFailedException;
use Pterodactyl\Services\ServerOperations\ServerOperationService;
/**
* Queue job to apply server egg configuration changes.
*
* Handles the complete egg change process including backup creation,
* file wiping, server configuration updates, and reinstallation.
*/
class ApplyEggChangeJob extends Job implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use SerializesModels;
public $timeout;
public $tries = 1;
public $failOnTimeout = true;
public function __construct(
public Server $server,
public User $user,
public int $eggId,
public int $nestId,
public ?string $dockerImage,
public ?string $startupCommand,
public array $environment,
public bool $shouldBackup,
public bool $shouldWipe,
public string $operationId
) {
$this->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();
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace Pterodactyl\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Server operations tracking model.
*
* Tracks long-running server operations like egg changes, reinstalls, and backup restores.
* Provides status tracking, timeout detection, and operation lifecycle management.
*/
class ServerOperation extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const TYPE_EGG_CHANGE = 'egg_change';
public const TYPE_REINSTALL = 'reinstall';
public const TYPE_BACKUP_RESTORE = 'backup_restore';
protected $table = 'server_operations';
protected $fillable = [
'operation_id',
'server_id',
'user_id',
'type',
'status',
'message',
'parameters',
'started_at',
];
protected $casts = [
'parameters' => '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());
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Pterodactyl\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Pterodactyl\Console\Commands\Server\CleanupServerOperationsCommand;
use Pterodactyl\Http\Middleware\Api\Client\Server\ServerOperationRateLimit;
/**
* Service provider for server operations functionality.
*
* Registers commands, middleware, scheduled tasks, and configuration
* for the server operations system.
*/
class ServerOperationServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->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');
}
}

View File

@@ -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();
});
}
}

View File

@@ -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.');
}
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace Pterodactyl\Services\ServerOperations;
use Exception;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\User;
use Pterodactyl\Jobs\Server\ApplyEggChangeJob;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Service for handling server egg configuration changes.
*
* Manages egg previews, validation, and asynchronous application of egg changes
* including backup creation and file management.
*/
class EggChangeService
{
public function __construct(
private ServerOperationService $operationService,
private ServerStateValidationService $validationService
) {}
/**
* Preview egg change information.
*/
public function previewEggChange(Server $server, int $eggId, int $nestId): array
{
$this->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";
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Pterodactyl\Services\ServerOperations;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ServerOperation;
use Pterodactyl\Models\User;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
/**
* Service for managing server operations lifecycle.
*
* Handles creation, tracking, and cleanup of long-running server operations
* like egg changes, reinstalls, and backup restores.
*/
class ServerOperationService
{
/**
* Check if server can accept new operations.
*/
public function canAcceptOperation(Server $server): bool
{
try {
$activeOperations = ServerOperation::forServer($server)->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;
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Pterodactyl\Services\ServerOperations;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ServerOperation;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
/**
* Service for validating server state before operations.
*
* Ensures servers are in appropriate states for modifications and prevents
* concurrent operations that could cause conflicts.
*/
class ServerStateValidationService
{
/**
* Validate server state before making changes.
*/
public function validateServerState(Server $server): void
{
try {
if ($server->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);
}
}

View File

@@ -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]);
}

View File

@@ -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,
/*

View File

@@ -0,0 +1,41 @@
<?php
/**
* Configuration for server operations system.
*
* Defines timeouts, cleanup policies, and other operational parameters
* for long-running server operations like egg changes and reinstalls.
*/
return [
/*
|--------------------------------------------------------------------------
| Operation Timeouts
|--------------------------------------------------------------------------
|
| Maximum execution time (in seconds) for different types of server
| operations before they are considered timed out and marked as failed.
|
*/
'timeouts' => [
'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
],
];

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Migration to create server operations tracking table.
*
* Creates table for tracking long-running server operations like egg changes,
* reinstalls, and backup restores with proper indexing for performance.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('server_operations', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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<string, string>;
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<ApplyEggChangeResponse> => {
const { data: response } = await http.post(`/api/client/servers/${uuid}/settings/egg/apply`, data);
return response;
};

View File

@@ -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<string, string>;
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<EggPreview> => {
const { data } = await http.post(`/api/client/servers/${uuid}/settings/egg/preview`, {
egg_id: eggId,
nest_id: nestId,
});
return data;
};

View File

@@ -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<string, any>;
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<ServerOperation> => {
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<string, () => 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)
};
};

View File

@@ -1,11 +0,0 @@
import http from '@/api/http';
export default async (uuid: string, eggid: number, nestid: number): Promise<void> => {
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 });
};

View File

@@ -39,7 +39,7 @@ const AccountApiContainer = () => {
const [apiKey, setApiKey] = useState('');
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const { clearAndAddHttpError } = useFlashKey('account');
const { clearAndAddHttpError } = useFlashKey('api-keys');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => {

View File

@@ -62,7 +62,7 @@ const AccountSSHContainer = () => {
};
const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers<CreateValues>) => {
clearFlashes('account');
clearFlashes('ssh-keys');
createSSHKey(values.name, values.publicKey)
.then((key) => {
resetForm();

View File

@@ -27,7 +27,7 @@ const CreateSSHKeyForm = () => {
const { mutate } = useSSHKeys();
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
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);
});
};

View File

@@ -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();

View File

@@ -20,17 +20,26 @@ export const MainPageHeader: React.FC<MainPageHeaderProps> = ({
return (
<HeaderWrapper
className={clsx(
'flex',
direction === 'row' ? 'items-center flex-col md:flex-row' : 'items-start flex-col',
'justify-between',
'flex flex-col',
'mb-4 gap-8 mt-8 md:mt-0 select-none',
)}
>
<div className='flex items-center gap-4 flex-wrap'>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>{title}</h1>
<div className={clsx(
'flex items-center',
direction === 'row' ? 'flex-col md:flex-row' : 'flex-row',
'justify-between',
'gap-4'
)}>
<div className='flex items-center gap-4 flex-wrap'>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>{title}</h1>
</div>
{titleChildren}
</div>
{children}
{direction === 'column' && children && (
<div className='-mt-4'>
{children}
</div>
)}
</HeaderWrapper>
);
};

View File

@@ -58,12 +58,6 @@ const ActivityLogEntry = ({ activity, children }: Props) => {
API
</span>
)}
{activity.event.startsWith('server:sftp.') && (
<span className='text-xs bg-green-900/30 text-green-300 px-1.5 py-0.5 rounded flex items-center gap-1'>
<FolderIcon fill='currentColor' className='w-3 h-3' />
SFTP
</span>
)}
{children}
</div>
</div>

View File

@@ -24,7 +24,7 @@ const PageListItem = ({ className, children }: Props) => {
<div
className={clsx(
className,
'flex items-center rounded-md bg-[#ffffff11] px-2 sm:px-6 py-4 transition duration-100 hover:bg-[#ffffff19] hover:duration-0 gap-4 flex-col sm:flex-row',
'bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all flex items-center gap-4 flex-col sm:flex-row',
)}
>
{children}

View File

@@ -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)',
}}
>
<MainPageHeader title={'Activity Log'}>
<div className='flex gap-2 items-center flex-wrap'>
<ActionButton
variant='secondary'
onClick={() => setShowFilters(!showFilters)}
className='flex items-center gap-2'
title='Toggle Filters (Ctrl+F)'
>
<FontAwesomeIcon icon={faFilter} className='w-4 h-4' />
Filters
{hasActiveFilters && <span className='w-2 h-2 bg-brand rounded-full'></span>}
</ActionButton>
<ActionButton
variant='secondary'
onClick={exportLogs}
disabled={!filteredData?.items?.length}
className='flex items-center gap-2'
title='Export CSV (Ctrl+E)'
>
<FontAwesomeIcon icon={faDownload} className='w-4 h-4' />
Export
</ActionButton>
</div>
<MainPageHeader
direction='column'
title={'Activity Log'}
titleChildren={
<div className='flex gap-2 items-center flex-wrap'>
<ActionButton
variant='secondary'
onClick={() => setShowFilters(!showFilters)}
className='flex items-center gap-2'
title='Toggle Filters (Ctrl+F)'
>
<FontAwesomeIcon icon={faFilter} className='w-4 h-4' />
Filters
{hasActiveFilters && <span className='w-2 h-2 bg-brand rounded-full'></span>}
</ActionButton>
<ActionButton
variant='secondary'
onClick={exportLogs}
disabled={!filteredData?.items?.length}
className='flex items-center gap-2'
title='Export CSV (Ctrl+E)'
>
<FontAwesomeIcon icon={faDownload} className='w-4 h-4' />
Export
</ActionButton>
</div>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Monitor all server activity and track user actions. Filter events, search for specific activities, and export logs for audit purposes.
</p>
</MainPageHeader>
</div>

View File

@@ -142,7 +142,11 @@ const BackupContainer = () => {
return (
<ServerContentBlock title={'Backups'}>
<FlashMessageRender byKey={'backups'} />
<MainPageHeader title={'Backups'} />
<MainPageHeader direction='column' title={'Backups'}>
<p className='text-sm text-neutral-400 leading-relaxed'>
Create and manage server backups to protect your data. Schedule automated backups, download existing ones, and restore when needed.
</p>
</MainPageHeader>
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
</div>
@@ -153,21 +157,29 @@ const BackupContainer = () => {
return (
<ServerContentBlock title={'Backups'}>
<FlashMessageRender byKey={'backups'} />
<MainPageHeader title={'Backups'}>
<Can action={'backup.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{backupLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{backups.backupCount} of {backupLimit} backups
</p>
)}
{backupLimit > 0 && backupLimit > backups.backupCount && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
)}
</div>
</Can>
<MainPageHeader
direction='column'
title={'Backups'}
titleChildren={
<Can action={'backup.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{backupLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{backups.backupCount} of {backupLimit} backups
</p>
)}
{backupLimit > 0 && backupLimit > backups.backupCount && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
)}
</div>
</Can>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Create and manage server backups to protect your data. Schedule automated backups, download existing ones, and restore when needed.
</p>
</MainPageHeader>
{createModalVisible && (
@@ -187,7 +199,7 @@ const BackupContainer = () => {
<Pagination data={backups} onPageSelect={setPage}>
{({ items }) =>
!items.length ? (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>

View File

@@ -53,8 +53,8 @@ const BackupRow = ({ backup }: Props) => {
});
return (
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<PageListItem>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 w-full'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-2'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
@@ -113,7 +113,7 @@ const BackupRow = ({ backup }: Props) => {
</Can>
</div>
</div>
</div>
</PageListItem>
);
};

View File

@@ -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)',
}}
>
<MainPageHeader title={name}>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '100ms',
animationTimingFunction:
'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)',
}}
>
<PowerButtons className='flex gap-1 items-center justify-center' />
</div>
</MainPageHeader>
<MainPageHeader
title={name}
titleChildren={
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '100ms',
animationTimingFunction:
'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)',
}}
>
<PowerButtons className='flex gap-1 items-center justify-center' />
</div>
}
/>
</div>
{description && (

View File

@@ -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) => {
</div>
</Modal>
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<PageListItem>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 w-full'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-2'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
@@ -225,7 +226,7 @@ const DatabaseRow = ({ database }: Props) => {
</Can>
</div>
</div>
</div>
</PageListItem>
</>
);
};

View File

@@ -86,21 +86,29 @@ const DatabasesContainer = () => {
return (
<ServerContentBlock title={'Databases'}>
<FlashMessageRender byKey={'databases'} />
<MainPageHeader title={'Databases'}>
<Can action={'database.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{databaseLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{databases.length} of {databaseLimit} databases
</p>
)}
{databaseLimit > 0 && databaseLimit !== databases.length && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Database
</ActionButton>
)}
</div>
</Can>
<MainPageHeader
direction='column'
title={'Databases'}
titleChildren={
<Can action={'database.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{databaseLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{databases.length} of {databaseLimit} databases
</p>
)}
{databaseLimit > 0 && databaseLimit !== databases.length && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Database
</ActionButton>
)}
</div>
</Can>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Create and manage MySQL databases for your server. Configure database access, manage users, and view connection details.
</p>
</MainPageHeader>
<Formik
@@ -162,7 +170,7 @@ const DatabasesContainer = () => {
</For>
</PageListContainer>
) : (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon icon={faDatabase} className='w-8 h-8 text-zinc-400' />

View File

@@ -102,15 +102,23 @@ const FileManagerContainer = () => {
<ServerContentBlock className='p-0!' title={'File Manager'} showFlashKey={'files'}>
<div className='px-2 sm:px-14 pt-2 sm:pt-14'>
<ErrorBoundary>
<MainPageHeader title={'Files'}>
<Can action={'file.create'}>
<div className='flex flex-row gap-1'>
<FileManagerStatus />
<NewDirectoryButton />
<NewFileButton id={id} />
<UploadButton />
</div>
</Can>
<MainPageHeader
direction='column'
title={'Files'}
titleChildren={
<Can action={'file.create'}>
<div className='flex flex-row gap-1'>
<FileManagerStatus />
<NewDirectoryButton />
<NewFileButton id={id} />
<UploadButton />
</div>
</Can>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Manage your server files and directories. Upload, download, edit, and organize your server's file system with our integrated file manager.
</p>
</MainPageHeader>
<div className={'flex flex-wrap-reverse md:flex-nowrap mb-4'}>
<FileManagerBreadcrumbs

View File

@@ -10,6 +10,8 @@ import Code from '@/components/elements/Code';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { Textarea } from '@/components/elements/Input';
import InputSpinner from '@/components/elements/InputSpinner';
import { Dialog } from '@/components/elements/dialog';
import { PageListItem } from '@/components/elements/pages/PageList';
import { ip } from '@/lib/formatters';
@@ -31,6 +33,7 @@ const AllocationRow = ({ allocation }: Props) => {
const [loading, setLoading] = useState(false);
const [isEditingNotes, setIsEditingNotes] = useState(false);
const [notesValue, setNotesValue] = useState(allocation.notes || '');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<PageListItem>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 w-full'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-3'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
@@ -206,7 +209,7 @@ const AllocationRow = ({ allocation }: Props) => {
</Can>
</div>
</div>
</div>
</PageListItem>
);
};

View File

@@ -58,21 +58,29 @@ const NetworkContainer = () => {
return (
<ServerContentBlock title={'Network'}>
<FlashMessageRender byKey={'server:network'} />
<MainPageHeader title={'Network'}>
{data && allocationLimit > 0 && (
<Can action={'allocation.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{data.length} of {allocationLimit} allowed allocations
</p>
{allocationLimit > data.length && (
<ActionButton variant='primary' onClick={onCreateAllocation}>
New Allocation
</ActionButton>
)}
</div>
</Can>
)}
<MainPageHeader
direction='column'
title={'Network'}
titleChildren={
data && allocationLimit > 0 ? (
<Can action={'allocation.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{data.filter(allocation => !allocation.isDefault).length} of {allocationLimit} allowed allocations
</p>
{allocationLimit > data.filter(allocation => !allocation.isDefault).length && (
<ActionButton variant='primary' onClick={onCreateAllocation}>
New Allocation
</ActionButton>
)}
</div>
</Can>
) : undefined
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Configure network allocations for your server. Manage IP addresses and ports that your server can bind to for incoming connections.
</p>
</MainPageHeader>
{!data ? (
@@ -88,7 +96,7 @@ const NetworkContainer = () => {
</For>
</PageListContainer>
) : (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>

View File

@@ -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<Props> = ({
visible,
operationId,
operationType,
onClose,
onComplete,
onError
}) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [operation, setOperation] = useState<ServerOperation | null>(null);
const [error, setError] = useState<string | null>(null);
const [autoCloseTimer, setAutoCloseTimer] = useState<NodeJS.Timeout | null>(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 <Spinner size={'small'} />;
case 'success':
return (
<div className="w-5 h-5 rounded-full bg-green-400 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
);
case 'error':
return <HugeIconsAlert fill="currentColor" className="w-5 h-5 text-red-400" />;
default:
return <Spinner size={'small'} />;
}
};
const canClose = canCloseOperation(operation, error);
const statusStyling = operation ? getStatusStyling(operation.status) : null;
const handleClose = () => {
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
setAutoCloseTimer(null);
}
onClose();
};
return (
<Modal
visible={visible}
onDismissed={canClose ? handleClose : () => {}}
closeOnEscape={canClose}
closeOnBackground={canClose}
>
<div className="w-full max-w-md mx-auto">
<div className="bg-[#1a1a1a] border border-[#ffffff12] rounded-lg p-6">
<div className="text-center space-y-6">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-neutral-200">
{operationType} in Progress
</h3>
{operationId && (
<p className="text-xs text-neutral-400 font-mono">
Operation ID: {formatOperationId(operationId)}
</p>
)}
</div>
{error ? (
<div className="space-y-4">
<div className="flex items-center justify-center space-x-3">
<HugeIconsAlert fill="currentColor" className="w-6 h-6 text-red-400" />
<span className="text-red-400 font-medium">Error</span>
</div>
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-300">{error}</p>
</div>
</div>
) : operation ? (
<div className="space-y-4">
<div className="flex items-center justify-center space-x-3">
{renderStatusIcon(operation.status)}
<span className={`font-medium capitalize ${statusStyling?.color || 'text-neutral-400'}`}>
{operation.status}
</span>
</div>
<div className="p-3 bg-[#ffffff08] border border-[#ffffff12] rounded-lg">
<p className="text-sm text-neutral-300">
{operation.message || 'Processing...'}
</p>
</div>
<div className="text-xs text-neutral-400">
Duration: {formatDuration(operation.created_at, operation.updated_at)}
</div>
{isActiveStatus(operation.status) && (
<div className="w-full bg-[#ffffff08] rounded-full h-1.5">
<div className="bg-brand h-1.5 rounded-full animate-pulse transition-all duration-300"
style={{ width: `${UI_CONFIG.ESTIMATED_PROGRESS_WIDTH}%` }}
/>
</div>
)}
{isCompletedStatus(operation.status) && (
<div className={`p-3 ${statusStyling?.bgColor} border ${statusStyling?.borderColor} rounded-lg`}>
<p className={`text-sm ${statusStyling?.textColor} font-medium`}>
Operation completed successfully
</p>
{autoCloseTimer && (
<p className="text-xs text-green-200 mt-1">
This window will close automatically in 3 seconds
</p>
)}
</div>
)}
{isFailedStatus(operation.status) && (
<div className={`p-3 ${statusStyling?.bgColor} border ${statusStyling?.borderColor} rounded-lg`}>
<p className={`text-sm ${statusStyling?.textColor} font-medium`}>
Operation failed
</p>
{operation.message && (
<p className="text-xs text-red-200 mt-1">
{operation.message}
</p>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center space-x-3">
<Spinner size={'small'} />
<span className="text-neutral-400">Initializing...</span>
</div>
</div>
)}
<div className="flex justify-center space-x-3">
{canClose && (
<button
onClick={handleClose}
className="px-4 py-2 bg-brand hover:bg-brand/80 text-white text-sm font-medium rounded-lg transition-colors"
>
{operation?.is_completed ? 'Done' : 'Close'}
</button>
)}
{operation && isActiveStatus(operation.status) && (
<div className="text-xs text-neutral-500 flex items-center">
<span>This window will close automatically when complete</span>
</div>
)}
</div>
</div>
</div>
</div>
</Modal>
);
};
export default OperationProgressModal;

View File

@@ -41,20 +41,44 @@ function ScheduleContainer() {
return (
<ServerContentBlock title={'Schedules'}>
<FlashMessageRender byKey={'schedules'} />
<MainPageHeader title={'Schedules'}>
<Can action={'schedule.create'}>
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)} />
<ActionButton variant='primary' onClick={() => setVisible(true)}>
New Schedule
</ActionButton>
</Can>
<MainPageHeader
direction='column'
title={'Schedules'}
titleChildren={
<Can action={'schedule.create'}>
<ActionButton variant='primary' onClick={() => setVisible(true)}>
New Schedule
</ActionButton>
</Can>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Automate server tasks with scheduled commands. Create recurring tasks to manage your server, run backups, or execute custom commands.
</p>
</MainPageHeader>
<Can action={'schedule.create'}>
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)} />
</Can>
{!schedules.length && loading ? null : (
<>
{schedules.length === 0 ? (
<p className={`text-center text-sm text-neutral-300`}>
There are no schedules configured for this server.
</p>
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No schedules found</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
Your server does not have any scheduled tasks. Create one to automate server management.
</p>
</div>
</div>
) : (
<PageListContainer data-pyro-schedules>
{schedules.map((schedule) => (

View File

@@ -27,7 +27,11 @@ const SettingsContainer = () => {
return (
<ServerContentBlock title={'Settings'}>
<FlashMessageRender byKey={'settings'} />
<MainPageHeader title={'Settings'} />
<MainPageHeader direction='column' title={'Settings'}>
<p className='text-sm text-neutral-400 leading-relaxed'>
Configure your server settings, manage SFTP access, and access debug information. Make changes to server name and reinstall when needed.
</p>
</MainPageHeader>
<Can action={'settings.rename'}>
<div className={`mb-6 md:mb-10`}>
<RenameServerBox />

File diff suppressed because it is too large Load Diff

View File

@@ -451,7 +451,7 @@ const StartupContainer = () => {
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3'>
<div className='flex-1'>
<p className='text-sm text-amber-200'>
<span className='font-medium'> Notice:</span> This server&apos;s
<span className='font-medium'>Notice:</span> This server&apos;s
Docker image has been manually set by an administrator and cannot be
changed through this interface.
</p>

View File

@@ -56,20 +56,28 @@ const UsersContainer = () => {
return (
<ServerContentBlock title={'Users'}>
<FlashMessageRender byKey={'users'} />
<MainPageHeader title={'Users'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>0 users</p>
<Can action={'user.create'}>
<ActionButton
variant='primary'
onClick={() => navigate(`/server/${serverId}/users/new`)}
className='flex items-center gap-2'
>
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
New User
</ActionButton>
</Can>
</div>
<MainPageHeader
direction='column'
title={'Users'}
titleChildren={
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>0 users</p>
<Can action={'user.create'}>
<ActionButton
variant='primary'
onClick={() => navigate(`/server/${serverId}/users/new`)}
className='flex items-center gap-2'
>
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
New User
</ActionButton>
</Can>
</div>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Manage user access to your server. Grant specific permissions to other users to help you manage and maintain your server.
</p>
</MainPageHeader>
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
@@ -81,23 +89,31 @@ const UsersContainer = () => {
return (
<ServerContentBlock title={'Users'}>
<FlashMessageRender byKey={'users'} />
<MainPageHeader title={'Users'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>{subusers.length} users</p>
<Can action={'user.create'}>
<ActionButton
variant='primary'
onClick={() => navigate(`/server/${serverId}/users/new`)}
className='flex items-center gap-2'
>
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
New User
</ActionButton>
</Can>
</div>
<MainPageHeader
direction='column'
title={'Users'}
titleChildren={
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>{subusers.length} users</p>
<Can action={'user.create'}>
<ActionButton
variant='primary'
onClick={() => navigate(`/server/${serverId}/users/new`)}
className='flex items-center gap-2'
>
<FontAwesomeIcon icon={faPlus} className='w-4 h-4' />
New User
</ActionButton>
</Can>
</div>
}
>
<p className='text-sm text-neutral-400 leading-relaxed'>
Manage user access to your server. Grant specific permissions to other users to help you manage and maintain your server.
</p>
</MainPageHeader>
{!subusers.length ? (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon icon={faUser} className='w-8 h-8 text-zinc-400' />

View File

@@ -0,0 +1,123 @@
import { OPERATION_STATUS, OperationStatus } from '@/api/server/serverOperations';
/**
* UI configuration constants for server operations.
*/
export const UI_CONFIG = {
AUTO_CLOSE_DELAY: 3000,
PROGRESS_UPDATE_INTERVAL: 30000,
ESTIMATED_PROGRESS_WIDTH: 60,
} as const;
/**
* Status styling configuration for different operation states.
*/
export const STATUS_CONFIG = {
[OPERATION_STATUS.PENDING]: {
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/10',
borderColor: 'border-yellow-500/20',
textColor: 'text-yellow-300',
},
[OPERATION_STATUS.RUNNING]: {
color: 'text-blue-400',
bgColor: 'bg-blue-500/10',
borderColor: 'border-blue-500/20',
textColor: 'text-blue-300',
},
[OPERATION_STATUS.COMPLETED]: {
color: 'text-green-400',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/20',
textColor: 'text-green-300',
},
[OPERATION_STATUS.FAILED]: {
color: 'text-red-400',
bgColor: 'bg-red-500/10',
borderColor: 'border-red-500/20',
textColor: 'text-red-300',
},
[OPERATION_STATUS.CANCELLED]: {
color: 'text-gray-400',
bgColor: 'bg-gray-500/10',
borderColor: 'border-gray-500/20',
textColor: 'text-gray-300',
},
} as const;
/**
* Get status-specific styling configuration.
*/
export const getStatusStyling = (status: OperationStatus) => {
return STATUS_CONFIG[status] || STATUS_CONFIG[OPERATION_STATUS.PENDING];
};
/**
* Get appropriate icon type for operation status.
*/
export const getStatusIconType = (status: OperationStatus): 'spinner' | 'success' | 'error' => {
switch (status) {
case OPERATION_STATUS.PENDING:
case OPERATION_STATUS.RUNNING:
return 'spinner';
case OPERATION_STATUS.COMPLETED:
return 'success';
case OPERATION_STATUS.FAILED:
case OPERATION_STATUS.CANCELLED:
return 'error';
default:
return 'spinner';
}
};
/**
* Format operation duration for display.
*/
export const formatDuration = (createdAt: string, updatedAt: string): string => {
const start = new Date(createdAt);
const end = new Date(updatedAt);
const duration = Math.floor((end.getTime() - start.getTime()) / 1000);
if (duration < 60) {
return `${duration}s`;
} else if (duration < 3600) {
return `${Math.floor(duration / 60)}m ${duration % 60}s`;
} else {
return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
}
};
/**
* Check if operation modal can be closed or dismissed.
*/
export const canCloseOperation = (operation: any, error: string | null): boolean => {
return Boolean(operation && (operation.is_completed || operation.has_failed) || error);
};
/**
* Format operation ID for compact display.
*/
export const formatOperationId = (operationId: string): string => {
return `${operationId.split('-')[0]}...`;
};
/**
* Check if operation status is active (pending or running).
*/
export const isActiveStatus = (status: OperationStatus): boolean => {
return status === OPERATION_STATUS.PENDING || status === OPERATION_STATUS.RUNNING;
};
/**
* Check if operation status indicates successful completion.
*/
export const isCompletedStatus = (status: OperationStatus): boolean => {
return status === OPERATION_STATUS.COMPLETED;
};
/**
* Check if operation status indicates failure or cancellation.
*/
export const isFailedStatus = (status: OperationStatus): boolean => {
return status === OPERATION_STATUS.FAILED || status === OPERATION_STATUS.CANCELLED;
};

View File

@@ -19,36 +19,36 @@ use Pterodactyl\Http\Middleware\Api\Client\Server\AuthenticateServerAccess;
Route::get('/', [Client\ClientController::class, 'index'])->name('api:client.index');
Route::get('/permissions', [Client\ClientController::class, 'permissions']);
Route::get('/version', function () {
return response()->json(['version' => config('app.version')]);
return response()->json(['version' => config('app.version')]);
});
Route::prefix('/nests')->group(function () {
Route::get('/', [Client\Nests\NestController::class, 'index'])->name('api:client.nests');
Route::get('/{nest}', [Client\Nests\NestController::class, 'view'])->name('api:client.nests.view');
Route::get('/', [Client\Nests\NestController::class, 'index'])->name('api:client.nests');
Route::get('/{nest}', [Client\Nests\NestController::class, 'view'])->name('api:client.nests.view');
});
Route::prefix('/account')->middleware(AccountSubject::class)->group(function () {
Route::prefix('/')->withoutMiddleware(RequireTwoFactorAuthentication::class)->group(function () {
Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account');
Route::get('/two-factor', [Client\TwoFactorController::class, 'index']);
Route::post('/two-factor', [Client\TwoFactorController::class, 'store']);
Route::post('/two-factor/disable', [Client\TwoFactorController::class, 'delete']);
});
Route::prefix('/')->withoutMiddleware(RequireTwoFactorAuthentication::class)->group(function () {
Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account');
Route::get('/two-factor', [Client\TwoFactorController::class, 'index']);
Route::post('/two-factor', [Client\TwoFactorController::class, 'store']);
Route::post('/two-factor/disable', [Client\TwoFactorController::class, 'delete']);
});
Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email');
Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password');
Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email');
Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password');
Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity');
Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity');
Route::get('/api-keys', [Client\ApiKeyController::class, 'index']);
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
Route::get('/api-keys', [Client\ApiKeyController::class, 'index']);
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
Route::prefix('/ssh-keys')->group(function () {
Route::get('/', [Client\SSHKeyController::class, 'index']);
Route::post('/', [Client\SSHKeyController::class, 'store']);
Route::post('/remove', [Client\SSHKeyController::class, 'delete']);
});
Route::prefix('/ssh-keys')->group(function () {
Route::get('/', [Client\SSHKeyController::class, 'index']);
Route::post('/', [Client\SSHKeyController::class, 'store']);
Route::post('/remove', [Client\SSHKeyController::class, 'delete']);
});
});
/*
@@ -60,96 +60,108 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function ()
|
*/
Route::group([
'prefix' => '/servers/{server}',
'middleware' => [
ServerSubject::class,
AuthenticateServerAccess::class,
ResourceBelongsToServer::class,
],
'prefix' => '/servers/{server}',
'middleware' => [
ServerSubject::class,
AuthenticateServerAccess::class,
ResourceBelongsToServer::class,
],
], function () {
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view');
Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws');
Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources');
Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity');
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view');
Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws');
Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources');
Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity');
Route::post('/command', [Client\Servers\CommandController::class, 'index']);
Route::post('/power', [Client\Servers\PowerController::class, 'index']);
Route::post('/command', [Client\Servers\CommandController::class, 'index']);
Route::post('/power', [Client\Servers\PowerController::class, 'index']);
Route::group(['prefix' => '/databases'], function () {
Route::get('/', [Client\Servers\DatabaseController::class, 'index']);
Route::post('/', [Client\Servers\DatabaseController::class, 'store']);
Route::post('/{database}/rotate-password', [Client\Servers\DatabaseController::class, 'rotatePassword']);
Route::delete('/{database}', [Client\Servers\DatabaseController::class, 'delete']);
});
Route::group(['prefix' => '/databases'], function () {
Route::get('/', [Client\Servers\DatabaseController::class, 'index']);
Route::post('/', [Client\Servers\DatabaseController::class, 'store']);
Route::post('/{database}/rotate-password', [Client\Servers\DatabaseController::class, 'rotatePassword']);
Route::delete('/{database}', [Client\Servers\DatabaseController::class, 'delete']);
});
Route::group(['prefix' => '/files'], function () {
Route::get('/list', [Client\Servers\FileController::class, 'directory']);
Route::get('/contents', [Client\Servers\FileController::class, 'contents']);
Route::get('/download', [Client\Servers\FileController::class, 'download']);
Route::put('/rename', [Client\Servers\FileController::class, 'rename']);
Route::post('/copy', [Client\Servers\FileController::class, 'copy']);
Route::post('/write', [Client\Servers\FileController::class, 'write']);
Route::post('/compress', [Client\Servers\FileController::class, 'compress']);
Route::post('/decompress', [Client\Servers\FileController::class, 'decompress']);
Route::post('/delete', [Client\Servers\FileController::class, 'delete']);
Route::post('/create-folder', [Client\Servers\FileController::class, 'create']);
Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']);
Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:10,5']);
Route::get('/upload', Client\Servers\FileUploadController::class);
});
Route::group(['prefix' => '/files'], function () {
Route::get('/list', [Client\Servers\FileController::class, 'directory']);
Route::get('/contents', [Client\Servers\FileController::class, 'contents']);
Route::get('/download', [Client\Servers\FileController::class, 'download']);
Route::put('/rename', [Client\Servers\FileController::class, 'rename']);
Route::post('/copy', [Client\Servers\FileController::class, 'copy']);
Route::post('/write', [Client\Servers\FileController::class, 'write']);
Route::post('/compress', [Client\Servers\FileController::class, 'compress']);
Route::post('/decompress', [Client\Servers\FileController::class, 'decompress']);
Route::post('/delete', [Client\Servers\FileController::class, 'delete']);
Route::post('/create-folder', [Client\Servers\FileController::class, 'create']);
Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']);
Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:10,5']);
Route::get('/upload', Client\Servers\FileUploadController::class);
});
Route::group(['prefix' => '/schedules'], function () {
Route::get('/', [Client\Servers\ScheduleController::class, 'index']);
Route::post('/', [Client\Servers\ScheduleController::class, 'store']);
Route::get('/{schedule}', [Client\Servers\ScheduleController::class, 'view']);
Route::post('/{schedule}', [Client\Servers\ScheduleController::class, 'update']);
Route::post('/{schedule}/execute', [Client\Servers\ScheduleController::class, 'execute']);
Route::delete('/{schedule}', [Client\Servers\ScheduleController::class, 'delete']);
Route::group(['prefix' => '/schedules'], function () {
Route::get('/', [Client\Servers\ScheduleController::class, 'index']);
Route::post('/', [Client\Servers\ScheduleController::class, 'store']);
Route::get('/{schedule}', [Client\Servers\ScheduleController::class, 'view']);
Route::post('/{schedule}', [Client\Servers\ScheduleController::class, 'update']);
Route::post('/{schedule}/execute', [Client\Servers\ScheduleController::class, 'execute']);
Route::delete('/{schedule}', [Client\Servers\ScheduleController::class, 'delete']);
Route::post('/{schedule}/tasks', [Client\Servers\ScheduleTaskController::class, 'store']);
Route::post('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'update']);
Route::delete('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'delete']);
});
Route::post('/{schedule}/tasks', [Client\Servers\ScheduleTaskController::class, 'store']);
Route::post('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'update']);
Route::delete('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'delete']);
});
Route::group(['prefix' => '/network'], function () {
Route::get('/allocations', [Client\Servers\NetworkAllocationController::class, 'index']);
Route::post('/allocations', [Client\Servers\NetworkAllocationController::class, 'store']);
Route::post('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'update']);
Route::post('/allocations/{allocation}/primary', [Client\Servers\NetworkAllocationController::class, 'setPrimary']);
Route::delete('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'delete']);
});
Route::group(['prefix' => '/network'], function () {
Route::get('/allocations', [Client\Servers\NetworkAllocationController::class, 'index']);
Route::post('/allocations', [Client\Servers\NetworkAllocationController::class, 'store']);
Route::post('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'update']);
Route::post('/allocations/{allocation}/primary', [Client\Servers\NetworkAllocationController::class, 'setPrimary']);
Route::delete('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'delete']);
});
Route::group(['prefix' => '/users'], function () {
Route::get('/', [Client\Servers\SubuserController::class, 'index']);
Route::post('/', [Client\Servers\SubuserController::class, 'store']);
Route::get('/{user}', [Client\Servers\SubuserController::class, 'view']);
Route::post('/{user}', [Client\Servers\SubuserController::class, 'update']);
Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']);
});
Route::group(['prefix' => '/users'], function () {
Route::get('/', [Client\Servers\SubuserController::class, 'index']);
Route::post('/', [Client\Servers\SubuserController::class, 'store']);
Route::get('/{user}', [Client\Servers\SubuserController::class, 'view']);
Route::post('/{user}', [Client\Servers\SubuserController::class, 'update']);
Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']);
});
Route::group(['prefix' => '/backups'], function () {
Route::get('/', [Client\Servers\BackupController::class, 'index']);
Route::post('/', [Client\Servers\BackupController::class, 'store']);
Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']);
Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']);
Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']);
Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']);
});
Route::group(['prefix' => '/backups'], function () {
Route::get('/', [Client\Servers\BackupController::class, 'index']);
Route::post('/', [Client\Servers\BackupController::class, 'store'])
->middleware('server.operation.rate-limit');
Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']);
Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']);
Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore'])
->middleware('server.operation.rate-limit');
Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']);
});
Route::group(['prefix' => '/startup'], function () {
Route::get('/', [Client\Servers\StartupController::class, 'index']);
Route::put('/variable', [Client\Servers\StartupController::class, 'update']);
Route::put('/command', [Client\Servers\StartupController::class, 'updateCommand']);
Route::get('/command/default', [Client\Servers\StartupController::class, 'getDefaultCommand']);
Route::post('/command/process', [Client\Servers\StartupController::class, 'processCommand']);
});
Route::group(['prefix' => '/startup'], function () {
Route::get('/', [Client\Servers\StartupController::class, 'index']);
Route::put('/variable', [Client\Servers\StartupController::class, 'update']);
Route::put('/command', [Client\Servers\StartupController::class, 'updateCommand']);
Route::get('/command/default', [Client\Servers\StartupController::class, 'getDefaultCommand']);
Route::post('/command/process', [Client\Servers\StartupController::class, 'processCommand']);
});
Route::group(['prefix' => '/settings'], function () {
Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']);
Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall']);
Route::put('/docker-image', [Client\Servers\SettingsController::class, 'dockerImage']);
Route::post('/docker-image/revert', [Client\Servers\SettingsController::class, 'revertDockerImage']);
Route::put('/egg', [Client\Servers\SettingsController::class, 'changeEgg']);
});
Route::group(['prefix' => '/settings'], function () {
Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']);
Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall'])
->middleware('server.operation.rate-limit');
Route::put('/docker-image', [Client\Servers\SettingsController::class, 'dockerImage']);
Route::post('/docker-image/revert', [Client\Servers\SettingsController::class, 'revertDockerImage']);
Route::put('/egg', [Client\Servers\SettingsController::class, 'changeEgg']);
Route::post('/egg/preview', [Client\Servers\SettingsController::class, 'previewEggChange'])
->middleware('server.operation.rate-limit');
Route::post('/egg/apply', [Client\Servers\SettingsController::class, 'applyEggChange'])
->middleware('server.operation.rate-limit');
});
Route::group(['prefix' => '/operations'], function () {
Route::get('/', [Client\Servers\SettingsController::class, 'getServerOperations']);
Route::get('/{operationId}', [Client\Servers\SettingsController::class, 'getOperationStatus']);
});
});