feat: backups v2 + elytra jobs

This commit is contained in:
Elizabeth
2025-09-27 16:06:40 -05:00
parent b64cab3511
commit 22bc0ace77
51 changed files with 2260 additions and 2940 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
/vendor
# Elytra binary
elytra
!elytra/
*.DS_Store*
!.env.ci
!.env.example

13
Vagrantfile vendored
View File

@@ -64,12 +64,13 @@ Vagrant.configure("2") do |config|
end
if Vagrant::Util::Platform.windows?
config.vm.synced_folder ".", "/var/www/pterodactyl",
type: "rsync",
rsync__exclude: [".git/", "node_modules/", "vendor/**", ".vagrant/", "storage/logs/", "storage/framework/cache/", "storage/framework/sessions/", "storage/framework/views/"],
rsync__args: ["--verbose", "--archive", "--delete", "-z", "--copy-links"]
config.vm.synced_folder ".", "/home/vagrant/pyrodactyl",
type: "virtualbox",
owner: "vagrant",
group: "vagrant",
mount_options: ["dmode=775", "fmode=664"]
else
config.vm.synced_folder ".", "/var/www/pterodactyl",
config.vm.synced_folder ".", "/home/vagrant/pyrodactyl",
type: "nfs",
nfs_version: 4,
nfs_udp: false,
@@ -84,6 +85,6 @@ Vagrant.configure("2") do |config|
Pyrodactyl is up and running at http://localhost:3000
Login with:
username: dev@pyro.host
password: password
password: dev
MSG
end

View File

@@ -1,110 +0,0 @@
<?php
namespace Pterodactyl\Console\Commands\Backups;
use Illuminate\Console\Command;
use Pterodactyl\Services\Backups\BackupJobPollingService;
use Illuminate\Support\Facades\Log;
class PollBackupJobsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'p:backups:poll
{--timeout=300 : Maximum execution time in seconds}
{--limit=50 : Maximum number of jobs to poll per execution}';
/**
* The console command description.
*/
protected $description = 'Poll backup job statuses from Elytra and update panel records';
/**
* Create a new command instance.
*/
public function __construct(
private BackupJobPollingService $pollingService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$timeout = (int) $this->option('timeout');
$limit = (int) $this->option('limit');
$startTime = time();
$this->info('Starting backup job polling...');
try {
$totalResults = ['updated' => 0, 'errors' => 0, 'completed' => 0];
$iterations = 0;
do {
$iterations++;
$results = $this->pollingService->pollAllJobs();
// Accumulate results
$totalResults['updated'] += $results['updated'];
$totalResults['errors'] += $results['errors'];
$totalResults['completed'] += $results['completed'];
if ($results['updated'] > 0 || $results['errors'] > 0) {
$this->line(sprintf(
'Iteration %d: Updated %d jobs, %d errors, %d completed',
$iterations,
$results['updated'],
$results['errors'],
$results['completed']
));
}
// Check if we should continue
$elapsed = time() - $startTime;
$shouldContinue = $elapsed < $timeout &&
$totalResults['updated'] < $limit &&
($results['updated'] > 0 || $iterations === 1); // Always do at least one full iteration
if ($shouldContinue && $results['updated'] > 0) {
// Brief pause before next iteration to avoid overwhelming Elytra
sleep(2);
}
} while ($shouldContinue);
$this->info(sprintf(
'Backup job polling completed. Total: %d updated, %d errors, %d completed in %d iterations (%.2fs)',
$totalResults['updated'],
$totalResults['errors'],
$totalResults['completed'],
$iterations,
time() - $startTime
));
Log::info('Backup job polling completed', [
'updated' => $totalResults['updated'],
'errors' => $totalResults['errors'],
'completed' => $totalResults['completed'],
'iterations' => $iterations,
'duration' => time() - $startTime,
]);
return self::SUCCESS;
} catch (\Exception $e) {
$this->error('Backup job polling failed: ' . $e->getMessage());
Log::error('Backup job polling failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'duration' => time() - $startTime,
]);
return self::FAILURE;
}
}
}

View File

@@ -4,7 +4,6 @@ namespace Pterodactyl\Console\Commands\Maintenance;
use Illuminate\Console\Command;
use Pterodactyl\Models\Backup;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Illuminate\Database\Eloquent\Builder;
class DeleteOrphanedBackupsCommand extends Command
@@ -16,7 +15,7 @@ class DeleteOrphanedBackupsCommand extends Command
/**
* DeleteOrphanedBackupsCommand constructor.
*/
public function __construct(private DeleteBackupService $deleteBackupService)
public function __construct()
{
parent::__construct();
}
@@ -80,8 +79,8 @@ class DeleteOrphanedBackupsCommand extends Command
$deletedCount++;
$this->info("Force deleted soft-deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}");
} else {
// Use the service to properly delete from storage and database
$this->deleteBackupService->handle($backup);
// Delete the orphaned backup from the database
$backup->forceDelete();
$deletedCount++;
$this->info("Deleted backup: {$backup->uuid} ({$backup->name}) - {$this->formatBytes($backup->bytes)}");
}

View File

@@ -4,7 +4,7 @@ namespace Pterodactyl\Console\Commands\Maintenance;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Models\Backup;
class PruneOrphanedBackupsCommand extends Command
{
@@ -15,7 +15,7 @@ class PruneOrphanedBackupsCommand extends Command
/**
* PruneOrphanedBackupsCommand constructor.
*/
public function __construct(private BackupRepository $backupRepository)
public function __construct()
{
parent::__construct();
}
@@ -27,7 +27,7 @@ class PruneOrphanedBackupsCommand extends Command
throw new \InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
}
$query = $this->backupRepository->getBuilder()
$query = Backup::query()
->whereNull('completed_at')
->where('created_at', '<=', CarbonImmutable::now()->subMinutes($since)->toDateTimeString());

View File

@@ -11,7 +11,6 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Pterodactyl\Console\Commands\Schedule\ProcessRunnableCommand;
use Pterodactyl\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use Pterodactyl\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use Pterodactyl\Console\Commands\Backups\PollBackupJobsCommand;
class Kernel extends ConsoleKernel
{
@@ -33,7 +32,6 @@ class Kernel extends ConsoleKernel
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();
$schedule->command(CleanServiceBackupFilesCommand::class)->daily();
$schedule->command(PollBackupJobsCommand::class)->everyMinute()->withoutOverlapping();
if (config('backups.prune_age')) {
$schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes();

View File

@@ -0,0 +1,24 @@
<?php
namespace Pterodactyl\Contracts\Elytra;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ElytraJob;
use Pterodactyl\Repositories\Elytra\ElytraRepository;
interface Job
{
public static function getSupportedJobTypes(): array;
public function getRequiredPermissions(string $operation): array;
public function validateJobData(array $jobData): array;
public function submitToElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string;
public function cancelOnElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): void;
public function processStatusUpdate(ElytraJob $job, array $statusData): void;
public function formatJobResponse(ElytraJob $job): array;
}

View File

@@ -106,6 +106,14 @@ class BackupManager
return new InMemoryFilesystemAdapter();
}
/**
* Creates a new Elytra adapter.
*/
public function createElytraAdapter(array $config): FilesystemAdapter
{
return new InMemoryFilesystemAdapter();
}
/**
* Creates a new S3 adapter.
*/

View File

