mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-05 19:51:59 +02:00
feat: asynchronous server operations.
This commit is contained in:
57
app/Exceptions/ServerOperations/ServerOperationException.php
Normal file
57
app/Exceptions/ServerOperations/ServerOperationException.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
12
app/Exceptions/Service/Backup/BackupFailedException.php
Normal file
12
app/Exceptions/Service/Backup/BackupFailedException.php
Normal 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.
|
||||
*/
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Resources/ServerOperationResource.php
Normal file
44
app/Http/Resources/ServerOperationResource.php
Normal 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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
341
app/Jobs/Server/ApplyEggChangeJob.php
Normal file
341
app/Jobs/Server/ApplyEggChangeJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
165
app/Models/ServerOperation.php
Normal file
165
app/Models/ServerOperation.php
Normal 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());
|
||||
}
|
||||
}
|
||||
52
app/Providers/ServerOperationServiceProvider.php
Normal file
52
app/Providers/ServerOperationServiceProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
205
app/Services/ServerOperations/EggChangeService.php
Normal file
205
app/Services/ServerOperations/EggChangeService.php
Normal 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";
|
||||
}
|
||||
}
|
||||
173
app/Services/ServerOperations/ServerOperationService.php
Normal file
173
app/Services/ServerOperations/ServerOperationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
/*
|
||||
|
||||
41
config/server_operations.php
Normal file
41
config/server_operations.php
Normal 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
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
27
resources/scripts/api/server/applyEggChange.ts
Normal file
27
resources/scripts/api/server/applyEggChange.ts
Normal 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;
|
||||
};
|
||||
35
resources/scripts/api/server/previewEggChange.ts
Normal file
35
resources/scripts/api/server/previewEggChange.ts
Normal 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;
|
||||
};
|
||||
175
resources/scripts/api/server/serverOperations.ts
Normal file
175
resources/scripts/api/server/serverOperations.ts
Normal 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)
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
@@ -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's
|
||||
<span className='font-medium'>Notice:</span> This server's
|
||||
Docker image has been manually set by an administrator and cannot be
|
||||
changed through this interface.
|
||||
</p>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
123
resources/scripts/lib/server-operations.ts
Normal file
123
resources/scripts/lib/server-operations.ts
Normal 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;
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user