mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
feat: backups v2 + elytra jobs
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
||||
/vendor
|
||||
|
||||
# Elytra binary
|
||||
elytra
|
||||
!elytra/
|
||||
|
||||
*.DS_Store*
|
||||
!.env.ci
|
||||
!.env.example
|
||||
|
||||
13
Vagrantfile
vendored
13
Vagrantfile
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
24
app/Contracts/Elytra/Job.php
Normal file
24
app/Contracts/Elytra/Job.php
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
219
app/Http/Controllers/Api/Client/Servers/BackupsController.php
Normal file
219
app/Http/Controllers/Api/Client/Servers/BackupsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
126
app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php
Normal file
126
app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php
Normal file
34
app/Http/Requests/Api/Remote/ElytraJobCompleteRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Api/Remote/ReportJobCompleteRequest.php
Normal file
31
app/Http/Requests/Api/Remote/ReportJobCompleteRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
107
app/Models/ElytraJob.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
165
app/Repositories/Elytra/ElytraRepository.php
Normal file
165
app/Repositories/Elytra/ElytraRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
303
app/Services/Elytra/ElytraJobService.php
Normal file
303
app/Services/Elytra/ElytraJobService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
345
app/Services/Elytra/Jobs/BackupJob.php
Normal file
345
app/Services/Elytra/Jobs/BackupJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
3
resources/scripts/api/server/types.d.ts
vendored
3
resources/scripts/api/server/types.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,5 +47,8 @@ export default () => {
|
||||
storage: data.meta.storage,
|
||||
limits: data.meta.limits,
|
||||
};
|
||||
}, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 || ''),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
244
resources/scripts/components/server/backups/BackupItem.tsx
Normal file
244
resources/scripts/components/server/backups/BackupItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
240
resources/scripts/components/server/backups/useUnifiedBackups.ts
Normal file
240
resources/scripts/components/server/backups/useUnifiedBackups.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user