@@ -1,498 +0,0 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Services\Backups\BackupStorageService;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Services\Backups\AsyncBackupService;
use Pterodactyl\Services\Backups\ServerStateService;
use Pterodactyl\Services\Backups\BackupJobPollingService;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
class BackupController extends ClientApiController
{
/**
* BackupController constructor.
*/
public function __construct(
private DaemonBackupRepository $daemonRepository,
private DeleteBackupService $deleteBackupService,
private InitiateBackupService $initiateBackupService,
private AsyncBackupService $asyncBackupService,
private BackupJobPollingService $pollingService,
private DownloadLinkService $downloadLinkService,
private BackupRepository $repository,
private ServerStateService $serverStateService,
private BackupStorageService $backupStorageService,
) {
parent::__construct();
}
/**
* Returns all the backups for a given server instance in a paginated
* result set.
*
* @throws AuthorizationException
*/
public function index(Request $request, Server $server): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
$limit = min($request->query('per_page') ?? 20, 50);
// Sort backups: locked ones first, then by created_at descending (latest first)
$backups = $server->backups()
->orderByRaw('is_locked DESC, created_at DESC')
->paginate($limit);
$storageInfo = $this->backupStorageService->getStorageUsageInfo($server);
return $this->fractal->collection($backups)
->transformWith($this->getTransformer(BackupTransformer::class))
->addMeta([
'backup_count' => $this->repository->getNonFailedBackups($server)->count(),
'storage' => [
'used_mb' => $storageInfo['used_mb'],
'limit_mb' => $storageInfo['limit_mb'],
'has_limit' => $storageInfo['has_limit'],
'usage_percentage' => $storageInfo['usage_percentage'] ?? null,
'available_mb' => $storageInfo['available_mb'] ?? null,
'is_over_limit' => $storageInfo['is_over_limit'] ?? false,
],
'limits' => [
'count_limit' => $server->backup_limit,
'has_count_limit' => $server->hasBackupCountLimit(),
'storage_limit_mb' => $server->backup_storage_limit,
'has_storage_limit' => $server->hasBackupStorageLimit(),
],
])
->toArray();
}
/**
* Starts the async backup process for a server.
*
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Throwable
*/
public function store(StoreBackupRequest $request, Server $server): array
{
$action = $this->asyncBackupService
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
// Only set the lock status if the user even has permission to delete backups,
// otherwise ignore this status. This gets a little funky since it isn't clear
// how best to allow a user to create a backup that is locked without also preventing
// them from just filling up a server with backups that can never be deleted?
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
}
$backup = $action->initiate($server, $request->input('name'));
Activity::event('server:backup.start')
->subject($backup)
->property([
'name' => $backup->name,
'locked' => (bool) $request->input('is_locked'),
'adapter' => $backup->disk,
'async' => true,
'job_id' => $backup->job_id,
])
->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->addMeta([
'job_id' => $backup->job_id,
'status' => $backup->job_status,
'progress' => $backup->job_progress,
'message' => $backup->job_message,
])
->toArray();
}
/**
* Toggles the lock status of a given backup for a server.
*
* @throws \Throwable
* @throws AuthorizationException
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
$backup->update(['is_locked' => !$backup->is_locked]);
Activity::event($action)->subject($backup)->property('name', $backup->name)->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Rename a backup.
*
* @throws AuthorizationException
*/
public function rename(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$request->validate([
'name' => 'required|string|min:1|max:191',
]);
$oldName = $backup->name;
$newName = trim($request->input('name'));
// Sanitize backup name to prevent injection
$newName = preg_replace('/[^a-zA-Z0-9\s\-_\.\(\)→:,]/', '', $newName);
$newName = substr($newName, 0, 191); // Limit to database field length
if (empty($newName)) {
throw new BadRequestHttpException('Backup name cannot be empty after sanitization.');
}
$backup->update(['name' => $newName]);
Activity::event('server:backup.rename')
->subject($backup)
->property([
'old_name' => $oldName,
'new_name' => $newName,
])
->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Cancel a running backup job
*
* @throws AuthorizationException
*/
public function cancel(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
if (!$backup->canCancel()) {
throw new BadRequestHttpException('This backup cannot be cancelled.');
}
$success = $this->asyncBackupService->cancel($backup);
if ($success) {
Activity::event('server:backup.cancel')
->subject($backup)
->property(['name' => $backup->name, 'job_id' => $backup->job_id])
->log();
return new JsonResponse([
'message' => 'Backup cancelled successfully',
'status' => $backup->job_status,
]);
}
throw new BadRequestHttpException('Failed to cancel backup.');
}
/**
* Retry a failed backup
*
* @throws AuthorizationException
*/
public function retry(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) {
throw new AuthorizationException();
}
if (!$backup->canRetry()) {
throw new BadRequestHttpException('This backup cannot be retried.');
}
$success = $this->asyncBackupService->retry($backup);
if ($success) {
Activity::event('server:backup.retry')
->subject($backup)
->property(['name' => $backup->name, 'old_job_id' => $backup->job_id])
->log();
// Refresh backup to get updated job_id
$backup->refresh();
return new JsonResponse([
'message' => 'Backup retry initiated successfully',
'job_id' => $backup->job_id,
'status' => $backup->job_status,
'progress' => $backup->job_progress,
]);
}
throw new BadRequestHttpException('Failed to retry backup.');
}
/**
* Get real-time status of a backup job
*
* @throws AuthorizationException
*/
public function status(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
// Poll latest status from Elytra if job is still active
if ($backup->isInProgress() && $backup->job_id) {
$this->pollingService->pollBackupStatus($backup);
$backup->refresh();
}
return new JsonResponse([
'job_id' => $backup->job_id,
'status' => $backup->job_status,
'progress' => $backup->job_progress,
'message' => $backup->job_message,
'error' => $backup->job_error,
'is_successful' => $backup->is_successful,
'can_cancel' => $backup->canCancel(),
'can_retry' => $backup->canRetry(),
'started_at' => $backup->job_started_at?->toISOString(),
'last_updated_at' => $backup->job_last_updated_at?->toISOString(),
'completed_at' => $backup->completed_at?->toISOString(),
]);
}
/**
* Returns information about a single backup.
*
* @throws AuthorizationException
*/
public function view(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Deletes a backup from the panel as well as the remote source where it is currently
* being stored.
*
* @throws \Throwable
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$this->deleteBackupService->handle($backup);
Activity::event('server:backup.delete')
->subject($backup)
->property(['name' => $backup->name, 'failed' => !$backup->is_successful])
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Download the backup for a given server instance. For daemon local files, the file
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
* which the user is redirected to.
*
* @throws \Throwable
* @throws AuthorizationException
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
throw new AuthorizationException();
}
$allowedAdapters = [
Backup::ADAPTER_AWS_S3,
Backup::ADAPTER_WINGS,
Backup::ADAPTER_RUSTIC_LOCAL,
Backup::ADAPTER_RUSTIC_S3
];
if (!in_array($backup->disk, $allowedAdapters)) {
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
}
$url = $this->downloadLinkService->handle($backup, $request->user());
Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log();
return new JsonResponse([
'object' => 'signed_url',
'attributes' => ['url' => $url],
]);
}
/**
* Handles restoring a backup by making a request to the Wings instance telling it
* to begin the process of finding (or downloading) the backup and unpacking it
* over the server files.
*
* All files that currently exist on the server will be deleted before restoring
* the backup to ensure a clean restoration process.
*
* @throws \Throwable
*/
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
{
$this->validateServerForRestore($server);
$this->validateBackupForRestore($backup);
// Validate server state compatibility if backup has state data
if ($this->serverStateService->hasServerState($backup)) {
$compatibility = $this->serverStateService->validateRestoreCompatibility($backup);
if (!empty($compatibility['errors'])) {
throw new BadRequestHttpException('Cannot restore backup: ' . implode(' ', $compatibility['errors']));
}
// Log warnings for user awareness
if (!empty($compatibility['warnings'])) {
\Log::warning('Backup restore compatibility warnings', [
'backup_uuid' => $backup->uuid,
'server_uuid' => $server->uuid,
'warnings' => $compatibility['warnings'],
]);
}
}
$hasServerState = $this->serverStateService->hasServerState($backup);
$log = Activity::event('server:backup.restore')
->subject($backup)
->property([
'name' => $backup->name,
'truncate' => true,
'has_server_state' => $hasServerState,
]);
$log->transaction(function () use ($backup, $server, $request, $hasServerState) {
// Double-check server state within transaction to prevent race conditions
$server->refresh();
if (!is_null($server->status)) {
throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.');
}
// If the backup is for an S3 file (legacy or rustic) we need to generate a unique
// Download link for it that will allow Wings to actually access the file.
$url = null;
if (in_array($backup->disk, [Backup::ADAPTER_AWS_S3, Backup::ADAPTER_RUSTIC_S3])) {
try {
$url = $this->downloadLinkService->handle($backup, $request->user());
} catch (\Exception $e) {
throw new BadRequestHttpException('Failed to generate download link for S3 backup: ' . $e->getMessage());
}
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
try {
// Start the file restoration process on Wings (always truncate for clean restore)
$this->daemonRepository->setServer($server)->restore($backup, $url);
// If backup has server state, restore it immediately
// This is safe to do now since we're in a transaction and the daemon request succeeded
if ($hasServerState) {
$this->serverStateService->restoreServerState($server, $backup);
}
} catch (\Exception $e) {
// If either daemon request or state restoration fails, reset server status
$server->update(['status' => null]);
throw $e;
}
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Validate server state for backup restoration
*/
private function validateServerForRestore(Server $server): void
{
// Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
if (!is_null($server->status)) {
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
}
if ($server->isSuspended()) {
throw new BadRequestHttpException('Cannot restore backup for suspended server.');
}
if (!$server->isInstalled()) {
throw new BadRequestHttpException('Cannot restore backup for server that is not fully installed.');
}
if ($server->transfer) {
throw new BadRequestHttpException('Cannot restore backup while server is being transferred.');
}
}
/**
* Validate backup for restoration
*/
private function validateBackupForRestore(Backup $backup): void
{
if (!$backup->is_successful && is_null($backup->completed_at)) {
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
}
// Additional safety check for backup integrity
if (!$backup->is_successful) {
throw new BadRequestHttpException('Cannot restore a failed backup.');
}
if (is_null($backup->completed_at)) {
throw new BadRequestHttpException('Cannot restore backup that is still in progress.');
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Elytra\ElytraJobService;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
class BackupsController extends ClientApiController
{
public function __construct(
private ElytraJobService $elytraJobService,
private DownloadLinkService $downloadLinkService,
private BackupTransformer $transformer,
) {
parent::__construct();
}
public function index(Request $request, Server $server): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
$limit = min($request->query('per_page') ?? 20, 50);
$backups = $server->backups()
->orderByRaw('is_locked DESC, created_at DESC')
->paginate($limit);
return $this->fractal->collection($backups)
->transformWith($this->transformer)
->addMeta([
'backup_count' => $server->backups()->count(),
'storage' => [
'used_mb' => round($server->backups()->where('is_successful', true)->sum('bytes') / 1024 / 1024, 2),
'limit_mb' => null,
'has_limit' => false,
'usage_percentage' => null,
'available_mb' => null,
'is_over_limit' => false,
],
'limits' => [
'count_limit' => null,
'has_count_limit' => false,
'storage_limit_mb' => null,
'has_storage_limit' => false,
],
])
->toArray();
}
public function store(StoreBackupRequest $request, Server $server): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) {
throw new AuthorizationException();
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_create',
[
'operation' => 'create',
'adapter' => $request->input('adapter', config('backups.default')),
'ignored' => $request->input('ignored', ''),
'name' => $request->input('name'),
],
$request->user()
);
Activity::event('backup:create')
->subject($server)
->property(['backup_name' => $request->input('name'), 'job_id' => $result['job_id']])
->log();
return new JsonResponse($result);
}
public function show(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
return $this->fractal->item($backup)
->transformWith($this->transformer)
->toArray();
}
public function destroy(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_delete',
[
'operation' => 'delete',
'backup_uuid' => $backup->uuid,
],
$request->user()
);
Activity::event('backup:delete')
->subject($server)
->property(['backup_name' => $backup->name, 'job_id' => $result['job_id']])
->log();
return new JsonResponse($result);
}
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) {
throw new AuthorizationException();
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_restore',
[
'operation' => 'restore',
'backup_uuid' => $backup->uuid,
'truncate_directory' => $request->boolean('truncate_directory'),
],
$request->user()
);
Activity::event('backup:restore')
->subject($server)
->property(['backup_name' => $backup->name, 'job_id' => $result['job_id']])
->log();
return new JsonResponse($result);
}
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
throw new AuthorizationException();
}
if (!$backup->is_successful) {
throw new \Exception('Cannot download an incomplete backup.');
}
$url = $this->downloadLinkService->handle($backup, $request->user());
Activity::event('backup:download')
->subject($server)
->property(['backup_name' => $backup->name])
->log();
return new JsonResponse([
'object' => 'signed_url',
'attributes' => ['url' => $url],
]);
}
public function rename(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$request->validate([
'name' => 'required|string|max:191',
]);
$backup->update([
'name' => $request->input('name'),
]);
Activity::event('backup:rename')
->subject($server)
->property(['old_name' => $backup->getOriginal('name'), 'new_name' => $backup->name])
->log();
$transformed = $this->fractal->item($backup)
->transformWith($this->transformer)
->toArray();
return new JsonResponse($transformed);
}
public function toggleLock(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$backup->update([
'is_locked' => !$backup->is_locked,
]);
Activity::event('backup:lock')
->subject($server)
->property(['backup_name' => $backup->name, 'locked' => $backup->is_locked])
->log();
$transformed = $this->fractal->item($backup)
->transformWith($this->transformer)
->toArray();
return new JsonResponse($transformed);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Elytra\ElytraJobService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
class ElytraJobsController extends ClientApiController
{
public function __construct(
private ElytraJobService $elytraJobService,
) {
parent::__construct();
}
public function index(Request $request, Server $server): JsonResponse
{
$jobType = $request->query('type');
if ($jobType) {
$handler = $this->elytraJobService->getJobHandler($jobType);
$requiredPermissions = $handler->getRequiredPermissions('index');
foreach ($requiredPermissions as $permission) {
if (!$request->user()->can($permission, $server)) {
throw new AuthorizationException();
}
}
}
$jobs = $this->elytraJobService->listJobs(
$server,
$request->query('type'),
$request->query('status')
);
return new JsonResponse([
'object' => 'list',
'data' => $jobs,
]);
}
public function create(Request $request, Server $server): JsonResponse
{
$jobType = $request->input('job_type');
$jobData = $request->input('job_data', []);
$handler = $this->elytraJobService->getJobHandler($jobType);
$requiredPermissions = $handler->getRequiredPermissions('create');
foreach ($requiredPermissions as $permission) {
if (!$request->user()->can($permission, $server)) {
throw new AuthorizationException();
}
}
$result = $this->elytraJobService->submitJob(
$server,
$jobType,
$jobData,
$request->user()
);
Activity::event('job:create')
->subject($server)
->property(['job_type' => $jobType, 'job_id' => $result['job_id']])
->log();
return new JsonResponse($result);
}
public function show(Request $request, Server $server, string $jobId): JsonResponse
{
$job = $this->elytraJobService->getJobStatus($server, $jobId);
if (!$job) {
return response()->json(['error' => 'Job not found'], 404);
}
$handler = $this->elytraJobService->getJobHandler($job['type']);
$requiredPermissions = $handler->getRequiredPermissions('show');
foreach ($requiredPermissions as $permission) {
if (!$request->user()->can($permission, $server)) {
throw new AuthorizationException();
}
}
return new JsonResponse([
'object' => 'job',
'attributes' => $job,
]);
}
public function cancel(Request $request, Server $server, string $jobId): JsonResponse
{
$job = $this->elytraJobService->getJobStatus($server, $jobId);
if (!$job) {
return response()->json(['error' => 'Job not found'], 404);
}
$handler = $this->elytraJobService->getJobHandler($job['type']);
$requiredPermissions = $handler->getRequiredPermissions('cancel');
foreach ($requiredPermissions as $permission) {
if (!$request->user()->can($permission, $server)) {
throw new AuthorizationException();
}
}
$result = $this->elytraJobService->cancelJob($server, $jobId);
Activity::event('job:cancel')
->subject($server)
->property(['job_id' => $jobId])
->log();
return new JsonResponse($result);
}
}

View File

@@ -7,19 +7,12 @@ use Pterodactyl\Models\Backup;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class BackupDeleteController extends Controller
{
/**
* BackupDeleteController constructor.
*/
public function __construct(private DeleteBackupService $deleteBackupService)
{
}
/**
* Handles the deletion of a backup from the remote daemon.
@@ -52,7 +45,8 @@ class BackupDeleteController extends Controller
->property('name', $model->name);
$log->transaction(function () use ($model) {
$this->deleteBackupService->handle($model);
// Simply mark the backup as deleted
$model->delete();
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);

View File

@@ -0,0 +1,33 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Elytra\ElytraJobService;
use Pterodactyl\Http\Requests\Api\Remote\ElytraJobCompleteRequest;
class ElytraJobCompletionController extends Controller
{
public function __construct(
private ElytraJobService $elytraJobService,
) {}
public function update(ElytraJobCompleteRequest $request, string $jobId): JsonResponse
{
try {
$this->elytraJobService->updateJobStatus($jobId, $request->validated());
return response()->json([
'success' => true,
'message' => 'Job status updated successfully',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Remote;
use Illuminate\Foundation\Http\FormRequest;
class ElytraJobCompleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'successful' => 'required|boolean',
'job_type' => 'required|string',
'status' => 'nullable|string|in:pending,running,completed,failed',
'message' => 'nullable|string',
'error_message' => 'nullable|string',
'progress' => 'nullable|integer|min:0|max:100',
'updated_at' => 'nullable|integer',
// Generic result data (job-type specific fields can be included here)
'checksum' => 'nullable|string',
'checksum_type' => 'nullable|string',
'size' => 'nullable|integer|min:0',
'snapshot_id' => 'nullable|string',
'adapter' => 'nullable|string',
'result_data' => 'nullable|array',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Remote;
use Illuminate\Foundation\Http\FormRequest;
class ReportJobCompleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'successful' => 'required|boolean',
'job_type' => 'required|string|in:backup_create,backup_delete,backup_restore',
// Backup-specific fields (nullable for future job types)
'checksum' => 'nullable|string',
'checksum_type' => 'nullable|string|in:sha1,md5',
'size' => 'nullable|integer|min:0',
'snapshot_id' => 'nullable|string',
// Generic fields
'error_message' => 'nullable|string',
'result_data' => 'nullable|array',
];
}
}

View File

@@ -8,16 +8,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* Backup model
*
* @property int $id
* @property int $server_id
* @property string $uuid
* @property string|null $job_id
* @property string $job_status
* @property int $job_progress
* @property string|null $job_message
* @property string|null $job_error
* @property \Carbon\CarbonImmutable|null $job_started_at
* @property \Carbon\CarbonImmutable|null $job_last_updated_at
* @property bool $is_successful
* @property bool $is_locked
* @property string $name
@@ -33,7 +28,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
* @property \Carbon\CarbonImmutable $updated_at
* @property \Carbon\CarbonImmutable|null $deleted_at
* @property Server $server
* @property \Pterodactyl\Models\BackupJobQueue[] $jobQueue
* @property \Pterodactyl\Models\ElytraJob[] $elytraJobs
* @property \Pterodactyl\Models\AuditLog[] $audits
*/
class Backup extends Model
@@ -44,27 +39,19 @@ class Backup extends Model
public const RESOURCE_NAME = 'backup';
// Backup adapters
public const ADAPTER_WINGS = 'wings';
public const ADAPTER_ELYTRA = 'elytra'; // Preferred name for local backups
public const ADAPTER_AWS_S3 = 's3';
public const ADAPTER_RUSTIC_LOCAL = 'rustic_local';
public const ADAPTER_RUSTIC_S3 = 'rustic_s3';
// Async job statuses matching Elytra's system
public const JOB_STATUS_PENDING = 'pending';
public const JOB_STATUS_RUNNING = 'running';
public const JOB_STATUS_COMPLETED = 'completed';
public const JOB_STATUS_FAILED = 'failed';
public const JOB_STATUS_CANCELLED = 'cancelled';
protected $table = 'backups';
protected bool $immutableDates = true;
protected $casts = [
'id' => 'int',
'job_progress' => 'int',
'job_started_at' => 'datetime',
'job_last_updated_at' => 'datetime',
'is_successful' => 'bool',
'is_locked' => 'bool',
'ignored_files' => 'array',
@@ -74,8 +61,6 @@ class Backup extends Model
];
protected $attributes = [
'job_status' => self::JOB_STATUS_PENDING,
'job_progress' => 0,
'is_successful' => false,
'is_locked' => false,
'checksum' => null,
@@ -99,7 +84,7 @@ class Backup extends Model
*/
public function isLocal(): bool
{
return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_RUSTIC_LOCAL]);
return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_ELYTRA, self::ADAPTER_RUSTIC_LOCAL]);
}
/**
@@ -122,20 +107,23 @@ class Backup extends Model
return !empty($this->snapshot_id);
}
/**
* Get the size in gigabytes for display
*/
public function getSizeGbAttribute(): float
{
return round($this->bytes / 1024 / 1024 / 1024, 3);
}
public static array $validationRules = [
'server_id' => 'bail|required|numeric|exists:servers,id',
'uuid' => 'required|uuid',
'job_id' => 'nullable|string|max:255',
'job_status' => 'required|string|in:pending,running,completed,failed,cancelled',
'job_progress' => 'integer|min:0|max:100',
'job_message' => 'nullable|string',
'job_error' => 'nullable|string',
'is_successful' => 'boolean',
'is_locked' => 'boolean',
'name' => 'required|string',
'ignored_files' => 'array',
'server_state' => 'nullable|array',
'disk' => 'required|string|in:wings,s3,rustic_local,rustic_s3',
'disk' => 'required|string|in:wings,elytra,s3,rustic_local,rustic_s3',
'checksum' => 'nullable|string',
'snapshot_id' => 'nullable|string|max:64',
'bytes' => 'numeric',
@@ -148,106 +136,20 @@ class Backup extends Model
}
/**
* Relationship to job queue entries for this backup
* Get all Elytra jobs related to this backup
*/
public function jobQueue(): HasMany
public function elytraJobs(): HasMany
{
return $this->hasMany(BackupJobQueue::class);
return $this->hasMany(ElytraJob::class, 'server_id', 'server_id')
->where('job_data->backup_uuid', $this->uuid);
}
/**
* Check if this backup is currently in progress
* Get the latest Elytra job for this backup
*/
public function isInProgress(): bool
public function latestElytraJob()
{
return in_array($this->job_status, [
self::JOB_STATUS_PENDING,
self::JOB_STATUS_RUNNING
]);
}
/**
* Check if this backup has completed successfully
*/
public function isCompleted(): bool
{
return $this->job_status === self::JOB_STATUS_COMPLETED && $this->is_successful;
}
/**
* Check if this backup has failed
*/
public function hasFailed(): bool
{
return $this->job_status === self::JOB_STATUS_FAILED ||
($this->job_status === self::JOB_STATUS_COMPLETED && !$this->is_successful);
}
/**
* Check if this backup has been cancelled
*/
public function isCancelled(): bool
{
return $this->job_status === self::JOB_STATUS_CANCELLED;
}
/**
* Check if this backup can be cancelled
*/
public function canCancel(): bool
{
return $this->isInProgress() && !empty($this->job_id);
}
/**
* Check if this backup can be retried
*/
public function canRetry(): bool
{
return $this->hasFailed() && !empty($this->job_id);
}
/**
* Update the job status and related fields
*/
public function updateJobStatus(string $status, int $progress = null, string $message = null, string $error = null): void
{
$updateData = [
'job_status' => $status,
'job_last_updated_at' => now(),
];
if ($progress !== null) {
$updateData['job_progress'] = max(0, min(100, $progress));
}
if ($message !== null) {
$updateData['job_message'] = $message;
}
if ($error !== null) {
$updateData['job_error'] = $error;
}
// Mark as started when first moving to running
if ($status === self::JOB_STATUS_RUNNING && $this->job_status === self::JOB_STATUS_PENDING) {
$updateData['job_started_at'] = now();
}
// Update completion fields when job finishes
if (in_array($status, [self::JOB_STATUS_COMPLETED, self::JOB_STATUS_FAILED, self::JOB_STATUS_CANCELLED])) {
if ($status === self::JOB_STATUS_COMPLETED) {
$updateData['is_successful'] = true;
$updateData['completed_at'] = now();
$updateData['job_progress'] = 100;
} elseif ($status === self::JOB_STATUS_FAILED) {
$updateData['is_successful'] = false;
$updateData['completed_at'] = now();
// Don't change lock status for failed backups
}
}
$this->update($updateData);
return $this->elytraJobs()->latest('created_at')->first();
}
/**
@@ -256,7 +158,8 @@ class Backup extends Model
public function getElytraAdapterType(): string
{
return match($this->disk) {
self::ADAPTER_WINGS => 'elytra', // Elytra uses 'elytra' for local backups
self::ADAPTER_WINGS => 'elytra', // Legacy support: wings -> elytra
self::ADAPTER_ELYTRA => 'elytra', // Direct mapping for new backups
self::ADAPTER_AWS_S3 => 's3',
self::ADAPTER_RUSTIC_LOCAL => 'rustic_local',
self::ADAPTER_RUSTIC_S3 => 'rustic_s3',
@@ -265,20 +168,11 @@ class Backup extends Model
}
/**
* Scope to get backups that are currently in progress
* Scope to get successful backups
*/
public function scopeInProgress($query)
public function scopeSuccessful($query)
{
return $query->whereIn('job_status', [self::JOB_STATUS_PENDING, self::JOB_STATUS_RUNNING]);
}
/**
* Scope to get completed backups
*/
public function scopeCompleted($query)
{
return $query->where('job_status', self::JOB_STATUS_COMPLETED)
->where('is_successful', true);
return $query->where('is_successful', true);
}
/**
@@ -286,12 +180,31 @@ class Backup extends Model
*/
public function scopeFailed($query)
{
return $query->where(function($q) {
$q->where('job_status', self::JOB_STATUS_FAILED)
->orWhere(function($subQ) {
$subQ->where('job_status', self::JOB_STATUS_COMPLETED)
->where('is_successful', false);
});
});
return $query->where('is_successful', false);
}
}
/**
* Scope to get locked backups
*/
public function scopeLocked($query)
{
return $query->where('is_locked', true);
}
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Resolve the route binding by UUID instead of ID.
*/
public function resolveRouteBinding($value, $field = null): ?\Illuminate\Database\Eloquent\Model
{
return $this->query()->where($field ?? $this->getRouteKeyName(), $value)->firstOrFail();
}
}

View File

@@ -1,172 +0,0 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Carbon\CarbonImmutable;
/**
* @property int $id
* @property string $job_id
* @property int $backup_id
* @property string $operation_type
* @property string $status
* @property array|null $job_data
* @property string|null $error_message
* @property int $retry_count
* @property \Carbon\CarbonImmutable|null $last_polled_at
* @property \Carbon\CarbonImmutable|null $expires_at
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
* @property Backup $backup
*/
class BackupJobQueue extends Model
{
/** @use HasFactory<\Database\Factories\BackupJobQueueFactory> */
use HasFactory;
public const RESOURCE_NAME = 'backup_job_queue';
// Operation types
public const OPERATION_CREATE = 'create';
public const OPERATION_DELETE = 'delete';
public const OPERATION_RESTORE = 'restore';
// Job statuses
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_RETRY = 'retry';
protected $table = 'backup_job_queue';
protected bool $immutableDates = true;
protected $casts = [
'id' => 'int',
'backup_id' => 'int',
'job_data' => 'array',
'retry_count' => 'int',
'last_polled_at' => 'datetime',
'expires_at' => 'datetime',
];
protected $attributes = [
'status' => self::STATUS_QUEUED,
'retry_count' => 0,
];
protected $guarded = ['id', 'created_at', 'updated_at'];
public static array $validationRules = [
'job_id' => 'required|string|max:255',
'backup_id' => 'required|int|exists:backups,id',
'operation_type' => 'required|string|in:create,delete,restore',
'status' => 'required|string|in:queued,processing,completed,failed,cancelled,retry',
'job_data' => 'nullable|array',
'error_message' => 'nullable|string',
'retry_count' => 'int|min:0|max:10',
];
/**
* Relationship to the associated backup
*/
public function backup(): BelongsTo
{
return $this->belongsTo(Backup::class);
}
/**
* Check if this job can be retried
*/
public function canRetry(): bool
{
return $this->status === self::STATUS_FAILED &&
$this->retry_count < config('backups.max_retry_attempts', 3);
}
/**
* Check if this job has expired and should be cleaned up
*/
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
/**
* Mark this job for retry
*/
public function markForRetry(string $errorMessage = null): void
{
$this->update([
'status' => self::STATUS_RETRY,
'retry_count' => $this->retry_count + 1,
'error_message' => $errorMessage,
'last_polled_at' => CarbonImmutable::now(),
]);
}
/**
* Mark this job as completed
*/
public function markCompleted(): void
{
$this->update([
'status' => self::STATUS_COMPLETED,
'last_polled_at' => CarbonImmutable::now(),
]);
}
/**
* Mark this job as failed
*/
public function markFailed(string $errorMessage): void
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $errorMessage,
'last_polled_at' => CarbonImmutable::now(),
]);
}
/**
* Update last polled timestamp
*/
public function updateLastPolled(): void
{
$this->update(['last_polled_at' => CarbonImmutable::now()]);
}
/**
* Get jobs that need status polling
*/
public static function needsPolling(): \Illuminate\Database\Eloquent\Builder
{
$staleThreshold = CarbonImmutable::now()->subMinutes(2);
return static::query()
->whereIn('status', [self::STATUS_QUEUED, self::STATUS_PROCESSING, self::STATUS_RETRY])
->where(function ($query) use ($staleThreshold) {
$query->whereNull('last_polled_at')
->orWhere('last_polled_at', '<=', $staleThreshold);
})
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', CarbonImmutable::now());
});
}
/**
* Get expired jobs that should be cleaned up
*/
public static function expired(): \Illuminate\Database\Eloquent\Builder
{
return static::query()
->whereNotNull('expires_at')
->where('expires_at', '<=', CarbonImmutable::now())
->whereNotIn('status', [self::STATUS_COMPLETED]);
}
}

107
app/Models/ElytraJob.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ElytraJob extends Model
{
use HasUuids;
public const STATUS_PENDING = 'pending';
public const STATUS_SUBMITTED = 'submitted';
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'server_id',
'user_id',
'job_type',
'job_data',
'status',
'progress',
'status_message',
'error_message',
'elytra_job_id',
'submitted_at',
'completed_at',
];
protected $casts = [
'job_data' => 'array',
'progress' => 'integer',
'created_at' => 'immutable_datetime',
'submitted_at' => 'immutable_datetime',
'completed_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
];
public function uniqueIds(): array
{
return ['uuid'];
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function canBeCancelled(): bool
{
return in_array($this->status, [
self::STATUS_PENDING,
self::STATUS_SUBMITTED,
self::STATUS_RUNNING,
]);
}
public function isCompleted(): bool
{
return in_array($this->status, [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
]);
}
public function isInProgress(): bool
{
return in_array($this->status, [
self::STATUS_SUBMITTED,
self::STATUS_RUNNING,
]);
}
public function getStatusDisplayAttribute(): string
{
return match ($this->status) {
self::STATUS_PENDING => 'Pending',
self::STATUS_SUBMITTED => 'Submitted',
self::STATUS_RUNNING => 'Running',
self::STATUS_COMPLETED => 'Completed',
self::STATUS_FAILED => 'Failed',
self::STATUS_CANCELLED => 'Cancelled',
default => ucfirst($this->status),
};
}
public function getOperationAttribute(): string
{
return match ($this->job_type) {
'backup_create' => 'create',
'backup_delete' => 'delete',
'backup_restore' => 'restore',
'backup_download' => 'download',
default => 'unknown',
};
}
}

View File

@@ -1,57 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Eloquent;
use Carbon\Carbon;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupRepository extends EloquentRepository
{
public function model(): string
{
return Backup::class;
}
/**
* Determines if too many backups have been generated by the server.
*/
public function getBackupsGeneratedDuringTimespan(int $server, int $seconds = 600): array|Collection
{
return $this->getBuilder()
->withTrashed()
->where('server_id', $server)
->where(function ($query) {
$query->whereNull('completed_at')
->orWhere('is_successful', '=', true);
})
->where('created_at', '>=', Carbon::now()->subSeconds($seconds)->toDateTimeString())
->get()
->toBase();
}
/**
* Returns a query filtering only non-failed backups for a specific server.
*/
public function getNonFailedBackups(Server $server): HasMany
{
return $server->backups()->where(function ($query) {
$query->whereNull('completed_at')
->orWhere('is_successful', true);
});
}
/**
* Returns backups that are currently in progress for a specific server.
*/
public function getBackupsInProgress(int $serverId): Collection
{
return $this->getBuilder()
->where('server_id', $serverId)
->whereNull('completed_at')
->get()
->toBase();
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace Pterodactyl\Repositories\Elytra;
use GuzzleHttp\Client;
use Pterodactyl\Models\Node;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Contracts\Foundation\Application;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* Repository for communicating with the Elytra daemon
* Replaces the Wings DaemonBackupRepository functionality
*/
class ElytraRepository
{
protected ?Server $server;
protected ?Node $node;
public function __construct(protected Application $app)
{
}
/**
* Set the server model this request is stemming from.
*/
public function setServer(Server $server): self
{
$this->server = $server;
$this->setNode($this->server->node);
return $this;
}
/**
* Set the node model this request is stemming from.
*/
public function setNode(Node $node): self
{
$this->node = $node;
return $this;
}
/**
* Return an instance of the Guzzle HTTP Client to be used for requests.
*/
public function getHttpClient(array $headers = []): Client
{
Assert::isInstanceOf($this->node, Node::class);
return new Client([
'verify' => $this->app->environment('production'),
'base_uri' => $this->node->getConnectionAddress(),
'timeout' => config('pterodactyl.guzzle.timeout'),
'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'),
'headers' => array_merge($headers, [
'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]),
]);
}
/**
* Create a new job on Elytra
*
* @throws DaemonConnectionException
*/
public function createJob(string $jobType, array $jobData): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient(['timeout' => 30])->post(
sprintf('/api/servers/%s/jobs', $this->server->uuid),
[
'json' => [
'job_type' => $jobType,
'job_data' => $jobData,
],
]
);
return json_decode($response->getBody()->getContents(), true);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Gets the current status of a job from Elytra
*
* @throws DaemonConnectionException
*/
public function getJobStatus(string $jobId): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient(['timeout' => 5])->get(
sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId)
);
return json_decode($response->getBody()->getContents(), true);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Cancels a running job on Elytra
*
* @throws DaemonConnectionException
*/
public function cancelJob(string $jobId): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient(['timeout' => 5])->delete(
sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId)
);
return json_decode($response->getBody()->getContents(), true);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Update job status on Elytra
*
* @throws DaemonConnectionException
*/
public function updateJob(string $jobId, string $status, int $progress = 0, string $message = '', array $result = null): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$data = [
'status' => $status,
'progress' => $progress,
'message' => $message,
];
if ($result !== null) {
$data['result'] = $result;
}
$response = $this->getHttpClient(['timeout' => 5])->put(
sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId),
['json' => $data]
);
return json_decode($response->getBody()->getContents(), true);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -1,168 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonBackupRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonBackupRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonBackupRepository extends DaemonRepository
{
protected ?string $adapter;
/**
* Sets the backup adapter for this execution instance.
*/
public function setBackupAdapter(string $adapter): self
{
$this->adapter = $adapter;
return $this;
}
/**
* Tells the remote Daemon to begin generating a backup for the server (async).
* Returns the response which should contain a job_id for tracking.
*
* @throws DaemonConnectionException
*/
public function backup(Backup $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
$adapterToSend = $this->adapter ?? config('backups.default');
return $this->getHttpClient(['timeout' => 10])->post(
sprintf('/api/servers/%s/backup', $this->server->uuid),
[
'json' => [
'adapter' => $adapterToSend,
'uuid' => $backup->uuid,
'ignore' => implode("\n", $backup->ignored_files),
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Sends a request to Wings to begin restoring a backup for a server.
* Always truncates the directory for a clean restore.
*
* @throws DaemonConnectionException
*/
public function restore(Backup $backup, ?string $url = null): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient(['timeout' => 5])->post(
sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid),
[
'json' => [
'adapter' => $backup->disk,
'truncate_directory' => true,
'download_url' => $url ?? '',
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Deletes a backup from the daemon (async).
* Returns the response which should contain a job_id for tracking.
*
* @throws DaemonConnectionException
*/
public function delete(Backup $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient(['timeout' => 10])->delete(
sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid),
[
'json' => [
'adapter_type' => $backup->getElytraAdapterType(),
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Gets the current status of a job from Elytra
*
* @throws DaemonConnectionException
*/
public function getJobStatus(string $jobId): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient(['timeout' => 5])->get(
sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId)
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Cancels a running job on Elytra
*
* @throws DaemonConnectionException
*/
public function cancelJob(string $jobId): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient(['timeout' => 5])->delete(
sprintf('/api/servers/%s/jobs/%s', $this->server->uuid, $jobId)
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Lists all jobs for a server on Elytra
*
* @throws DaemonConnectionException
*/
public function listJobs(?string $status = null, ?string $type = null): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
$query = [];
if ($status) $query['status'] = $status;
if ($type) $query['type'] = $type;
$url = sprintf('/api/servers/%s/jobs', $this->server->uuid);
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
return $this->getHttpClient(['timeout' => 5])->get($url);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -1,343 +0,0 @@
<?php
namespace Pterodactyl\Services\Backups;
use Ramsey\Uuid\Uuid;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\BackupJobQueue;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Services\Backups\BackupStorageService;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Exception\RequestException;
class AsyncBackupService
{
private ?array $ignoredFiles = null;
private bool $isLocked = false;
public function __construct(
private BackupRepository $repository,
private ConnectionInterface $connection,
private DaemonBackupRepository $daemonBackupRepository,
private DeleteBackupService $deleteBackupService,
private BackupManager $backupManager,
private ServerStateService $serverStateService,
private BackupStorageService $backupStorageService,
) {
}
/**
* Set if the backup should be locked once it is created
*/
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
/**
* Set the files to be ignored by this backup
*/
public function setIgnoredFiles(?array $ignored): self
{
if (is_array($ignored)) {
$this->ignoredFiles = array_filter($ignored, fn($value) => strlen($value) > 0);
} else {
$this->ignoredFiles = [];
}
return $this;
}
/**
* Initiate an async backup operation
*
* @throws \Throwable
* @throws TooManyBackupsException
* @throws TooManyRequestsHttpException
*/
public function initiate(Server $server, ?string $name = null, bool $override = false): Backup
{
// Validate server state before creating backup
$this->validateServerForBackup($server);
// Check for existing backups in progress (only allow one at a time)
$inProgressBackups = $this->repository->getBackupsInProgress($server->id);
if ($inProgressBackups->count() > 0) {
throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.');
}
$successful = $this->repository->getNonFailedBackups($server);
if (!$server->allowsBackups()) {
throw new TooManyBackupsException(0, 'Backups are disabled for this server');
}
// Block backup creation if already over storage limit
if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) {
$usage = $this->backupStorageService->getStorageUsageInfo($server);
throw new TooManyBackupsException(0, sprintf(
'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.',
$usage['used_mb'],
$usage['limit_mb']
));
}
elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) {
if (!$override) {
throw new TooManyBackupsException($server->backup_limit);
}
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
if (!$oldest) {
throw new TooManyBackupsException($server->backup_limit);
}
$this->deleteBackupService->handle($oldest);
}
return $this->connection->transaction(function () use ($server, $name) {
$backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString());
$backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName);
$backupName = substr($backupName, 0, 191); // Limit to database field length
$serverState = $this->serverStateService->captureServerState($server);
// Use the configured default adapter
$adapter = $this->backupManager->getDefaultAdapter();
/** @var Backup $backup */
$backup = $this->repository->create([
'server_id' => $server->id,
'uuid' => Uuid::uuid4()->toString(),
'name' => $backupName,
'ignored_files' => array_values($this->ignoredFiles ?? []),
'disk' => $adapter,
'is_locked' => $this->isLocked,
'server_state' => $serverState,
'job_status' => Backup::JOB_STATUS_PENDING,
'job_progress' => 0,
'job_message' => 'Backup job queued',
], true, true);
try {
// Send async backup request to Elytra
$jobId = $this->requestAsyncBackup($server, $backup);
// Update backup with job ID
$backup->update([
'job_id' => $jobId,
'job_message' => 'Backup job submitted to Elytra',
]);
// Create job queue entry for tracking
BackupJobQueue::create([
'job_id' => $jobId,
'backup_id' => $backup->id,
'operation_type' => BackupJobQueue::OPERATION_CREATE,
'status' => BackupJobQueue::STATUS_QUEUED,
'job_data' => [
'adapter' => $backup->getElytraAdapterType(),
'uuid' => $backup->uuid,
'ignore' => implode("\n", $backup->ignored_files),
],
'expires_at' => CarbonImmutable::now()->addHours(6), // Backup jobs expire after 6 hours
]);
Log::info('Async backup initiated', [
'backup_uuid' => $backup->uuid,
'server_uuid' => $server->uuid,
'job_id' => $jobId,
'adapter' => $backup->disk,
]);
} catch (\Exception $e) {
// If daemon backup request fails, clean up the backup record
$backup->delete();
Log::error('Failed to initiate async backup', [
'backup_uuid' => $backup->uuid,
'server_uuid' => $server->uuid,
'error' => $e->getMessage(),
]);
throw $e;
}
return $backup;
});
}
/**
* Send async backup request to Elytra and return job ID
*/
private function requestAsyncBackup(Server $server, Backup $backup): string
{
try {
$response = $this->daemonBackupRepository->setServer($server)
->setBackupAdapter($backup->getElytraAdapterType())
->backup($backup);
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['job_id'])) {
throw new \Exception('Elytra response missing job_id field');
}
return $data['job_id'];
} catch (RequestException $e) {
$response = $e->getResponse();
$statusCode = $response ? $response->getStatusCode() : 0;
$responseBody = $response ? $response->getBody()->getContents() : '';
Log::error('Elytra backup request failed', [
'server_uuid' => $server->uuid,
'backup_uuid' => $backup->uuid,
'status_code' => $statusCode,
'response' => $responseBody,
'error' => $e->getMessage(),
]);
throw new \Exception("Failed to initiate backup on Elytra: HTTP {$statusCode} - " . $e->getMessage());
}
}
/**
* Cancel an async backup operation
*/
public function cancel(Backup $backup): bool
{
if (!$backup->canCancel()) {
return false;
}
try {
// Send cancel request to Elytra
$this->daemonBackupRepository->setServer($backup->server);
$response = $this->daemonBackupRepository->cancelJob($backup->job_id);
// Update backup status
$backup->updateJobStatus(
Backup::JOB_STATUS_CANCELLED,
$backup->job_progress,
'Backup cancelled by user'
);
// Update job queue entry
$jobQueueEntry = BackupJobQueue::where('job_id', $backup->job_id)->first();
if ($jobQueueEntry) {
$jobQueueEntry->update(['status' => BackupJobQueue::STATUS_CANCELLED]);
}
Log::info('Backup cancelled', [
'backup_uuid' => $backup->uuid,
'job_id' => $backup->job_id,
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to cancel backup', [
'backup_uuid' => $backup->uuid,
'job_id' => $backup->job_id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Retry a failed backup
*/
public function retry(Backup $backup): bool
{
if (!$backup->canRetry()) {
return false;
}
try {
// Reset backup status
$backup->updateJobStatus(
Backup::JOB_STATUS_PENDING,
0,
'Backup retry requested'
);
// Find and update job queue entry
$jobQueueEntry = BackupJobQueue::where('job_id', $backup->job_id)->first();
if ($jobQueueEntry && $jobQueueEntry->canRetry()) {
$jobQueueEntry->markForRetry('Backup retry requested by user');
}
// Send new backup request to Elytra
$jobId = $this->requestAsyncBackup($backup->server, $backup);
// Update backup with new job ID
$backup->update([
'job_id' => $jobId,
'job_message' => 'Backup retry submitted to Elytra',
]);
// Create new job queue entry
BackupJobQueue::create([
'job_id' => $jobId,
'backup_id' => $backup->id,
'operation_type' => BackupJobQueue::OPERATION_CREATE,
'status' => BackupJobQueue::STATUS_QUEUED,
'job_data' => [
'adapter' => $backup->getElytraAdapterType(),
'uuid' => $backup->uuid,
'ignore' => implode("\n", $backup->ignored_files),
],
'retry_count' => $jobQueueEntry ? $jobQueueEntry->retry_count + 1 : 1,
'expires_at' => CarbonImmutable::now()->addHours(6),
]);
Log::info('Backup retry initiated', [
'backup_uuid' => $backup->uuid,
'old_job_id' => $jobQueueEntry?->job_id,
'new_job_id' => $jobId,
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to retry backup', [
'backup_uuid' => $backup->uuid,
'job_id' => $backup->job_id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Validate that the server is in a valid state for backup creation
*/
private function validateServerForBackup(Server $server): void
{
if ($server->isSuspended()) {
throw new TooManyBackupsException(0, 'Cannot create backup for suspended server.');
}
if (!$server->isInstalled()) {
throw new TooManyBackupsException(0, 'Cannot create backup for server that is not fully installed.');
}
if ($server->status === Server::STATUS_RESTORING_BACKUP) {
throw new TooManyBackupsException(0, 'Cannot create backup while server is restoring from another backup.');
}
if ($server->transfer) {
throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.');
}
}
}

View File

@@ -1,308 +0,0 @@
<?php
namespace Pterodactyl\Services\Backups;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\BackupJobQueue;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Illuminate\Support\Collection;
use GuzzleHttp\Exception\RequestException;
class BackupJobPollingService
{
public function __construct(
private DaemonBackupRepository $daemonRepository,
) {
}
/**
* Poll job statuses for all pending/running backup jobs
* This should be called regularly by a scheduled task
*/
public function pollAllJobs(): array
{
$results = ['updated' => 0, 'errors' => 0, 'completed' => 0];
// Get all jobs that need polling
$jobsToCheck = BackupJobQueue::needsPolling()
->with(['backup.server'])
->get();
if ($jobsToCheck->isEmpty()) {
return $results;
}
Log::info('Polling backup job statuses', ['job_count' => $jobsToCheck->count()]);
// Group jobs by server for efficient API calls
$jobsByServer = $jobsToCheck->groupBy('backup.server.uuid');
foreach ($jobsByServer as $serverUuid => $serverJobs) {
try {
$this->pollJobsForServer($serverJobs, $results);
} catch (\Exception $e) {
Log::error('Failed to poll jobs for server', [
'server_uuid' => $serverUuid,
'error' => $e->getMessage(),
'job_count' => $serverJobs->count(),
]);
$results['errors'] += $serverJobs->count();
}
}
// Clean up expired jobs
$this->cleanupExpiredJobs();
return $results;
}
/**
* Poll jobs for a specific server
*/
private function pollJobsForServer(Collection $serverJobs, array &$results): void
{
if ($serverJobs->isEmpty()) {
return;
}
$server = $serverJobs->first()->backup->server;
$this->daemonRepository->setServer($server);
foreach ($serverJobs as $jobQueue) {
try {
$this->pollSingleJob($jobQueue, $results);
} catch (\Exception $e) {
Log::error('Failed to poll single job', [
'job_id' => $jobQueue->job_id,
'backup_uuid' => $jobQueue->backup->uuid,
'error' => $e->getMessage(),
]);
$results['errors']++;
// Mark as failed if too many polling failures
if ($jobQueue->retry_count >= 5) {
$jobQueue->backup->updateJobStatus(
Backup::JOB_STATUS_FAILED,
null,
null,
'Job polling failed repeatedly: ' . $e->getMessage()
);
$jobQueue->markFailed('Job polling failed repeatedly');
}
}
}
}
/**
* Poll a single job and update its status
*/
private function pollSingleJob(BackupJobQueue $jobQueue, array &$results): void
{
try {
$response = $this->daemonRepository->getJobStatus($jobQueue->job_id);
$data = json_decode($response->getBody()->getContents(), true);
if (!$this->isValidJobResponse($data)) {
throw new \Exception('Invalid job status response from Elytra');
}
$this->updateBackupFromJobStatus($jobQueue->backup, $data);
$this->updateJobQueueFromStatus($jobQueue, $data);
$jobQueue->updateLastPolled();
$results['updated']++;
// Check if job completed
if (in_array($data['status'], ['completed', 'failed', 'cancelled'])) {
$results['completed']++;
Log::info('Backup job completed', [
'job_id' => $jobQueue->job_id,
'backup_uuid' => $jobQueue->backup->uuid,
'status' => $data['status'],
'progress' => $data['progress'] ?? 0,
]);
}
} catch (RequestException $e) {
if ($e->getResponse() && $e->getResponse()->getStatusCode() === 404) {
// Job not found on Elytra - it may have been cleaned up
$this->handleJobNotFound($jobQueue);
$results['completed']++;
} else {
throw $e;
}
}
}
/**
* Validate job response from Elytra
*/
private function isValidJobResponse(array $data): bool
{
return isset($data['job_id']) &&
isset($data['status']) &&
in_array($data['status'], ['pending', 'running', 'completed', 'failed', 'cancelled']);
}
/**
* Update backup model based on job status from Elytra
*/
private function updateBackupFromJobStatus(Backup $backup, array $jobData): void
{
$status = $jobData['status'];
$progress = $jobData['progress'] ?? $backup->job_progress;
$message = $jobData['message'] ?? null;
$error = $jobData['error'] ?? null;
// Map Elytra status to backup status
$backupStatus = match($status) {
'pending' => Backup::JOB_STATUS_PENDING,
'running' => Backup::JOB_STATUS_RUNNING,
'completed' => Backup::JOB_STATUS_COMPLETED,
'failed' => Backup::JOB_STATUS_FAILED,
'cancelled' => Backup::JOB_STATUS_CANCELLED,
default => $backup->job_status,
};
// Update backup status
$backup->updateJobStatus($backupStatus, $progress, $message, $error);
// For completed backups, update additional fields from job data
if ($status === 'completed' && isset($jobData['result'])) {
$result = $jobData['result'];
$updateData = [];
if (isset($result['checksum'])) {
$updateData['checksum'] = $result['checksum'];
}
if (isset($result['size'])) {
$updateData['bytes'] = (int) $result['size'];
}
if (isset($result['snapshot_id'])) {
$updateData['snapshot_id'] = $result['snapshot_id'];
}
if (!empty($updateData)) {
$backup->update($updateData);
}
}
}
/**
* Update job queue status based on Elytra response
*/
private function updateJobQueueFromStatus(BackupJobQueue $jobQueue, array $jobData): void
{
$status = $jobData['status'];
$queueStatus = match($status) {
'pending' => BackupJobQueue::STATUS_QUEUED,
'running' => BackupJobQueue::STATUS_PROCESSING,
'completed' => BackupJobQueue::STATUS_COMPLETED,
'failed' => BackupJobQueue::STATUS_FAILED,
'cancelled' => BackupJobQueue::STATUS_CANCELLED,
default => $jobQueue->status,
};
if ($queueStatus !== $jobQueue->status) {
$errorMessage = isset($jobData['error']) ? $jobData['error'] : null;
if ($queueStatus === BackupJobQueue::STATUS_COMPLETED) {
$jobQueue->markCompleted();
} elseif ($queueStatus === BackupJobQueue::STATUS_FAILED) {
$jobQueue->markFailed($errorMessage ?? 'Job failed on Elytra');
} else {
$jobQueue->update(['status' => $queueStatus]);
}
}
}
/**
* Handle case where job is not found on Elytra
*/
private function handleJobNotFound(BackupJobQueue $jobQueue): void
{
// If backup is still pending/running, mark it as failed
if ($jobQueue->backup->isInProgress()) {
$jobQueue->backup->updateJobStatus(
Backup::JOB_STATUS_FAILED,
null,
null,
'Job not found on Elytra - may have been cleaned up'
);
}
$jobQueue->markFailed('Job not found on Elytra');
Log::warning('Backup job not found on Elytra', [
'job_id' => $jobQueue->job_id,
'backup_uuid' => $jobQueue->backup->uuid,
]);
}
/**
* Clean up expired jobs that are no longer relevant
*/
private function cleanupExpiredJobs(): int
{
$expiredJobs = BackupJobQueue::expired()->get();
if ($expiredJobs->isEmpty()) {
return 0;
}
Log::info('Cleaning up expired backup jobs', ['count' => $expiredJobs->count()]);
foreach ($expiredJobs as $jobQueue) {
// Mark associated backups as failed if still in progress
if ($jobQueue->backup->isInProgress()) {
$jobQueue->backup->updateJobStatus(
Backup::JOB_STATUS_FAILED,
null,
null,
'Job expired - no response from Elytra'
);
}
$jobQueue->markFailed('Job expired');
}
return $expiredJobs->count();
}
/**
* Poll status for a specific backup
*/
public function pollBackupStatus(Backup $backup): bool
{
if (!$backup->job_id) {
return false;
}
$jobQueue = BackupJobQueue::where('job_id', $backup->job_id)
->where('backup_id', $backup->id)
->first();
if (!$jobQueue) {
return false;
}
try {
$this->daemonRepository->setServer($backup->server);
$results = ['updated' => 0, 'errors' => 0, 'completed' => 0];
$this->pollSingleJob($jobQueue, $results);
return true;
} catch (\Exception $e) {
Log::error('Failed to poll backup status', [
'backup_uuid' => $backup->uuid,
'job_id' => $backup->job_id,
'error' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -1,79 +0,0 @@
<?php
namespace Pterodactyl\Services\Backups;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Backup;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
class BackupStorageService
{
public function __construct(
private BackupRepository $repository,
) {
}
public function calculateServerBackupStorage(Server $server): int
{
return $this->repository->getNonFailedBackups($server)->sum('bytes');
}
public function isOverStorageLimit(Server $server): bool
{
if (!$server->hasBackupStorageLimit()) {
return false;
}
return $this->calculateServerBackupStorage($server) > $server->getBackupStorageLimitBytes();
}
public function wouldExceedStorageLimit(Server $server, int $estimatedBackupSizeBytes): bool
{
if (!$server->hasBackupStorageLimit()) {
return false;
}
$currentUsage = $this->calculateServerBackupStorage($server);
$estimatedSize = $estimatedBackupSizeBytes * 0.5; // Conservative estimate for deduplication
return ($currentUsage + $estimatedSize) > $server->getBackupStorageLimitBytes();
}
public function getStorageUsageInfo(Server $server): array
{
$usedBytes = $this->calculateServerBackupStorage($server);
$limitBytes = $server->getBackupStorageLimitBytes();
$mbDivisor = 1024 * 1024;
$result = [
'used_bytes' => $usedBytes,
'used_mb' => round($usedBytes / $mbDivisor, 2),
'limit_bytes' => $limitBytes,
'limit_mb' => $server->backup_storage_limit,
'has_limit' => $server->hasBackupStorageLimit(),
];
if ($limitBytes) {
$result['usage_percentage'] = round(($usedBytes / $limitBytes) * 100, 1);
$result['available_bytes'] = max(0, $limitBytes - $usedBytes);
$result['available_mb'] = round($result['available_bytes'] / $mbDivisor, 2);
$result['is_over_limit'] = $usedBytes > $limitBytes;
}
return $result;
}
public function getBackupsForStorageCleanup(Server $server): \Illuminate\Database\Eloquent\Collection
{
return $this->repository->getNonFailedBackups($server)
->where('is_locked', false)
->sortBy('created_at');
}
public function calculateStorageFreedByDeletion(\Illuminate\Database\Eloquent\Collection $backups): int
{
return (int) $backups->sum(function ($backup) {
return $backup->isRustic() ? $backup->bytes * 0.3 : $backup->bytes;
});
}
}

View File

@@ -1,96 +0,0 @@
<?php
namespace Pterodactyl\Services\Backups;
use Illuminate\Http\Response;
use Pterodactyl\Models\Backup;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DeleteBackupService
{
public function __construct(
private ConnectionInterface $connection,
private BackupManager $manager,
private DaemonBackupRepository $daemonBackupRepository,
) {
}
/**
* Deletes a backup from the system. If the backup is stored in S3 a request
* will be made to delete that backup from the disk as well.
*
* @throws \Throwable
*/
public function handle(Backup $backup): void
{
// If the backup is marked as failed it can still be deleted, even if locked
// since the UI doesn't allow you to unlock a failed backup in the first place.
//
// I also don't really see any reason you'd have a locked, failed backup to keep
// around. The logic that updates the backup to the failed state will also remove
// the lock, so this condition should really never happen.
if ($backup->is_locked && ($backup->is_successful && !is_null($backup->completed_at))) {
throw new BackupLockedException();
}
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$this->deleteFromS3($backup);
return;
}
$this->connection->transaction(function () use ($backup) {
try {
$this->daemonBackupRepository->setServer($backup->server)->delete($backup);
} catch (DaemonConnectionException $exception) {
$previous = $exception->getPrevious();
// Don't fail the request if the Daemon responds with a 404, just assume the backup
// doesn't actually exist and remove its reference from the Panel as well.
if (!$previous instanceof ClientException || $previous->getResponse()->getStatusCode() !== Response::HTTP_NOT_FOUND) {
throw $exception;
}
}
$backup->delete();
});
}
/**
* Deletes a backup from an S3 disk.
*
* @throws \Throwable
*/
protected function deleteFromS3(Backup $backup): void
{
$this->connection->transaction(function () use ($backup) {
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
$s3Key = sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid);
// First delete from S3, then from database to prevent orphaned records
try {
$adapter->getClient()->deleteObject([
'Bucket' => $adapter->getBucket(),
'Key' => $s3Key,
]);
} catch (\Exception $e) {
// Log S3 deletion failure but continue with database cleanup
\Log::warning('Failed to delete backup from S3, continuing with database cleanup', [
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
's3_key' => $s3Key,
'error' => $e->getMessage(),
]);
}
// Delete from database after S3 cleanup
$backup->delete();
});
}
}

View File

@@ -1,178 +0,0 @@
<?php
namespace Pterodactyl\Services\Backups;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Services\Backups\BackupStorageService;
use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Carbon\CarbonImmutable;
class InitiateBackupService
{
private ?array $ignoredFiles;
private bool $isLocked = false;
/**
* InitiateBackupService constructor.
*/
public function __construct(
private BackupRepository $repository,
private ConnectionInterface $connection,
private DaemonBackupRepository $daemonBackupRepository,
private DeleteBackupService $deleteBackupService,
private BackupManager $backupManager,
private ServerStateService $serverStateService,
private BackupStorageService $backupStorageService,
) {
}
/**
* Set if the backup should be locked once it is created which will prevent
* its deletion by users or automated system processes.
*/
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
* @param string[]|null $ignored
*/
public function setIgnoredFiles(?array $ignored): self
{
if (is_array($ignored)) {
foreach ($ignored as $value) {
Assert::string($value);
}
}
// Set the ignored files to be any values that are not empty in the array. Don't use
// the PHP empty function here incase anything that is "empty" by default (0, false, etc.)
// were passed as a file or folder name.
$this->ignoredFiles = is_null($ignored) ? [] : array_filter($ignored, function ($value) {
return strlen($value) > 0;
});
return $this;
}
/**
* Initiates the backup process for a server on Wings.
*
* @throws \Throwable
* @throws TooManyBackupsException
* @throws TooManyRequestsHttpException
*/
public function handle(Server $server, ?string $name = null, bool $override = false): Backup
{
// Validate server state before creating backup
$this->validateServerForBackup($server);
// Check for existing backups in progress (only allow one at a time)
$inProgressBackups = $this->repository->getBackupsInProgress($server->id);
if ($inProgressBackups->count() > 0) {
throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.');
}
$successful = $this->repository->getNonFailedBackups($server);
if (!$server->allowsBackups()) {
throw new TooManyBackupsException(0, 'Backups are disabled for this server');
}
// Block backup creation if already over storage limit
if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) {
$usage = $this->backupStorageService->getStorageUsageInfo($server);
throw new TooManyBackupsException(0, sprintf(
'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.',
$usage['used_mb'],
$usage['limit_mb']
));
}
elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) {
if (!$override) {
throw new TooManyBackupsException($server->backup_limit);
}
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
if (!$oldest) {
throw new TooManyBackupsException($server->backup_limit);
}
$this->deleteBackupService->handle($oldest);
}
return $this->connection->transaction(function () use ($server, $name) {
$backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString());
$backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName);
$backupName = substr($backupName, 0, 191); // Limit to database field length
$serverState = $this->serverStateService->captureServerState($server);
// Use the configured default adapter
$adapter = $this->backupManager->getDefaultAdapter();
/** @var Backup $backup */
$backup = $this->repository->create([
'server_id' => $server->id,
'uuid' => Uuid::uuid4()->toString(),
'name' => $backupName,
'ignored_files' => array_values($this->ignoredFiles ?? []),
'disk' => $adapter,
'is_locked' => $this->isLocked,
'server_state' => $serverState,
], true, true);
try {
$this->daemonBackupRepository->setServer($server)
->setBackupAdapter($adapter)
->backup($backup);
} catch (\Exception $e) {
// If daemon backup request fails, clean up the backup record
$backup->delete();
throw $e;
}
return $backup;
});
}
/**
* Validate that the server is in a valid state for backup creation
*/
private function validateServerForBackup(Server $server): void
{
if ($server->isSuspended()) {
throw new TooManyBackupsException(0, 'Cannot create backup for suspended server.');
}
if (!$server->isInstalled()) {
throw new TooManyBackupsException(0, 'Cannot create backup for server that is not fully installed.');
}
if ($server->status === Server::STATUS_RESTORING_BACKUP) {
throw new TooManyBackupsException(0, 'Cannot create backup while server is restoring from another backup.');
}
if ($server->transfer) {
throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.');
}
}
}

View File

@@ -0,0 +1,303 @@
<?php
namespace Pterodactyl\Services\Elytra;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ElytraJob;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Contracts\Elytra\Job;
use Pterodactyl\Repositories\Elytra\ElytraRepository;
class ElytraJobService
{
private array $jobHandlers = [];
public function __construct(
private ElytraRepository $elytraRepository,
) {
$this->discoverHandlers();
}
private function discoverHandlers(): void
{
$handlerClasses = [
\Pterodactyl\Services\Elytra\Jobs\BackupJob::class,
];
foreach ($handlerClasses as $handlerClass) {
if (class_exists($handlerClass)) {
$handler = app($handlerClass);
if ($handler instanceof Job) {
foreach ($handler::getSupportedJobTypes() as $jobType) {
$this->jobHandlers[$jobType] = $handler;
}
}
}
}
}
public function registerJobHandler(Job $handler): void
{
foreach ($handler::getSupportedJobTypes() as $jobType) {
$this->jobHandlers[$jobType] = $handler;
}
}
public function submitJob(Server $server, string $jobType, array $jobData, User $user): array
{
$handler = $this->getJobHandler($jobType);
$validatedData = $handler->validateJobData($jobData);
$job = ElytraJob::create([
'server_id' => $server->id,
'user_id' => $user->id,
'job_type' => $jobType,
'job_data' => $validatedData,
'status' => ElytraJob::STATUS_PENDING,
'progress' => 0,
'created_at' => CarbonImmutable::now(),
]);
try {
$elytraJobId = $handler->submitToElytra($server, $job, $this->elytraRepository);
$job->update([
'elytra_job_id' => $elytraJobId,
'status' => ElytraJob::STATUS_SUBMITTED,
'submitted_at' => CarbonImmutable::now(),
]);
return [
'job_id' => $job->uuid,
'elytra_job_id' => $elytraJobId,
'status' => 'submitted',
'message' => 'Job submitted to Elytra successfully',
'data' => $handler->formatJobResponse($job),
];
} catch (\Exception $e) {
$job->update([
'status' => ElytraJob::STATUS_FAILED,
'error_message' => $e->getMessage(),
'completed_at' => CarbonImmutable::now(),
]);
Log::error('Failed to submit job to Elytra', [
'job_id' => $job->uuid,
'job_type' => $jobType,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getJobStatus(Server $server, string $jobId): ?array
{
$job = ElytraJob::where('uuid', $jobId)
->where('server_id', $server->id)
->first();
if (!$job) {
return null;
}
$handler = $this->getJobHandler($job->job_type);
return [
'job_id' => $job->uuid,
'elytra_job_id' => $job->elytra_job_id,
'type' => $job->job_type,
'status' => $job->status,
'progress' => $job->progress,
'message' => $job->status_message,
'error' => $job->error_message,
'created_at' => $job->created_at,
'submitted_at' => $job->submitted_at,
'completed_at' => $job->completed_at,
'data' => $handler->formatJobResponse($job),
];
}
public function cancelJob(Server $server, string $jobId): array
{
$job = ElytraJob::where('uuid', $jobId)
->where('server_id', $server->id)
->first();
if (!$job) {
throw new \Exception('Job not found');
}
if (!in_array($job->status, [ElytraJob::STATUS_PENDING, ElytraJob::STATUS_SUBMITTED, ElytraJob::STATUS_RUNNING])) {
throw new \Exception('Job cannot be cancelled in current status');
}
$handler = $this->getJobHandler($job->job_type);
try {
$handler->cancelOnElytra($server, $job, $this->elytraRepository);
$job->update([
'status' => ElytraJob::STATUS_CANCELLED,
'completed_at' => CarbonImmutable::now(),
]);
return [
'job_id' => $job->uuid,
'status' => 'cancelled',
'message' => 'Job cancelled successfully',
];
} catch (\Exception $e) {
Log::error('Failed to cancel job on Elytra', [
'job_id' => $job->uuid,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function listJobs(Server $server, ?string $jobType = null, ?string $status = null): array
{
$query = ElytraJob::where('server_id', $server->id);
if ($jobType) {
$query->where('job_type', $jobType);
}
if ($status) {
$query->where('status', $status);
}
$jobs = $query->orderBy('created_at', 'desc')->get();
return $jobs->map(function ($job) {
$handler = $this->getJobHandler($job->job_type);
return [
'job_id' => $job->uuid,
'elytra_job_id' => $job->elytra_job_id,
'type' => $job->job_type,
'status' => $job->status,
'progress' => $job->progress,
'message' => $job->status_message,
'error' => $job->error_message,
'created_at' => $job->created_at,
'submitted_at' => $job->submitted_at,
'completed_at' => $job->completed_at,
'data' => $handler->formatJobResponse($job),
];
})->toArray();
}
public function updateJobStatus(string $elytraJobId, array $statusData): void
{
$job = ElytraJob::where('elytra_job_id', $elytraJobId)->first();
if (!$job) {
Log::warning('Received status update for unknown job', [
'elytra_job_id' => $elytraJobId,
]);
return;
}
$server = $job->server;
$currentStatus = $job->status;
$newStatus = $statusData['status'] ?? 'unknown';
$job->update([
'status' => $newStatus,
'progress' => $statusData['progress'] ?? $job->progress,
'status_message' => $statusData['message'] ?? null,
'error_message' => $statusData['error_message'] ?? null,
]);
if ($newStatus === 'completed' || $newStatus === 'failed') {
$handler = $this->getJobHandler($job->job_type);
$handler->processStatusUpdate($job, $statusData);
}
Log::info('Job status updated', [
'job_id' => $job->uuid,
'elytra_job_id' => $elytraJobId,
'job_type' => $job->job_type,
'old_status' => $currentStatus,
'new_status' => $newStatus,
'progress' => $statusData['progress'] ?? 0,
]);
}
private function fireJobStatusEvent(ElytraJob $job, array $statusData): void
{
$server = $job->server;
$eventName = $this->getJobEventName($job->job_type, $job->status);
$eventData = [
'job_id' => $job->uuid,
'elytra_job_id' => $job->elytra_job_id,
'job_type' => $job->job_type,
'status' => $job->status,
'progress' => $job->progress,
'message' => $job->status_message,
'error' => $job->error_message,
];
if ($job->job_type === 'backup_create') {
if (isset($statusData['checksum'])) {
$eventData['checksum'] = $statusData['checksum'];
$eventData['checksum_type'] = $statusData['checksum_type'] ?? 'sha1';
}
if (isset($statusData['size'])) {
$eventData['file_size'] = $statusData['size'];
}
if (isset($statusData['snapshot_id'])) {
$eventData['snapshot_id'] = $statusData['snapshot_id'];
}
if ($job->job_data && isset($job->job_data['backup_uuid'])) {
$eventData['uuid'] = $job->job_data['backup_uuid'];
}
}
$server->events()->publish($eventName, json_encode($eventData));
Log::debug('Fired WebSocket event for job status', [
'event' => $eventName,
'server' => $server->uuid,
'job_id' => $job->uuid,
'status' => $job->status,
]);
}
private function getJobEventName(string $jobType, string $status): string
{
return match ([$jobType, $status]) {
['backup_create', 'pending'] => 'backup.started',
['backup_create', 'running'] => 'backup.progress',
['backup_create', 'completed'] => 'backup.completed',
['backup_create', 'failed'] => 'backup.failed',
['backup_delete', 'pending'] => 'backup.delete.started',
['backup_delete', 'running'] => 'backup.delete.progress',
['backup_delete', 'completed'] => 'backup.delete.completed',
['backup_delete', 'failed'] => 'backup.delete.failed',
['backup_restore', 'pending'] => 'backup.restore.started',
['backup_restore', 'running'] => 'backup.restore.progress',
['backup_restore', 'completed'] => 'backup.restore.completed',
['backup_restore', 'failed'] => 'backup.restore.failed',
default => "job.{$status}",
};
}
public function getJobHandler(string $jobType): Job
{
if (!isset($this->jobHandlers[$jobType])) {
throw new \Exception("No handler registered for job type: {$jobType}");
}
return $this->jobHandlers[$jobType];
}
}

View File

@@ -0,0 +1,345 @@
<?php
namespace Pterodactyl\Services\Elytra\Jobs;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ElytraJob;
use Pterodactyl\Models\Permission;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Pterodactyl\Contracts\Elytra\Job;
use Pterodactyl\Repositories\Elytra\ElytraRepository;
use Pterodactyl\Services\Backups\ServerStateService;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
class BackupJob implements Job
{
public function __construct(
private ServerStateService $serverStateService,
private BackupTransformer $backupTransformer,
) {}
public static function getSupportedJobTypes(): array
{
return ['backup_create', 'backup_delete', 'backup_restore', 'backup_download'];
}
public function getRequiredPermissions(string $operation): array
{
return match ($operation) {
'index' => [Permission::ACTION_BACKUP_READ],
'create' => [Permission::ACTION_BACKUP_CREATE],
'show' => [Permission::ACTION_BACKUP_READ],
'cancel' => [Permission::ACTION_BACKUP_DELETE],
default => [],
};
}
public function validateJobData(array $jobData): array
{
$rules = match ($jobData['operation'] ?? '') {
'create' => [
'operation' => 'required|string|in:create',
'adapter' => 'nullable|string',
'ignored' => 'nullable|string',
'name' => 'nullable|string|max:255',
],
'delete' => [
'operation' => 'required|string|in:delete',
'backup_uuid' => 'required|string|uuid',
],
'restore' => [
'operation' => 'required|string|in:restore',
'backup_uuid' => 'required|string|uuid',
'truncate_directory' => 'boolean',
],
'download' => [
'operation' => 'required|string|in:download',
'backup_uuid' => 'required|string|uuid',
],
default => throw new \Exception('Invalid or missing operation'),
};
$validator = Validator::make($jobData, $rules);
if ($validator->fails()) {
throw new \Exception('Invalid job data: ' . implode(', ', $validator->errors()->all()));
}
return $validator->validated();
}
public function submitToElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
$jobData = $job->job_data;
$operation = $jobData['operation'];
return match ($operation) {
'create' => $this->submitCreateJob($server, $job, $elytraRepository),
'delete' => $this->submitDeleteJob($server, $job, $elytraRepository),
'restore' => $this->submitRestoreJob($server, $job, $elytraRepository),
'download' => $this->submitDownloadJob($server, $job, $elytraRepository),
default => throw new \Exception("Unsupported backup operation: {$operation}"),
};
}
public function cancelOnElytra(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): void
{
if (!$job->elytra_job_id) {
throw new \Exception('No Elytra job ID to cancel');
}
$elytraRepository->setServer($server)->cancelJob($job->elytra_job_id);
}
public function processStatusUpdate(ElytraJob $job, array $statusData): void
{
$successful = $statusData['successful'] ?? false;
$jobType = $statusData['job_type'] ?? '';
$operation = $this->getOperationFromJobType($jobType);
$job->update([
'status' => $successful ? ElytraJob::STATUS_COMPLETED : ElytraJob::STATUS_FAILED,
'progress' => $successful ? 100 : $job->progress,
'status_message' => $statusData['message'] ?? ($successful ? 'Completed successfully' : 'Failed'),
'error_message' => $successful ? null : ($statusData['error_message'] ?? 'Unknown error'),
'completed_at' => CarbonImmutable::now(),
]);
match ($operation) {
'create' => $this->handleCreateCompletion($job, $statusData),
'delete' => $this->handleDeleteCompletion($job, $statusData),
'restore' => $this->handleRestoreCompletion($job, $statusData),
'download' => $this->handleDownloadCompletion($job, $statusData),
default => Log::warning("Unknown backup operation for status update: {$operation}"),
};
}
public function formatJobResponse(ElytraJob $job): array
{
$jobData = $job->job_data;
$operation = $jobData['operation'] ?? 'unknown';
$response = [
'operation' => $operation,
];
if (isset($jobData['backup_uuid'])) {
$backup = Backup::where('uuid', $jobData['backup_uuid'])->first();
if ($backup) {
$response['backup'] = $this->backupTransformer->transform($backup);
}
}
match ($operation) {
'create' => $response = array_merge($response, [
'adapter' => $jobData['adapter'] ?? null,
'ignored' => $jobData['ignored'] ?? null,
'name' => $jobData['name'] ?? null,
]),
'restore' => $response = array_merge($response, [
'truncate_directory' => $jobData['truncate_directory'] ?? false,
]),
default => null,
};
return $response;
}
private function submitCreateJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
$jobData = $job->job_data;
$backupUuid = $this->generateBackupUuid();
$job->update([
'job_data' => array_merge($jobData, ['backup_uuid' => $backupUuid]),
]);
$elytraJobData = [
'server_id' => $server->uuid,
'backup_uuid' => $backupUuid,
'name' => $jobData['name'] ?? $this->generateBackupName(),
'ignore' => $jobData['ignored'] ?? '',
'adapter_type' => $jobData['adapter'] ?? 'elytra',
];
Log::info("Submitting backup creation job to Elytra", [
'server_id' => $server->id,
'backup_uuid' => $backupUuid,
'job_data' => $elytraJobData,
]);
$response = $elytraRepository->setServer($server)->createJob('backup_create', $elytraJobData);
return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra');
}
private function submitDeleteJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
$jobData = $job->job_data;
$backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail();
$elytraJobData = [
'server_id' => $server->uuid,
'backup_uuid' => $backup->uuid,
'adapter_type' => $backup->getElytraAdapterType(),
];
$response = $elytraRepository->setServer($server)->createJob('backup_delete', $elytraJobData);
return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra');
}
private function submitRestoreJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
$jobData = $job->job_data;
$backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail();
$elytraJobData = [
'server_id' => $server->uuid,
'backup_uuid' => $backup->uuid,
'adapter_type' => $backup->getElytraAdapterType(),
'truncate_directory' => $jobData['truncate_directory'] ?? false,
'download_url' => $jobData['download_url'] ?? null,
];
$response = $elytraRepository->setServer($server)->createJob('backup_restore', $elytraJobData);
return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra');
}
private function submitDownloadJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
throw new \Exception('Download jobs not yet implemented');
}
private function handleCreateCompletion(ElytraJob $job, array $statusData): void
{
$jobData = $job->job_data;
$backupUuid = $jobData['backup_uuid'] ?? null;
if (!$backupUuid) {
Log::error("No backup UUID in job data for completed backup job", ['job_id' => $job->id]);
return;
}
if ($statusData['successful']) {
$server = $job->server;
$actualAdapter = $this->mapElytraAdapterToModel($statusData['adapter'] ?? 'rustic_local');
$backupData = [
'server_id' => $server->id,
'uuid' => $backupUuid,
'name' => $jobData['name'] ?? $this->generateBackupName(),
'ignored_files' => $this->parseIgnoredFiles($jobData['ignored'] ?? ''),
'disk' => $actualAdapter,
'is_successful' => true,
'is_locked' => false,
'checksum' => ($statusData['checksum_type'] ?? 'sha1') . ':' . ($statusData['checksum'] ?? ''),
'bytes' => $statusData['size'] ?? 0,
'snapshot_id' => $statusData['snapshot_id'] ?? null,
'completed_at' => CarbonImmutable::now(),
];
$serverState = null;
try {
$serverState = $this->serverStateService->captureServerState($server);
} catch (\Exception $e) {
Log::warning("Could not capture server state for backup", [
'backup_uuid' => $backupUuid,
'error' => $e->getMessage(),
]);
}
if ($serverState) {
$backupData['server_state'] = $serverState;
}
$backup = Backup::create($backupData);
Log::info("Backup record created successfully", [
'backup_id' => $backup->id,
'backup_uuid' => $backup->uuid,
'disk' => $backup->disk,
'size_mb' => round($backup->bytes / 1024 / 1024, 2),
]);
} else {
Log::error("Backup job failed", [
'backup_uuid' => $backupUuid,
'error' => $statusData['error_message'] ?? 'Unknown error',
'job_id' => $job->id,
]);
}
}
private function handleDeleteCompletion(ElytraJob $job, array $statusData): void
{
if ($statusData['successful']) {
$jobData = $job->job_data;
$backup = Backup::where('uuid', $jobData['backup_uuid'])->first();
if ($backup) {
$backup->delete();
}
}
}
private function handleRestoreCompletion(ElytraJob $job, array $statusData): void
{
}
private function handleDownloadCompletion(ElytraJob $job, array $statusData): void
{
}
private function getOperationFromJobType(string $jobType): string
{
return match ($jobType) {
'backup_create' => 'create',
'backup_delete' => 'delete',
'backup_restore' => 'restore',
'backup_download' => 'download',
default => 'unknown',
};
}
private function generateBackupUuid(): string
{
return (string) \Illuminate\Support\Str::uuid();
}
private function generateBackupName(): string
{
return 'Backup at ' . now()->format('Y-m-d Hi');
}
private function mapElytraAdapterToModel(string $elytraAdapter): string
{
return match ($elytraAdapter) {
'elytra', 'local' => Backup::ADAPTER_RUSTIC_LOCAL,
'rustic_local' => Backup::ADAPTER_RUSTIC_LOCAL,
'rustic_s3' => Backup::ADAPTER_RUSTIC_S3,
's3' => Backup::ADAPTER_RUSTIC_S3,
'wings' => Backup::ADAPTER_WINGS,
default => Backup::ADAPTER_RUSTIC_LOCAL,
};
}
private function parseIgnoredFiles(string $ignored): array
{
if (empty($ignored)) {
return [];
}
$files = array_filter(
array_map('trim', explode("\n", $ignored)),
fn($line) => !empty($line)
);
return array_values($files);
}
}

View File

@@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
@@ -23,7 +22,6 @@ class ServerDeletionService
private ConnectionInterface $connection,
private DaemonServerRepository $daemonServerRepository,
private DatabaseManagementService $databaseManagementService,
private DeleteBackupService $deleteBackupService,
) {
}
@@ -63,29 +61,11 @@ class ServerDeletionService
// Delete all backups associated with this server
foreach ($server->backups as $backup) {
try {
$this->deleteBackupService->handle($backup);
} catch (BackupLockedException $exception) {
// If the backup is locked, unlock it and try again since we're deleting the entire server
$backup->update(['is_locked' => false]);
try {
$this->deleteBackupService->handle($backup);
} catch (\Exception $retryException) {
if (!$this->force) {
throw $retryException;
}
// If we still can't delete the backup from storage, at least remove the database record
// to prevent orphaned backup entries
$backup->delete();
Log::warning('Failed to delete unlocked backup during server deletion', [
'backup_id' => $backup->id,
'backup_uuid' => $backup->uuid,
'server_id' => $server->id,
'exception' => $retryException->getMessage(),
]);
}
// Simply delete the backup record
// note: this used to be more complex but Elytra's changes have made a lot of logic here redundant
// so this whole thing really needs a refactor now. THAT BEING SAID I HAVE NOT TESTED LOCAL IN A MINUTE!
// - ellie
$backup->delete();
} catch (\Exception $exception) {
if (!$this->force) {
throw $exception;

View File

@@ -33,17 +33,6 @@ class BackupTransformer extends BaseClientTransformer
'snapshot_id' => $backup->snapshot_id,
'created_at' => $backup->created_at->toAtomString(),
'completed_at' => $backup->completed_at ? $backup->completed_at->toAtomString() : null,
// Async job fields
'job_id' => $backup->job_id,
'job_status' => $backup->job_status,
'job_progress' => $backup->job_progress,
'job_message' => $backup->job_message,
'job_error' => $backup->job_error,
'job_started_at' => $backup->job_started_at ? $backup->job_started_at->toAtomString() : null,
'job_last_updated_at' => $backup->job_last_updated_at ? $backup->job_last_updated_at->toAtomString() : null,
'can_cancel' => $backup->canCancel(),
'can_retry' => $backup->canRetry(),
'is_in_progress' => $backup->isInProgress(),
];
// Add server state information if available

View File

@@ -6,7 +6,7 @@ return [
// The backup driver to use for this Panel instance. All client generated server backups
// will be stored in this location by default. It is possible to change this once backups
// have been made, without losing data.
// Options: wings, s3, rustic_local, rustic_s3
// Options: elytra, wings (legacy), s3, rustic_local, rustic_s3
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL),
// This value is used to determine the lifespan of UploadPart presigned urls that wings
@@ -29,6 +29,11 @@ return [
'adapter' => Backup::ADAPTER_WINGS,
],
// Elytra local backups (preferred over wings)
'elytra' => [
'adapter' => Backup::ADAPTER_ELYTRA,
],
// Configuration for storing backups in Amazon S3. This uses the same credentials
// specified in filesystems.php but does include some more specific settings for
// backups, notably bucket, location, and use_accelerate_endpoint.

View File

@@ -1,89 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class AddAsyncBackupJobsSupport extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backups', function (Blueprint $table) {
// Add async job tracking fields
$table->string('job_id')->nullable()->after('uuid')->index()->comment('Elytra job ID for async operations');
$table->enum('job_status', ['pending', 'running', 'completed', 'failed', 'cancelled'])
->default('pending')
->after('job_id')
->comment('Current status of the backup job');
$table->tinyInteger('job_progress')->default(0)->after('job_status')->comment('Job progress percentage (0-100)');
$table->text('job_message')->nullable()->after('job_progress')->comment('Current job status message');
$table->text('job_error')->nullable()->after('job_message')->comment('Error message if job failed');
$table->timestamp('job_started_at')->nullable()->after('job_error')->comment('When the job started processing');
$table->timestamp('job_last_updated_at')->nullable()->after('job_started_at')->comment('Last job status update');
// Add indexes for efficient querying
$table->index(['server_id', 'job_status'], 'backups_server_job_status_index');
$table->index(['job_status', 'job_last_updated_at'], 'backups_job_status_updated_index');
});
// Create backup job queue table for tracking operations that need retry/cleanup
Schema::create('backup_job_queue', function (Blueprint $table) {
$table->id();
$table->string('job_id')->unique()->comment('Elytra job ID');
$table->unsignedBigInteger('backup_id')->index()->comment('Associated backup record');
$table->enum('operation_type', ['create', 'delete', 'restore'])->comment('Type of backup operation');
$table->enum('status', ['queued', 'processing', 'completed', 'failed', 'cancelled', 'retry'])->default('queued');
$table->json('job_data')->nullable()->comment('Original job request data');
$table->text('error_message')->nullable()->comment('Error details if failed');
$table->tinyInteger('retry_count')->default(0)->comment('Number of retry attempts');
$table->timestamp('last_polled_at')->nullable()->comment('Last time job status was checked');
$table->timestamp('expires_at')->nullable()->comment('When to stop polling for this job');
$table->timestamps();
$table->foreign('backup_id')->references('id')->on('backups')->onDelete('cascade');
$table->index(['status', 'last_polled_at'], 'job_queue_status_polled_index');
$table->index(['expires_at'], 'job_queue_expires_index');
});
// Update existing backups to have default job status
DB::table('backups')->update([
'job_status' => DB::raw('CASE
WHEN is_successful = 1 AND completed_at IS NOT NULL THEN "completed"
WHEN is_successful = 0 AND completed_at IS NOT NULL THEN "failed"
ELSE "completed"
END'),
'job_progress' => DB::raw('CASE
WHEN is_successful = 1 AND completed_at IS NOT NULL THEN 100
WHEN is_successful = 0 AND completed_at IS NOT NULL THEN 0
ELSE 100
END'),
'job_last_updated_at' => DB::raw('COALESCE(completed_at, updated_at)')
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backup_job_queue');
Schema::table('backups', function (Blueprint $table) {
$table->dropIndex('backups_server_job_status_index');
$table->dropIndex('backups_job_status_updated_index');
$table->dropColumn([
'job_id',
'job_status',
'job_progress',
'job_message',
'job_error',
'job_started_at',
'job_last_updated_at'
]);
});
}
}

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('elytra_jobs', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->unsignedInteger('server_id');
$table->unsignedInteger('user_id');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('job_type'); // backup_create, backup_delete, etc.
$table->json('job_data'); // Operation-specific data
$table->string('status')->default('pending'); // pending, submitted, running, completed, failed, cancelled
$table->integer('progress')->default(0); // 0-100
$table->text('status_message')->nullable();
$table->text('error_message')->nullable();
$table->string('elytra_job_id')->nullable(); // Job ID from Elytra daemon
$table->timestampTz('created_at');
$table->timestampTz('submitted_at')->nullable();
$table->timestampTz('completed_at')->nullable();
$table->timestampTz('updated_at');
$table->index(['server_id', 'status']);
$table->index(['server_id', 'job_type']);
$table->index(['elytra_job_id']);
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('elytra_jobs');
}
};

View File

@@ -1,12 +0,0 @@
import http from '@/api/http';
export interface CancelBackupResponse {
message: string;
status: string;
}
export default async (uuid: string, backupUuid: string): Promise<CancelBackupResponse> => {
const { data } = await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/cancel`);
return data;
};

View File

@@ -25,11 +25,69 @@ export default async (uuid: string, params: RequestParameters): Promise<{ backup
is_locked: params.isLocked,
});
return {
backup: rawDataToServerBackup(response.data.data),
jobId: response.data.meta.job_id,
status: response.data.meta.status,
progress: response.data.meta.progress,
message: response.data.meta.message,
};
if (!response.data) {
throw new Error('Invalid response: missing data');
}
if (response.data.data && response.data.meta) {
const backupData = rawDataToServerBackup(response.data.data);
return {
backup: backupData,
jobId: response.data.meta.job_id,
status: response.data.meta.status,
progress: response.data.meta.progress,
message: response.data.meta.message,
};
}
if (response.data.job_id && response.data.status) {
// Create a minimal backup object for the async job
// note: I really don't like this implementation but I really can't be fucked right now to do this better - ellie
const tempBackup: ServerBackup = {
uuid: '', // Will be filled when WebSocket events arrive
name: params.name || 'Pending...',
isSuccessful: false,
isLocked: params.isLocked,
checksum: '',
bytes: 0,
createdAt: new Date(),
completedAt: null,
canRetry: false,
jobStatus: response.data.status,
jobProgress: 0,
jobMessage: response.data.message || '',
jobId: response.data.job_id,
jobError: null,
object: 'backup'
};
return {
backup: tempBackup,
jobId: response.data.job_id,
status: response.data.status,
progress: 0,
message: response.data.message || '',
};
}
if (response.data.uuid || response.data.object === 'backup') {
try {
const backupData = rawDataToServerBackup(response.data);
return {
backup: backupData,
jobId: backupData.jobId || '',
status: backupData.jobStatus || 'pending',
progress: backupData.jobProgress || 0,
message: backupData.jobMessage || '',
};
} catch (transformError) {
throw new Error(`Failed to process backup response: ${transformError.message}`);
}
}
throw new Error('Invalid response: unknown structure');
};

View File

@@ -1,5 +1,17 @@
import http from '@/api/http';
export default async (uuid: string, backup: string): Promise<void> => {
await http.delete(`/api/client/servers/${uuid}/backups/${backup}`);
interface DeleteBackupResponse {
job_id: string;
status: string;
message: string;
}
export default async (uuid: string, backup: string): Promise<{ jobId: string; status: string; message: string }> => {
const response = await http.delete<DeleteBackupResponse>(`/api/client/servers/${uuid}/backups/${backup}`);
return {
jobId: response.data.job_id,
status: response.data.status,
message: response.data.message,
};
};

View File

@@ -1,13 +1,28 @@
import http from '@/api/http';
export const restoreServerBackup = async (uuid: string, backup: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, {});
interface RestoreBackupResponse {
job_id: string;
status: string;
message: string;
}
export const restoreServerBackup = async (uuid: string, backup: string): Promise<{ jobId: string; status: string; message: string }> => {
const response = await http.post<RestoreBackupResponse>(`/api/client/servers/${uuid}/backups/${backup}/restore`, {
adapter: 'rustic_s3',
truncate_directory: true,
download_url: ''
});
return {
jobId: response.data.job_id,
status: response.data.status,
message: response.data.message,
};
};
export { default as createServerBackup } from './createServerBackup';
export { default as deleteServerBackup } from './deleteServerBackup';
export { default as getServerBackupDownloadUrl } from './getServerBackupDownloadUrl';
export { default as renameServerBackup } from './renameServerBackup';
export { default as getBackupStatus } from './getBackupStatus';
export { default as cancelBackup } from './cancelBackup';
export { default as retryBackup } from './retryBackup';
export type { BackupJobStatus } from './getBackupStatus';

View File

@@ -22,13 +22,12 @@ export interface ServerBackup {
completedAt: Date | null;
// Async job fields
jobId: string | null;
jobStatus: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
jobStatus: 'pending' | 'running' | 'completed' | 'failed';
jobProgress: number;
jobMessage: string | null;
jobError: string | null;
jobStartedAt: Date | null;
jobLastUpdatedAt: Date | null;
canCancel: boolean;
canRetry: boolean;
isInProgress: boolean;
}

View File

@@ -47,5 +47,8 @@ export default () => {
storage: data.meta.storage,
limits: data.meta.limits,
};
}, {
revalidateOnFocus: false,
revalidateOnReconnect: true,
});
};

View File

@@ -76,7 +76,6 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
jobError: attributes.job_error || null,
jobStartedAt: attributes.job_started_at ? new Date(attributes.job_started_at) : null,
jobLastUpdatedAt: attributes.job_last_updated_at ? new Date(attributes.job_last_updated_at) : null,
canCancel: attributes.can_cancel || false,
canRetry: attributes.can_retry || false,
isInProgress: ['pending', 'running'].includes(attributes.job_status || ''),
});

View File

@@ -15,14 +15,14 @@ import Pagination from '@/components/elements/Pagination';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import Spinner from '@/components/elements/Spinner';
import { PageListContainer } from '@/components/elements/pages/PageList';
import BackupRow from '@/components/server/backups/BackupRow';
import createServerBackup from '@/api/server/backups/createServerBackup';
import getServerBackups, { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
import { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import { useUnifiedBackups } from './useUnifiedBackups';
import BackupItem from './BackupItem';
// Helper function to format storage values
const formatStorage = (mb: number | undefined | null): string => {
@@ -96,71 +96,45 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
const BackupContainer = () => {
const { page, setPage } = useContext(ServerBackupContext);
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
const { data: backups, error, isValidating, mutate } = getServerBackups();
const [createModalVisible, setCreateModalVisible] = useState(false);
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const {
backups,
backupCount,
storage,
error,
isValidating,
createBackup
} = useUnifiedBackups();
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
const hasBackupsInProgress = backups?.items.some((backup) => backup.isInProgress) || false;
useEffect(() => {
let interval: NodeJS.Timeout;
if (hasBackupsInProgress) {
interval = setInterval(() => {
mutate();
}, 2000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [hasBackupsInProgress, mutate]);
useEffect(() => {
clearFlashes('backups:create');
}, [createModalVisible]);
const submitBackup = (values: BackupValues, { setSubmitting }: FormikHelpers<BackupValues>) => {
const submitBackup = async (values: BackupValues, { setSubmitting }: FormikHelpers<BackupValues>) => {
clearFlashes('backups:create');
createServerBackup(uuid, values)
.then(async (result) => {
// Add the new backup to the list immediately
await mutate(
(data) => ({
...data!,
items: data!.items.concat(result.backup),
backupCount: data!.backupCount + 1
}),
false,
);
// Show success message
clearFlashes('backups');
addFlash({
type: 'success',
key: 'backups',
message: `Backup "${result.backup.name}" has been started successfully.`,
});
try {
await createBackup(values.name, values.ignored, values.isLocked);
setSubmitting(false);
setCreateModalVisible(false);
})
.catch((error) => {
clearAndAddHttpError({ key: 'backups:create', error });
setSubmitting(false);
});
// Clear any existing flash messages
clearFlashes('backups');
clearFlashes('backups:create');
setSubmitting(false);
setCreateModalVisible(false);
} catch (error) {
clearAndAddHttpError({ key: 'backups:create', error });
setSubmitting(false);
}
};
useEffect(() => {
if (!error) {
clearFlashes('backups');
return;
}
@@ -197,12 +171,12 @@ const BackupContainer = () => {
{/* Backup Count Display */}
{backupLimit === null && (
<p className='text-sm text-zinc-300'>
{backups.backupCount} backups
{backupCount} backups
</p>
)}
{backupLimit > 0 && (
<p className='text-sm text-zinc-300'>
{backups.backupCount} of {backupLimit} backups
{backupCount} of {backupLimit} backups
</p>
)}
{backupLimit === 0 && (
@@ -212,22 +186,22 @@ const BackupContainer = () => {
)}
{/* Storage Usage Display */}
{backups.storage && (
{storage && (
<div className='flex flex-col gap-0.5'>
{backupStorageLimit === null ? (
<p
className='text-sm text-zinc-300 cursor-help'
title={`${backups.storage.used_mb?.toFixed(2) || 0}MB used(No Limit)`}
title={`${storage.used_mb?.toFixed(2) || 0}MB used(No Limit)`}
>
<span className='font-medium'>{formatStorage(backups.storage.used_mb)}</span> storage used
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> storage used
</p>
) : (
<>
<p
className='text-sm text-zinc-300 cursor-help'
title={`${backups.storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (${backups.storage.available_mb?.toFixed(2) || 0}MB Available)`}
title={`${storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (${storage.available_mb?.toFixed(2) || 0}MB Available)`}
>
<span className='font-medium'>{formatStorage(backups.storage.used_mb)}</span> {' '}
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> {' '}
{backupStorageLimit === null ?
"used" :
(<span className='font-medium'>of {formatStorage(backupStorageLimit)} used</span>)}
@@ -238,8 +212,8 @@ const BackupContainer = () => {
</div>
)}
</div>
{(backupLimit === null || backupLimit > backups.backupCount) &&
(!backupStorageLimit || !backups.storage?.is_over_limit) && (
{(backupLimit === null || backupLimit > backupCount) &&
(!backupStorageLimit || !storage?.is_over_limit) && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
@@ -268,39 +242,35 @@ const BackupContainer = () => {
</Formik>
)}
<Pagination data={backups} onPageSelect={setPage}>
{({ items }) =>
!items.length ? (
<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='M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{backupLimit === 0 ? 'Backups unavailable' : 'No backups found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{backupLimit === 0
? 'Backups cannot be created for this server.'
: 'Your server does not have any backups. Create one to get started.'}
</p>
</div>
{backups.length === 0 ? (
<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='M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z'
clipRule='evenodd'
/>
</svg>
</div>
) : (
<PageListContainer>
{items.map((backup) => (
<BackupRow key={backup.uuid} backup={backup} />
))}
</PageListContainer>
)
}
</Pagination>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{backupLimit === 0 ? 'Backups unavailable' : 'No backups found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{backupLimit === 0
? 'Backups cannot be created for this server.'
: 'Your server does not have any backups. Create one to get started.'}
</p>
</div>
</div>
) : (
<PageListContainer>
{backups.map((backup) => (
<BackupItem key={backup.uuid} backup={backup} />
))}
</PageListContainer>
)}
</ServerContentBlock>
);
};
@@ -314,4 +284,4 @@ const BackupContainerWrapper = () => {
);
};
export default BackupContainerWrapper;
export default BackupContainerWrapper;

View File

@@ -21,17 +21,14 @@ import HugeIconsHamburger from '@/components/elements/hugeicons/hamburger';
import http, { httpErrorToHuman } from '@/api/http';
import {
deleteServerBackup,
getServerBackupDownloadUrl,
renameServerBackup,
restoreServerBackup,
} from '@/api/server/backups';
import { ServerBackup } from '@/api/server/types';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import { useUnifiedBackups } from './useUnifiedBackups';
interface Props {
backup: ServerBackup;
@@ -45,7 +42,7 @@ const BackupContextMenu = ({ backup }: Props) => {
const [countdown, setCountdown] = useState(5);
const [newName, setNewName] = useState(backup.name);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups();
const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups();
const doDownload = () => {
setLoading(true);
@@ -56,111 +53,75 @@ const BackupContextMenu = ({ backup }: Props) => {
window.location = url;
})
.catch((error) => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => setLoading(false));
};
const doDeletion = () => {
const doDeletion = async () => {
setLoading(true);
clearFlashes('backups');
deleteServerBackup(uuid, backup.uuid)
.then(
async () =>
await mutate(
(data) => ({
...data!,
items: data!.items.filter((b) => b.uuid !== backup.uuid),
backupCount: data!.backupCount - 1,
}),
false,
),
)
.catch((error) => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setModal('');
});
try {
await deleteBackup(backup.uuid);
setLoading(false);
setModal('');
} catch (error) {
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setModal('');
}
};
const doRestorationAction = () => {
const doRestorationAction = async () => {
setLoading(true);
clearFlashes('backups');
restoreServerBackup(uuid, backup.uuid)
.then(() =>
setServerFromState((s) => ({
...s,
status: 'restoring_backup',
})),
)
.catch((error) => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => setLoading(false))
.then(() => setModal(''));
try {
await restoreBackup(backup.uuid);
// Set server status to restoring
setServerFromState((s) => ({
...s,
status: 'restoring_backup',
}));
setLoading(false);
setModal('');
} catch (error) {
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setModal('');
}
};
const onLockToggle = () => {
const onLockToggle = async () => {
if (backup.isLocked && modal !== 'unlock') {
return setModal('unlock');
}
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
.then(
async () =>
await mutate(
(data) => ({
...data!,
items: data!.items.map((b) =>
b.uuid !== backup.uuid
? b
: {
...b,
isLocked: !b.isLocked,
},
),
}),
false,
),
)
.catch((error) => alert(httpErrorToHuman(error)))
.then(() => setModal(''));
try {
await toggleBackupLock(backup.uuid);
setModal('');
} catch (error) {
alert(httpErrorToHuman(error));
}
};
const doRename = () => {
const doRename = async () => {
setLoading(true);
clearFlashes('backups');
renameServerBackup(uuid, backup.uuid, newName.trim())
.then(
async () =>
await mutate(
(data) => ({
...data!,
items: data!.items.map((b) =>
b.uuid !== backup.uuid
? b
: {
...b,
name: newName.trim(),
},
),
}),
false,
),
)
.catch((error) => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => {
setLoading(false);
setModal('');
});
try {
await renameBackup(backup.uuid, newName.trim());
setLoading(false);
setModal('');
} catch (error) {
clearAndAddHttpError({ key: 'backups', error });
setLoading(false);
setModal('');
}
};
// Countdown effect for restore modal
useEffect(() => {
let interval: NodeJS.Timeout;
if (modal === 'restore' && countdown > 0) {
@@ -173,14 +134,12 @@ const BackupContextMenu = ({ backup }: Props) => {
};
}, [modal, countdown]);
// Reset countdown when modal opens
useEffect(() => {
if (modal === 'restore') {
setCountdown(5);
}
}, [modal]);
// Reset name when modal opens
useEffect(() => {
if (modal === 'rename') {
setNewName(backup.name);

View File

@@ -0,0 +1,244 @@
import { format, formatDistanceToNow } from 'date-fns';
import Can from '@/components/elements/Can';
import Spinner from '@/components/elements/Spinner';
import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock';
import HugeIconsStorage from '@/components/elements/hugeicons/Storage';
import HugeIconsRefresh from '@/components/elements/hugeicons/Refresh';
import { PageListItem } from '@/components/elements/pages/PageList';
import { bytesToString } from '@/lib/formatters';
import useFlash from '@/plugins/useFlash';
import { useUnifiedBackups } from './useUnifiedBackups';
import BackupContextMenu from './BackupContextMenu';
export interface UnifiedBackup {
uuid: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
isSuccessful?: boolean;
isLocked: boolean;
checksum?: string;
bytes?: number;
createdAt: Date;
completedAt?: Date | null;
canRetry: boolean;
canDelete: boolean;
canDownload: boolean;
canRestore: boolean;
isLiveOnly: boolean;
isDeletion?: boolean;
}
interface Props {
backup: UnifiedBackup;
}
const BackupItem = ({ backup }: Props) => {
const { addFlash, clearFlashes } = useFlash();
const { retryBackup } = useUnifiedBackups();
const handleRetry = async () => {
if (!backup.canRetry) return;
try {
clearFlashes('backup');
await retryBackup(backup.uuid);
addFlash({
type: 'success',
title: 'Success',
key: 'backup',
message: 'Backup is being retried.',
});
} catch (error) {
addFlash({
type: 'error',
title: 'Error',
key: 'backup',
message: error instanceof Error ? error.message : 'Failed to retry backup.',
});
}
};
const getStatusIcon = () => {
const isActive = backup.status === 'running' || backup.status === 'pending';
if (isActive) {
return <Spinner size={'small'} />;
} else if (backup.isLocked) {
return <HugeIconsSquareLock className='text-red-400 w-4 h-4' fill='currentColor' />;
} else if (backup.status === 'completed' || backup.isSuccessful) {
return <HugeIconsStorage className='text-green-400 w-4 h-4' fill='currentColor' />;
} else {
return <HugeIconsStorage className='text-red-400 w-4 h-4' fill='currentColor' />;
}
};
const getStatusBadge = () => {
switch (backup.status) {
case 'failed':
return (
<span className='bg-red-500/20 border border-red-500/30 py-0.5 px-2 rounded text-red-300 text-xs font-medium'>
Failed
</span>
);
case 'pending':
return (
<span className='bg-yellow-500/20 border border-yellow-500/30 py-0.5 px-2 rounded text-yellow-300 text-xs font-medium'>
Pending
</span>
);
case 'running':
return (
<span className='bg-blue-500/20 border border-blue-500/30 py-0.5 px-2 rounded text-blue-300 text-xs font-medium'>
Running ({backup.progress}%)
</span>
);
case 'completed':
// Don't show "Completed" badge for deletion operations
if (backup.isDeletion) {
return null;
}
return backup.isLiveOnly ? (
<span className='bg-green-500/20 border border-green-500/30 py-0.5 px-2 rounded text-green-300 text-xs font-medium'>
Completed
</span>
) : null;
case 'cancelled':
return (
<span className='bg-gray-500/20 border border-gray-500/30 py-0.5 px-2 rounded text-gray-300 text-xs font-medium'>
Cancelled
</span>
);
default:
return null;
}
};
const isActive = backup.status === 'running' || backup.status === 'pending';
const showProgressBar = isActive || (backup.status === 'completed' && backup.isLiveOnly);
return (
<PageListItem>
<div className='flex items-center gap-4 w-full py-1'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{getStatusIcon()}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
{getStatusBadge()}
<h3 className='text-sm font-medium text-zinc-100 truncate'>{backup.name}</h3>
<span
className={`text-xs text-red-400 font-medium bg-red-500/10 border border-red-500/20 px-1.5 py-0.5 rounded transition-opacity ${
backup.isLocked ? 'opacity-100' : 'opacity-0'
}`}
>
Locked
</span>
</div>
{/* Progress bar for active backups */}
{showProgressBar && (
<div className='mb-2'>
<div className='flex justify-between text-xs text-zinc-400 mb-1'>
<span>{backup.message || 'Processing...'}</span>
<span>{backup.progress}%</span>
</div>
<div className='w-full bg-zinc-700 rounded-full h-1.5'>
<div
className={`h-1.5 rounded-full transition-all duration-300 ${
backup.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
}`}
style={{ width: `${backup.progress || 0}%` }}
/>
</div>
</div>
)}
{/* Error message for failed backups */}
{backup.status === 'failed' && backup.message && (
<p className='text-xs text-red-400 truncate mb-1'>{backup.message}</p>
)}
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
</div>
{/* Size info for completed backups */}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[80px]'>
{backup.completedAt && backup.isSuccessful && backup.bytes ? (
<>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
<p className='text-sm text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
</>
) : (
<>
<p className='text-xs text-transparent uppercase tracking-wide'>Size</p>
<p className='text-sm text-transparent font-medium'>-</p>
</>
)}
</div>
{/* Created time */}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[120px]'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Created</p>
<p
className='text-sm text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
</div>
{/* Actions */}
<div className='flex-shrink-0 flex items-center gap-2'>
{/* Retry button for failed backups */}
{backup.status === 'failed' && backup.canRetry && (
<Can action='backup.create'>
<button
onClick={handleRetry}
className='p-1.5 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20 transition-colors'
title='Retry backup'
>
<HugeIconsRefresh className='w-4 h-4' />
</button>
</Can>
)}
{/* Context menu for actionable backups */}
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
{!isActive && !backup.isLiveOnly && (
<BackupContextMenu
backup={{
uuid: backup.uuid,
name: backup.name,
isSuccessful: backup.isSuccessful || false,
isLocked: backup.isLocked,
checksum: backup.checksum || '',
bytes: backup.bytes || 0,
createdAt: backup.createdAt,
completedAt: backup.completedAt,
canRetry: backup.canRetry,
jobStatus: backup.status,
jobProgress: backup.progress,
jobMessage: backup.message,
jobId: '',
jobError: null,
object: 'backup' as const
}}
/>
)}
</Can>
</div>
</div>
</PageListItem>
);
};
export default BackupItem;

View File

@@ -1,235 +0,0 @@
import { format, formatDistanceToNow } from 'date-fns';
import Can from '@/components/elements/Can';
import Spinner from '@/components/elements/Spinner';
import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock';
import HugeIconsStorage from '@/components/elements/hugeicons/Storage';
import HugeIconsX from '@/components/elements/hugeicons/X';
import HugeIconsRefresh from '@/components/elements/hugeicons/Refresh';
import { PageListItem } from '@/components/elements/pages/PageList';
import { SocketEvent } from '@/components/server/events';
import { bytesToString } from '@/lib/formatters';
import { ServerBackup } from '@/api/server/types';
import getServerBackups from '@/api/swr/getServerBackups';
import { cancelBackup, retryBackup } from '@/api/server/backups';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import useFlash from '@/plugins/useFlash';
import { useBackupStatus } from './useBackupStatus';
import BackupContextMenu from './BackupContextMenu';
interface Props {
backup: ServerBackup;
}
const BackupRow = ({ backup }: Props) => {
const { mutate } = getServerBackups();
const { addFlash, clearFlashes } = useFlash();
const { status: liveStatus } = useBackupStatus(backup.server?.uuid || '', backup, {
enabled: backup.isInProgress,
});
// Use live status if available, otherwise use backup data
const currentStatus = liveStatus || {
job_id: backup.jobId,
status: backup.jobStatus,
progress: backup.jobProgress,
message: backup.jobMessage,
error: backup.jobError,
can_cancel: backup.canCancel,
can_retry: backup.canRetry,
};
const handleCancel = async () => {
if (!backup.server?.uuid || !currentStatus.can_cancel) return;
try {
clearFlashes('backup');
await cancelBackup(backup.server.uuid, backup.uuid);
addFlash({
type: 'success',
key: 'backup',
message: 'Backup has been cancelled.',
});
await mutate();
} catch (error) {
addFlash({
type: 'error',
key: 'backup',
message: error instanceof Error ? error.message : 'Failed to cancel backup.',
});
}
};
const handleRetry = async () => {
if (!backup.server?.uuid || !currentStatus.can_retry) return;
try {
clearFlashes('backup');
await retryBackup(backup.server.uuid, backup.uuid);
addFlash({
type: 'success',
key: 'backup',
message: 'Backup is being retried.',
});
await mutate();
} catch (error) {
addFlash({
type: 'error',
key: 'backup',
message: error instanceof Error ? error.message : 'Failed to retry backup.',
});
}
};
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => {
try {
await mutate();
} catch (e) {
console.warn(e);
}
});
const getStatusIcon = () => {
if (backup.isInProgress) {
return <Spinner size={'small'} />;
} else if (backup.isLocked) {
return <HugeIconsSquareLock className='text-red-400 w-4 h-4' fill='currentColor' />;
} else if (backup.isSuccessful) {
return <HugeIconsStorage className='text-green-400 w-4 h-4' fill='currentColor' />;
} else {
return <HugeIconsStorage className='text-red-400 w-4 h-4' fill='currentColor' />;
}
};
const getStatusBadge = () => {
if (currentStatus.status === 'failed') {
return (
<span className='bg-red-500/20 border border-red-500/30 py-0.5 px-2 rounded text-red-300 text-xs font-medium'>
Failed
</span>
);
} else if (currentStatus.status === 'pending') {
return (
<span className='bg-yellow-500/20 border border-yellow-500/30 py-0.5 px-2 rounded text-yellow-300 text-xs font-medium'>
Pending
</span>
);
} else if (currentStatus.status === 'running') {
return (
<span className='bg-blue-500/20 border border-blue-500/30 py-0.5 px-2 rounded text-blue-300 text-xs font-medium'>
Running ({currentStatus.progress}%)
</span>
);
} else if (currentStatus.status === 'cancelled') {
return (
<span className='bg-gray-500/20 border border-gray-500/30 py-0.5 px-2 rounded text-gray-300 text-xs font-medium'>
Cancelled
</span>
);
}
return null;
};
return (
<PageListItem>
<div className='flex items-center gap-4 w-full py-1'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{getStatusIcon()}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
{getStatusBadge()}
<h3 className='text-sm font-medium text-zinc-100 truncate'>{backup.name}</h3>
<span
className={`text-xs text-red-400 font-medium bg-red-500/10 border border-red-500/20 px-1.5 py-0.5 rounded transition-opacity ${
backup.isLocked ? 'opacity-100' : 'opacity-0'
}`}
>
Locked
</span>
</div>
{/* Progress bar for running backups */}
{backup.isInProgress && (
<div className='mb-2'>
<div className='flex justify-between text-xs text-zinc-400 mb-1'>
<span>{currentStatus.message || 'Processing...'}</span>
<span>{currentStatus.progress}%</span>
</div>
<div className='w-full bg-zinc-700 rounded-full h-1.5'>
<div
className='bg-blue-500 h-1.5 rounded-full transition-all duration-300'
style={{ width: `${currentStatus.progress}%` }}
/>
</div>
</div>
)}
{/* Error message for failed backups */}
{currentStatus.status === 'failed' && currentStatus.error && (
<p className='text-xs text-red-400 truncate mb-1'>{currentStatus.error}</p>
)}
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
</div>
{backup.completedAt !== null && backup.isSuccessful && (
<div className='hidden sm:block flex-shrink-0 text-right'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
<p className='text-sm text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
</div>
)}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[120px]'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Created</p>
<p
className='text-sm text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
</div>
<div className='flex-shrink-0 flex items-center gap-2'>
{/* Cancel button for running backups */}
{backup.isInProgress && currentStatus.can_cancel && (
<Can action='backup.delete'>
<button
onClick={handleCancel}
className='p-1.5 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-colors'
title='Cancel backup'
>
<HugeIconsX className='w-4 h-4' />
</button>
</Can>
)}
{/* Retry button for failed backups */}
{currentStatus.status === 'failed' && currentStatus.can_retry && (
<Can action='backup.create'>
<button
onClick={handleRetry}
className='p-1.5 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20 transition-colors'
title='Retry backup'
>
<HugeIconsRefresh className='w-4 h-4' />
</button>
</Can>
)}
{/* Context menu for completed backups */}
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
{!backup.isInProgress ? <BackupContextMenu backup={backup} /> : null}
</Can>
</div>
</div>
</PageListItem>
);
};
export default BackupRow;

View File

@@ -1,162 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ServerBackup } from '@/api/server/types';
import getBackupStatus, { BackupJobStatus } from '@/api/server/backups/getBackupStatus';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
interface UseBackupStatusReturn {
status: BackupJobStatus | null;
loading: boolean;
error: string | null;
pollNow: () => void;
stopPolling: () => void;
startPolling: () => void;
}
export const useBackupStatus = (
serverUuid: string,
backup: ServerBackup,
options: {
enabled?: boolean;
pollInterval?: number;
stopWhenComplete?: boolean;
} = {}
): UseBackupStatusReturn => {
const {
enabled = true,
pollInterval = 3000, // 3 seconds
stopWhenComplete = true,
} = options;
const [status, setStatus] = useState<BackupJobStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const mountedRef = useRef(true);
// Determine if we should be actively polling
const shouldPoll = enabled &&
backup.isInProgress &&
backup.jobId &&
isPolling;
const pollStatus = useCallback(async () => {
if (!backup.jobId || !mountedRef.current) return;
try {
setLoading(true);
setError(null);
const newStatus = await getBackupStatus(serverUuid, backup.uuid);
if (mountedRef.current) {
setStatus(newStatus);
// Stop polling if job is complete and option is enabled
if (stopWhenComplete && !['pending', 'running'].includes(newStatus.status)) {
setIsPolling(false);
}
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : 'Failed to fetch backup status');
// Stop polling on error
setIsPolling(false);
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, [serverUuid, backup.uuid, backup.jobId, stopWhenComplete]);
const pollNow = useCallback(() => {
pollStatus();
}, [pollStatus]);
const stopPolling = useCallback(() => {
setIsPolling(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const startPolling = useCallback(() => {
if (backup.isInProgress && backup.jobId) {
setIsPolling(true);
}
}, [backup.isInProgress, backup.jobId]);
// Set up polling interval
useEffect(() => {
if (shouldPoll) {
// Poll immediately when starting
pollStatus();
// Set up interval for future polls
intervalRef.current = setInterval(pollStatus, pollInterval);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [shouldPoll, pollStatus, pollInterval]);
// Auto-start polling for in-progress backups
useEffect(() => {
if (enabled && backup.isInProgress) {
startPolling();
}
}, [enabled, backup.isInProgress, startPolling]);
// Listen for WebSocket events for real-time updates
useWebsocketEvent('backup.status', (data: any) => {
if (data.backup_uuid === backup.uuid && mountedRef.current) {
setStatus({
job_id: data.job_id,
status: data.status,
progress: data.progress,
message: data.message,
error: data.error,
is_successful: data.is_successful,
can_cancel: data.can_cancel,
can_retry: data.can_retry,
started_at: data.started_at,
last_updated_at: data.last_updated_at,
completed_at: data.completed_at,
});
// Stop polling if job completed via WebSocket
if (stopWhenComplete && !['pending', 'running'].includes(data.status)) {
setIsPolling(false);
}
}
});
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
stopPolling();
};
}, [stopPolling]);
return {
status,
loading,
error,
pollNow,
stopPolling,
startPolling,
};
};

View File

@@ -0,0 +1,240 @@
import { useCallback, useState } from 'react';
import { SocketEvent } from '@/components/server/events';
import { ServerContext } from '@/state/server';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import getServerBackups from '@/api/swr/getServerBackups';
import { UnifiedBackup } from './BackupItem';
export const useUnifiedBackups = () => {
const { data: backups, error, isValidating, mutate } = getServerBackups();
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const [liveProgress, setLiveProgress] = useState<Record<string, {
status: string;
progress: number;
message: string;
canRetry: boolean;
lastUpdated: string;
completed: boolean;
isDeletion: boolean;
backupName?: string;
}>>({});
const handleBackupStatus = useCallback((rawData: any) => {
let data;
try {
if (typeof rawData === 'string') {
data = JSON.parse(rawData);
} else {
data = rawData;
}
} catch (error) {
return;
}
const backup_uuid = data?.backup_uuid;
if (!backup_uuid) {
return;
}
const {
status,
progress,
message,
timestamp,
operation,
error: errorMsg,
adapter,
name,
} = data;
const can_retry = status === 'failed' && operation === 'create';
const last_updated_at = timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
const isDeletionOperation = operation === 'delete' || data.deleted === true;
setLiveProgress(prevProgress => {
const currentState = prevProgress[backup_uuid];
const newProgress = progress || 0;
const isCompleted = status === 'completed' && newProgress === 100;
const displayMessage = errorMsg ? `${message || 'Operation failed'}: ${errorMsg}` : (message || '');
if (currentState?.completed && !isCompleted) {
return prevProgress;
}
if (currentState && !isCompleted && currentState.lastUpdated >= last_updated_at && currentState.progress >= newProgress) {
return prevProgress;
}
return {
...prevProgress,
[backup_uuid]: {
status,
progress: newProgress,
message: displayMessage,
canRetry: can_retry || false,
lastUpdated: last_updated_at,
completed: isCompleted,
isDeletion: isDeletionOperation,
backupName: name || currentState?.backupName,
}
};
});
if (status === 'completed' && progress === 100) {
mutate();
if (isDeletionOperation) {
setTimeout(() => {
setLiveProgress(prev => {
const updated = { ...prev };
delete updated[backup_uuid];
return updated;
});
}, 500);
} else {
const checkForBackup = async (attempts = 0) => {
if (attempts > 10) {
setLiveProgress(prev => {
const updated = { ...prev };
delete updated[backup_uuid];
return updated;
});
return;
}
// Force fresh data
await mutate();
const currentBackups = await mutate();
const backupExists = currentBackups?.items?.some(b => b.uuid === backup_uuid);
if (backupExists) {
setLiveProgress(prev => {
const updated = { ...prev };
delete updated[backup_uuid];
return updated;
});
} else {
setTimeout(() => checkForBackup(attempts + 1), 1000);
}
};
setTimeout(() => checkForBackup(), 1000);
}
}
}, [mutate]);
useWebsocketEvent(SocketEvent.BACKUP_STATUS, handleBackupStatus);
const createBackup = useCallback(async (name: string, ignored: string, isLocked: boolean) => {
const { default: createServerBackup } = await import('@/api/server/backups/createServerBackup');
const result = await createServerBackup(uuid, { name, ignored, isLocked });
mutate();
return result;
}, [uuid, mutate]);
const deleteBackup = useCallback(async (backupUuid: string) => {
const { deleteServerBackup } = await import('@/api/server/backups');
const result = await deleteServerBackup(uuid, backupUuid);
mutate();
return result;
}, [uuid, mutate]);
const retryBackup = useCallback(async (backupUuid: string) => {
const { retryBackup: retryBackupApi } = await import('@/api/server/backups');
await retryBackupApi(uuid, backupUuid);
mutate();
}, [uuid, mutate]);
const restoreBackup = useCallback(async (backupUuid: string) => {
const { restoreServerBackup } = await import('@/api/server/backups');
const result = await restoreServerBackup(uuid, backupUuid);
mutate();
return result;
}, [uuid, mutate]);
const renameBackup = useCallback(async (backupUuid: string, newName: string) => {
const http = (await import('@/api/http')).default;
await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/rename`, { name: newName });
mutate();
}, [uuid, mutate]);
const toggleBackupLock = useCallback(async (backupUuid: string) => {
const http = (await import('@/api/http')).default;
await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/lock`);
mutate();
}, [uuid, mutate]);
const unifiedBackups: UnifiedBackup[] = [];
if (backups?.items) {
for (const backup of backups.items) {
const live = liveProgress[backup.uuid];
unifiedBackups.push({
uuid: backup.uuid,
name: live?.backupName || backup.name,
status: live ? live.status as any : (backup.isSuccessful ? 'completed' : 'failed'),
progress: live ? live.progress : (backup.isSuccessful ? 100 : 0),
message: live ? live.message : (backup.isSuccessful ? 'Completed' : 'Failed'),
isSuccessful: backup.isSuccessful,
isLocked: backup.isLocked,
checksum: backup.checksum,
bytes: backup.bytes,
createdAt: backup.createdAt,
completedAt: backup.completedAt,
canRetry: live ? live.canRetry : backup.canRetry,
canDelete: live ? false : true,
canDownload: backup.isSuccessful && !live,
canRestore: backup.isSuccessful && !live,
isLiveOnly: false,
isDeletion: live?.isDeletion || false,
});
}
}
// Add live-only backups (new operations not yet in SWR)
for (const [backupUuid, live] of Object.entries(liveProgress)) {
const existsInSwr = unifiedBackups.some(b => b.uuid === backupUuid);
if (!existsInSwr && !live.isDeletion) {
unifiedBackups.push({
uuid: backupUuid,
name: live.backupName || live.message || 'Processing...',
status: live.status as any,
progress: live.progress,
message: live.message,
isSuccessful: false,
isLocked: false,
checksum: undefined,
bytes: undefined,
createdAt: new Date(),
completedAt: null,
canRetry: live.canRetry,
canDelete: false,
canDownload: false,
canRestore: false,
isLiveOnly: true,
isDeletion: false,
});
}
}
unifiedBackups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return {
backups: unifiedBackups,
backupCount: backups?.backupCount || 0,
storage: backups?.storage,
error,
isValidating,
createBackup,
deleteBackup,
retryBackup,
restoreBackup,
renameBackup,
toggleBackupLock,
refresh: () => mutate(),
};
};

View File

@@ -10,6 +10,7 @@ export enum SocketEvent {
TRANSFER_LOGS = 'transfer logs',
TRANSFER_STATUS = 'transfer status',
BACKUP_COMPLETED = 'backup completed',
BACKUP_STATUS = 'backup.status',
BACKUP_RESTORE_COMPLETED = 'backup restore completed',
}

View File

@@ -4,7 +4,7 @@ import { SocketEvent } from '@/components/server/events';
import { ServerContext } from '@/state/server';
const useWebsocketEvent = (event: SocketEvent, callback: (data: string) => void) => {
const useWebsocketEvent = (event: SocketEvent, callback: (data: any) => void) => {
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
const savedCallback = useRef<any>(null);
@@ -13,7 +13,7 @@ const useWebsocketEvent = (event: SocketEvent, callback: (data: string) => void)
}, [callback]);
return useEffect(() => {
const eventListener = (event: SocketEvent) => savedCallback.current(event);
const eventListener = (data: any) => savedCallback.current(data);
if (connected && instance) {
instance.addListener(event, eventListener);
}

View File

@@ -137,21 +137,27 @@ Route::group([
Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']);
});
// Elytra Jobs API
Route::group(['prefix' => '/jobs'], function () {
Route::get('/', [Client\Servers\ElytraJobsController::class, 'index']);
Route::post('/', [Client\Servers\ElytraJobsController::class, 'create'])
->middleware('server.operation.rate-limit');
Route::get('/{jobId}', [Client\Servers\ElytraJobsController::class, 'show']);
Route::delete('/{jobId}', [Client\Servers\ElytraJobsController::class, 'cancel']);
});
// Backups API
Route::group(['prefix' => '/backups'], function () {
Route::get('/', [Client\Servers\BackupController::class, 'index']);
Route::post('/', [Client\Servers\BackupController::class, 'store'])
Route::get('/', [Client\Servers\BackupsController::class, 'index']);
Route::post('/', [Client\Servers\BackupsController::class, 'store'])
->middleware('server.operation.rate-limit');
Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']);
Route::get('/{backup}/status', [Client\Servers\BackupController::class, 'status']);
Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']);
Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::post('/{backup}/rename', [Client\Servers\BackupController::class, 'rename']);
Route::post('/{backup}/cancel', [Client\Servers\BackupController::class, 'cancel']);
Route::post('/{backup}/retry', [Client\Servers\BackupController::class, 'retry'])
Route::get('/{backup}', [Client\Servers\BackupsController::class, 'show']);
Route::get('/{backup}/download', [Client\Servers\BackupsController::class, 'download']);
Route::post('/{backup}/restore', [Client\Servers\BackupsController::class, 'restore'])
->middleware('server.operation.rate-limit');
Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore'])
->middleware('server.operation.rate-limit');
Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']);
Route::post('/{backup}/rename', [Client\Servers\BackupsController::class, 'rename']);
Route::post('/{backup}/lock', [Client\Servers\BackupsController::class, 'toggleLock']);
Route::delete('/{backup}', [Client\Servers\BackupsController::class, 'destroy']);
});
Route::group(['prefix' => '/startup'], function () {

View File

@@ -7,7 +7,7 @@ use Pterodactyl\Http\Controllers\Api\Remote\SftpAuthenticationController;
use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupDeleteController;
use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupSizeController;
use Pterodactyl\Http\Controllers\Api\Remote\Backups\BackupStatusController;
use Pterodactyl\Http\Controllers\Api\Remote\ElytraJobCompletionController;
use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerDetailsController;
use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerInstallController;
use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerTransferController;
@@ -35,7 +35,9 @@ Route::group(['prefix' => '/servers/{uuid}'], function () {
Route::group(['prefix' => '/backups'], function () {
Route::get('/{backup}', BackupRemoteUploadController::class);
Route::post('/{backup}', [BackupStatusController::class, 'index']);
Route::post('/{backup}/restore', [BackupStatusController::class, 'restore']);
Route::delete('/{backup}', BackupDeleteController::class);
});
Route::group(['prefix' => '/elytra-jobs'], function () {
Route::put('/{jobId}', [ElytraJobCompletionController::class, 'update']);
});

View File

@@ -89,7 +89,7 @@ cat >/etc/nginx/sites-available/pterodactyl.conf <<'EOF'
server {
listen 3000;
server_name localhost;
root /var/www/pterodactyl/public;
root /home/vagrant/pyrodactyl/public;
index index.php index.html;
charset utf-8;
location / {
@@ -163,11 +163,7 @@ if ! command -v composer >/dev/null 2>&1; then
rm -f /tmp/composer-setup.php
fi
log Preparing /var/www/pterodactyl
chown -R vagrant:vagrant /var/www/pterodactyl
chmod -R u=rwX,g=rX,o=rX /var/www/pterodactyl
pushd /var/www/pterodactyl >/dev/null
pushd /home/vagrant/pyrodactyl >/dev/null
[ -f .env ] || cp .env.example .env
sudo -u vagrant mkdir -p storage/framework/cache
@@ -177,13 +173,13 @@ sudo -u vagrant mkdir -p storage/logs
sudo -u vagrant mkdir -p bootstrap/cache
log Composer install
sudo -u vagrant -H bash -lc 'cd /var/www/pterodactyl && composer install --no-dev --optimize-autoloader'
sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && composer install --no-dev --optimize-autoloader'
chmod -R 755 storage bootstrap/cache
setfacl -Rm u:vagrant:rwX storage bootstrap/cache >/dev/null 2>&1 || true
chown -R vagrant:vagrant storage bootstrap/cache
# helper (append --no-interaction automatically; avoid quoted, spaced values)
artisan() { sudo -u vagrant -H bash -lc "cd /var/www/pterodactyl && php artisan $* --no-interaction"; }
artisan() { sudo -u vagrant -H bash -lc "cd /home/vagrant/pyrodactyl && php artisan $* --no-interaction"; }
# generate key only if empty/missing
if ! grep -qE '^APP_KEY=base64:.+' .env; then
@@ -246,7 +242,7 @@ popd >/dev/null
log Installing Laravel scheduler cron
( crontab -l 2>/dev/null | grep -v 'pterodactyl/artisan schedule:run' || true
echo '* * * * * php /var/www/pterodactyl/artisan schedule:run >> /dev/null 2>&1'
echo '* * * * * php /home/vagrant/pyrodactyl/artisan schedule:run >> /dev/null 2>&1'
) | crontab -
log Creating pteroq.service
@@ -258,9 +254,9 @@ Requires=redis-server.service
[Service]
User=vagrant
Group=vagrant
WorkingDirectory=/var/www/pterodactyl
WorkingDirectory=/home/vagrant/pyrodactyl
Restart=always
ExecStart=/usr/bin/php /var/www/pterodactyl/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3
ExecStart=/usr/bin/php /home/vagrant/pyrodactyl/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3
StartLimitBurst=30
RestartSec=5s
[Install]
@@ -305,7 +301,7 @@ else
fi
if [ ! -f /etc/pterodactyl/config.yml ]; then
sudo -u vagrant -H bash -lc 'cd /var/www/pterodactyl && php artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true
sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && php artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true
else
log "Elytra config already exists, skipping"
fi
@@ -408,7 +404,7 @@ EOF
' || true
# Configure MinIO in .env for rustic_s3 backups
pushd /var/www/pterodactyl >/dev/null
pushd /home/vagrant/pyrodactyl >/dev/null
if [ -f .env ]; then
# Set rustic_s3 backup configuration for MinIO
sed -i '/^APP_BACKUP_DRIVER=/c\APP_BACKUP_DRIVER=rustic_s3' .env
@@ -558,7 +554,7 @@ EOF
systemctl enable --now mailpit
# Configure Mailpit in .env for mail testing
pushd /var/www/pterodactyl >/dev/null
pushd /home/vagrant/pyrodactyl >/dev/null
if [ -f .env ]; then
# Set mail configuration for Mailpit
if grep -q "^MAIL_MAILER=" .env; then
@@ -615,7 +611,7 @@ systemctl restart php8.4-fpm
systemctl reload nginx || systemctl restart nginx || true
log Generating Application API Key
pushd /var/www/pterodactyl >/dev/null
pushd /home/vagrant/pyrodactyl >/dev/null
API_KEY_RESULT=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute="
use Pterodactyl\Models\ApiKey;
use Pterodactyl\Models\User;
@@ -688,7 +684,7 @@ if [ -n "${API_KEY:-}" ]; then
EOF
# Check if a Minecraft server already exists using Laravel
pushd /var/www/pterodactyl >/dev/null
pushd /home/vagrant/pyrodactyl >/dev/null
EXISTING_SERVER_CHECK=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute="
use Pterodactyl\Models\Server;
\$server = Server::where(\"name\", \"Minecraft Vanilla Dev Server\")->first();