feat: fully functional deduplicated backups

This commit is contained in:
Elizabeth
2025-10-15 10:35:18 -05:00
parent 5bf6641159
commit eaefd5723f
29 changed files with 1515 additions and 312 deletions

View File

@@ -8,11 +8,14 @@ use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Support\Facades\Crypt;
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 Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
@@ -22,6 +25,7 @@ class BackupsController extends ClientApiController
private ElytraJobService $elytraJobService,
private DownloadLinkService $downloadLinkService,
private BackupTransformer $transformer,
private Google2FA $google2FA,
) {
parent::__construct();
}
@@ -38,12 +42,38 @@ class BackupsController extends ClientApiController
->orderByRaw('is_locked DESC, created_at DESC')
->paginate($limit);
$rusticBackupSum = $server->backups()
->where('is_successful', true)
->whereIn('disk', [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3])
->sum('bytes');
$rusticSumMb = round($rusticBackupSum / 1024 / 1024, 2);
$legacyBackupSum = $server->backups()
->where('is_successful', true)
->whereNotIn('disk', [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3])
->sum('bytes');
$legacyUsageMb = round($legacyBackupSum / 1024 / 1024, 2);
$repositoryUsageMb = round($server->repository_backup_bytes / 1024 / 1024, 2);
$overheadMb = max(0, $repositoryUsageMb - $rusticSumMb);
$totalUsedMb = $legacyUsageMb + $repositoryUsageMb;
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),
'used_mb' => $totalUsedMb,
'legacy_usage_mb' => $legacyUsageMb,
'repository_usage_mb' => $repositoryUsageMb,
'rustic_backup_sum_mb' => $rusticSumMb,
'overhead_mb' => $overheadMb,
'overhead_percent' => $rusticSumMb > 0 ? round(($overheadMb / $rusticSumMb) * 100, 1) : 0,
'needs_pruning' => $overheadMb > $rusticSumMb * 0.1,
'limit_mb' => null,
'has_limit' => false,
'usage_percentage' => null,
@@ -103,12 +133,35 @@ class BackupsController extends ClientApiController
throw new AuthorizationException();
}
// Only require password/2FA for web session requests, not API keys
if (!$request->user()->currentAccessToken()) {
// Require password confirmation for this destructive operation
$password = $request->input('password');
if (empty($password) || !password_verify($password, $request->user()->password)) {
throw new BadRequestHttpException('The password provided was not valid.');
}
// If user has 2FA enabled, require TOTP code
if ($request->user()->use_totp) {
$totpCode = $request->input('totp_code');
if (empty($totpCode)) {
throw new BadRequestHttpException('Two-factor authentication code is required.');
}
$secret = Crypt::decrypt($request->user()->totp_secret);
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
}
}
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_delete',
[
'operation' => 'delete',
'backup_uuid' => $backup->uuid,
'snapshot_id' => $backup->snapshot_id,
],
$request->user()
);
@@ -127,12 +180,35 @@ class BackupsController extends ClientApiController
throw new AuthorizationException();
}
// Only require password/2FA for web session requests, not API keys
if (!$request->user()->currentAccessToken()) {
// Require password confirmation for this destructive operation
$password = $request->input('password');
if (empty($password) || !password_verify($password, $request->user()->password)) {
throw new BadRequestHttpException('The password provided was not valid.');
}
// If user has 2FA enabled, require TOTP code
if ($request->user()->use_totp) {
$totpCode = $request->input('totp_code');
if (empty($totpCode)) {
throw new BadRequestHttpException('Two-factor authentication code is required.');
}
$secret = Crypt::decrypt($request->user()->totp_secret);
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
}
}
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_restore',
[
'operation' => 'restore',
'backup_uuid' => $backup->uuid,
'snapshot_id' => $backup->snapshot_id,
'truncate_directory' => $request->boolean('truncate_directory'),
],
$request->user()
@@ -216,4 +292,146 @@ class BackupsController extends ClientApiController
return new JsonResponse($transformed);
}
public function deleteAll(Request $request, Server $server): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
// Only require password/2FA for web session requests, not API keys
if (!$request->user()->currentAccessToken()) {
// Require password confirmation for this destructive operation
$password = $request->input('password');
if (empty($password) || !password_verify($password, $request->user()->password)) {
throw new BadRequestHttpException('The password provided was not valid.');
}
// If user has 2FA enabled, require TOTP code
if ($request->user()->use_totp) {
$totpCode = $request->input('totp_code');
if (empty($totpCode)) {
throw new BadRequestHttpException('Two-factor authentication code is required.');
}
$secret = Crypt::decrypt($request->user()->totp_secret);
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
}
}
}
$backupCount = $server->backups()->count();
if ($backupCount === 0) {
return new JsonResponse([
'error' => 'No backups to delete.',
], 400);
}
$result = $this->elytraJobService->submitJob(
$server,
'backup_delete_all',
[
'operation' => 'delete_all',
],
$request->user()
);
Activity::event('backup:delete_all')
->subject($server)
->property(['backup_count' => $backupCount, 'job_id' => $result['job_id']])
->log();
return new JsonResponse($result);
}
public function bulkDelete(Request $request, Server $server): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
// Only require password/2FA for web session requests, not API keys
if (!$request->user()->currentAccessToken()) {
// Require password confirmation for this destructive operation
$password = $request->input('password');
if (empty($password) || !password_verify($password, $request->user()->password)) {
throw new BadRequestHttpException('The password provided was not valid.');
}
// If user has 2FA enabled, require TOTP code
if ($request->user()->use_totp) {
$totpCode = $request->input('totp_code');
if (empty($totpCode)) {
throw new BadRequestHttpException('Two-factor authentication code is required.');
}
$secret = Crypt::decrypt($request->user()->totp_secret);
if (!$this->google2FA->verifyKey($secret, $totpCode)) {
throw new BadRequestHttpException('The two-factor authentication code provided was not valid.');
}
}
}
// Validate backup_uuids
$backupUuids = $request->input('backup_uuids', []);
if (empty($backupUuids) || !is_array($backupUuids)) {
return new JsonResponse([
'error' => 'No backups specified for deletion.',
], 400);
}
// Limit to reasonable number of backups at once
if (count($backupUuids) > 50) {
return new JsonResponse([
'error' => 'Cannot delete more than 50 backups at once. Use Delete All for larger operations.',
], 400);
}
// Verify all backups belong to this server
$backups = $server->backups()->whereIn('uuid', $backupUuids)->get();
if ($backups->count() !== count($backupUuids)) {
return new JsonResponse([
'error' => 'One or more backups not found or do not belong to this server.',
], 404);
}
// Submit individual delete jobs for each backup
$jobIds = [];
foreach ($backups as $backup) {
try {
$result = $this->elytraJobService->submitJob(
$server,
'backup_delete',
[
'operation' => 'delete',
'backup_uuid' => $backup->uuid,
'adapter_type' => $backup->getElytraAdapterType(),
'snapshot_id' => $backup->snapshot_id,
'checksum' => $backup->checksum,
],
$request->user()
);
$jobIds[] = $result['job_id'];
} catch (\Exception $e) {
// Log error but continue with other backups
\Log::error("Failed to submit delete job for backup {$backup->uuid}", [
'error' => $e->getMessage(),
]);
}
}
Activity::event('backup:bulk_delete')
->subject($server)
->property(['backup_count' => count($backupUuids), 'job_ids' => $jobIds])
->log();
return new JsonResponse([
'message' => 'Bulk delete jobs submitted successfully',
'job_count' => count($jobIds),
'backup_count' => count($backupUuids),
]);
}
}

View File

@@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Log;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Services\ServerOperations\ServerOperationService;
use Pterodactyl\Services\ServerOperations\ServerStateValidationService;
@@ -32,7 +31,6 @@ class SettingsController extends ClientApiController
private ServerRepository $repository,
private ReinstallServerService $reinstallServerService,
private StartupModificationService $startupModificationService,
private InitiateBackupService $backupService,
private DaemonFileRepository $fileRepository,
private ServerOperationService $operationService,
private ServerStateValidationService $validationService,

View File

@@ -28,6 +28,7 @@ class ElytraJobCompleteRequest extends FormRequest
'size' => 'nullable|integer|min:0',
'snapshot_id' => 'nullable|string',
'adapter' => 'nullable|string',
'repository_size' => 'nullable|integer|min:0',
'result_data' => 'nullable|array',
];
}

View File

@@ -10,7 +10,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Services\Elytra\ElytraJobService;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
@@ -21,9 +21,6 @@ class RunTaskJob extends Job implements ShouldQueue
use InteractsWithQueue;
use SerializesModels;
/**
* RunTaskJob constructor.
*/
public function __construct(public Task $task, public bool $manualRun = false)
{
$this->queue = 'standard';
@@ -36,7 +33,7 @@ class RunTaskJob extends Job implements ShouldQueue
*/
public function handle(
DaemonCommandRepository $commandRepository,
InitiateBackupService $backupService,
ElytraJobService $elytraJobService,
DaemonPowerRepository $powerRepository,
) {
// Do not process a task that is not set to active, unless it's been manually triggered.
@@ -70,7 +67,22 @@ class RunTaskJob extends Job implements ShouldQueue
case Task::ACTION_BACKUP:
// Mark the task as running before initiating the backup to prevent duplicate runs
$this->task->update(['is_processing' => true]);
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
$ignoredFiles = !empty($this->task->payload) ? explode(PHP_EOL, $this->task->payload) : [];
$elytraJobService->submitJob(
$server,
'backup_create',
[
'operation' => 'create',
'adapter' => config('backups.default', 'elytra'),
'ignored' => implode("\n", $ignoredFiles),
'name' => 'Scheduled Backup - ' . now()->format('Y-m-d H:i'),
'is_automatic' => true,
],
auth()->user() ?? $server->user
);
$this->task->update(['is_processing' => false]);
break;
default:

View File

@@ -7,7 +7,6 @@ use Carbon\Carbon;
use Pterodactyl\Jobs\Job;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
@@ -18,7 +17,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\ServerOperation;
use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Services\Elytra\ElytraJobService;
use Pterodactyl\Services\Servers\StartupModificationService;
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
use Pterodactyl\Exceptions\Service\Backup\BackupFailedException;
@@ -61,7 +60,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
* Execute the egg change job.
*/
public function handle(
InitiateBackupService $backupService,
ElytraJobService $elytraJobService,
ReinstallServerService $reinstallServerService,
StartupModificationService $startupModificationService,
DaemonFileRepository $fileRepository,
@@ -88,14 +87,17 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
->with(['variables', 'nest'])
->findOrFail($this->eggId);
$backup = null;
$backupJobId = null;
if ($this->shouldBackup) {
$backup = $this->createBackup($backupService, $operation);
$backupJobId = $this->createBackup($elytraJobService, $operation);
}
if ($this->shouldWipe) {
$this->wipeServerFiles($fileRepository, $operation, $backup);
// If we created a backup, wait for it to complete before wiping
if ($backupJobId) {
$this->waitForJobCompletion($elytraJobService, $backupJobId, $operation);
}
$this->wipeServerFiles($fileRepository, $operation);
}
$this->applyServerChanges($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService);
@@ -113,56 +115,100 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
/**
* Create backup before proceeding with changes.
*/
private function createBackup(InitiateBackupService $backupService, ServerOperation $operation): Backup
private function createBackup(ElytraJobService $elytraJobService, ServerOperation $operation): string
{
$operation->updateProgress('Creating backup before proceeding...');
// Get current and target egg names for better backup naming
$currentEgg = $this->server->egg;
$targetEgg = Egg::find($this->eggId);
// Create descriptive backup name
$backupName = sprintf(
'Pre-Change Backup: %s → %s (%s)',
'Software Change: %s → %s (%s)',
$currentEgg->name ?? 'Unknown',
$targetEgg->name ?? 'Unknown',
now()->format('M j, Y g:i A')
now()->format('M j, g:i A')
);
// Limit backup name length to prevent database issues
if (strlen($backupName) > 190) {
$backupName = substr($backupName, 0, 187) . '...';
}
$backup = $backupService
->setIsLocked(false)
->handle($this->server, $backupName);
try {
$result = $elytraJobService->submitJob(
$this->server,
'backup_create',
[
'operation' => 'create',
'adapter' => config('backups.default', 'elytra'),
'ignored' => '',
'name' => $backupName,
],
$this->user
);
Activity::actor($this->user)->event('server:backup.software-change')
->property([
'backup_name' => $backupName,
'backup_uuid' => $backup->uuid,
'operation_id' => $this->operationId,
'from_egg' => $this->server->egg_id,
'to_egg' => $this->eggId,
])
->log();
Activity::actor($this->user)->event('server:backup.software-change')
->property([
'backup_name' => $backupName,
'backup_job_id' => $result['job_id'],
'operation_id' => $this->operationId,
'from_egg' => $this->server->egg_id,
'to_egg' => $this->eggId,
])
->log();
$operation->updateProgress('Waiting for backup to complete...');
$this->waitForBackupCompletion($backup, $operation);
$operation->updateProgress('Backup job submitted successfully');
$backup->refresh();
if (!$backup->is_successful) {
throw new BackupFailedException('Backup failed. Aborting software change to prevent data loss.');
return $result['job_id'];
} catch (\Exception $e) {
throw new BackupFailedException('Failed to create backup before egg change: ' . $e->getMessage());
}
}
/**
* Wait for an Elytra job to complete.
*/
private function waitForJobCompletion(ElytraJobService $elytraJobService, string $jobId, ServerOperation $operation, int $timeoutMinutes = 30): void
{
$operation->updateProgress('Waiting for backup to complete before continuing...');
$startTime = Carbon::now();
$timeout = $startTime->addMinutes($timeoutMinutes);
$lastProgressUpdate = 0;
while (Carbon::now()->lt($timeout)) {
$jobStatus = $elytraJobService->getJobStatus($this->server, $jobId);
if (!$jobStatus) {
throw new BackupFailedException('Backup job not found');
}
if ($jobStatus['status'] === 'completed') {
$operation->updateProgress('Backup completed successfully');
return;
}
if (in_array($jobStatus['status'], ['failed', 'cancelled'])) {
throw new BackupFailedException('Backup failed: ' . ($jobStatus['error'] ?? 'Unknown error'));
}
$elapsed = Carbon::now()->diffInSeconds($startTime);
if ($elapsed - $lastProgressUpdate >= 30) {
$progress = $jobStatus['progress'] ?? 0;
$operation->updateProgress("Backup in progress... {$progress}%");
$lastProgressUpdate = $elapsed;
}
sleep(5);
}
return $backup;
throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.');
}
/**
* Wipe server files if requested.
*/
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation, ?Backup $backup): void
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation): void
{
$operation->updateProgress('Wiping server files...');
@@ -189,9 +235,13 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
'from_egg' => $this->server->egg_id,
'to_egg' => $this->eggId,
'files_deleted' => count($filesToDelete),
'backup_verified' => $backup ? true : false,
'backup_created' => $this->shouldBackup,
])
->log();
$operation->updateProgress('Server files wiped successfully');
} else {
$operation->updateProgress('No files found to wipe');
}
} catch (Exception $e) {
Log::error('Failed to wipe files', [
@@ -199,9 +249,16 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
'error' => $e->getMessage(),
]);
if (!$backup) {
// If file wipe failed and we don't have a backup, this is dangerous
if (!$this->shouldBackup) {
throw new \RuntimeException('File wipe failed and no backup was created. Aborting operation to prevent data loss.');
}
// If we have a backup, log the wipe failure but continue
Log::warning('File wipe failed but backup was created, continuing with operation', [
'server_id' => $this->server->id,
'operation_id' => $this->operationId,
]);
}
}
@@ -300,70 +357,11 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
}
/**
* Handle job failure.
* Handle job failure when the Laravel queue system detects a failure.
*/
public function failed(\Throwable $exception): void
{
try {
$operation = ServerOperation::where('operation_id', $this->operationId)->first();
Log::error('Egg change job failed', [
'server_id' => $this->server->id,
'operation_id' => $this->operationId,
'error' => $exception->getMessage(),
]);
if ($operation) {
$operation->markAsFailed('Job failed: ' . $exception->getMessage());
}
Activity::actor($this->user)->event('server:software.change-job-failed')
->property([
'operation_id' => $this->operationId,
'error' => $exception->getMessage(),
'attempted_egg_id' => $this->eggId,
])
->log();
} catch (\Throwable $e) {
Log::critical('Failed to handle job failure properly', [
'operation_id' => $this->operationId,
'original_error' => $exception->getMessage(),
'handler_error' => $e->getMessage(),
]);
}
}
/**
* Wait for backup completion with timeout monitoring.
*/
private function waitForBackupCompletion(Backup $backup, ServerOperation $operation, int $timeoutMinutes = 30): void
{
$startTime = Carbon::now();
$timeout = $startTime->addMinutes($timeoutMinutes);
$lastProgressUpdate = 0;
while (Carbon::now()->lt($timeout)) {
$backup->refresh();
if ($backup->is_successful && !is_null($backup->completed_at)) {
$operation->updateProgress('Backup completed successfully');
return;
}
if (!is_null($backup->completed_at) && !$backup->is_successful) {
throw new BackupFailedException('Backup failed during creation process.');
}
$elapsed = Carbon::now()->diffInSeconds($startTime);
if ($elapsed - $lastProgressUpdate >= 30) {
$operation->updateProgress("Backup in progress...");
$lastProgressUpdate = $elapsed;
}
sleep(5);
}
throw new BackupFailedException('Backup creation timed out after ' . $timeoutMinutes . ' minutes.');
$this->handleJobFailure($exception, null);
}
/**

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
* @property string $uuid
* @property bool $is_successful
* @property bool $is_locked
* @property bool $is_automatic
* @property string $name
* @property string[] $ignored_files
* @property array|null $server_state
@@ -54,6 +55,7 @@ class Backup extends Model
'id' => 'int',
'is_successful' => 'bool',
'is_locked' => 'bool',
'is_automatic' => 'bool',
'ignored_files' => 'array',
'server_state' => 'array',
'bytes' => 'int',
@@ -63,6 +65,7 @@ class Backup extends Model
protected $attributes = [
'is_successful' => false,
'is_locked' => false,
'is_automatic' => false,
'checksum' => null,
'bytes' => 0,
'upload_id' => null,
@@ -120,6 +123,7 @@ class Backup extends Model
'uuid' => 'required|uuid',
'is_successful' => 'boolean',
'is_locked' => 'boolean',
'is_automatic' => 'boolean',
'name' => 'required|string',
'ignored_files' => 'array',
'server_state' => 'nullable|array',
@@ -191,6 +195,14 @@ class Backup extends Model
return $query->where('is_locked', true);
}
/**
* Scope to get automatic backups
*/
public function scopeAutomatic($query)
{
return $query->where('is_automatic', true);
}
/**
* Get the route key for the model.

View File

@@ -73,7 +73,6 @@ class Egg extends Model
* than leaving it null.
*/
public const FEATURE_EULA_POPUP = 'eula';
public const FEATURE_FASTDL = 'fastdl';
/**
* The table associated with the model.

View File

@@ -214,6 +214,9 @@ class Node extends Model
],
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
'remote' => route('index'),
'allowed_origins' => [
config('app.url'), // note: I have no idea why this wasn't included by Pterodactyl upstream, this might need to be configurable later - ellie
],
];
}
@@ -251,7 +254,6 @@ class Node extends Model
'endpoint' => $s3Config['endpoint'] ?? '',
'region' => $s3Config['region'] ?? 'us-east-1',
'bucket' => $s3Config['bucket'] ?? '',
'key_prefix' => $s3Config['prefix'] ?? 'pterodactyl-backups/',
'use_cold_storage' => $s3Config['use_cold_storage'] ?? false,
'hot_bucket' => $s3Config['hot_bucket'] ?? '',
'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER',

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
use Pterodactyl\Models\ServerSubdomain;
/**
* \Pterodactyl\Models\Server.

View File

@@ -41,6 +41,7 @@ class DownloadLinkService
'server_uuid' => $backup->server->uuid,
'backup_disk' => $backup->disk,
'repository_type' => $backup->getRepositoryType(),
'snapshot_id' => $backup->snapshot_id,
])
->handle($backup->server->node, $user->id . $backup->server->uuid);

View File

@@ -205,11 +205,17 @@ class ElytraJobService
$server = $job->server;
$currentStatus = $job->status;
$newStatus = $statusData['status'] ?? 'unknown';
$errorMessage = $statusData['error_message'] ?? null;
if ($errorMessage && str_starts_with($job->job_type, 'backup_')) {
$errorMessage = 'Backup operation failed. Please contact an administrator for details.';
}
$job->update([
'status' => $newStatus,
'progress' => $statusData['progress'] ?? $job->progress,
'status_message' => $statusData['message'] ?? null,
'error_message' => $statusData['error_message'] ?? null,
'error_message' => $errorMessage,
]);
if ($newStatus === 'completed' || $newStatus === 'failed') {

View File

@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Validator;
use Pterodactyl\Contracts\Elytra\Job;
use Pterodactyl\Repositories\Elytra\ElytraRepository;
use Pterodactyl\Services\Backups\ServerStateService;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
class BackupJob implements Job
@@ -19,11 +21,13 @@ class BackupJob implements Job
public function __construct(
private ServerStateService $serverStateService,
private BackupTransformer $backupTransformer,
private DownloadLinkService $downloadLinkService,
private BackupManager $backupManager,
) {}
public static function getSupportedJobTypes(): array
{
return ['backup_create', 'backup_delete', 'backup_restore', 'backup_download'];
return ['backup_create', 'backup_delete', 'backup_restore', 'backup_download', 'backup_delete_all'];
}
public function getRequiredPermissions(string $operation): array
@@ -45,20 +49,26 @@ class BackupJob implements Job
'adapter' => 'nullable|string',
'ignored' => 'nullable|string',
'name' => 'nullable|string|max:255',
'is_automatic' => 'nullable|boolean',
],
'delete' => [
'operation' => 'required|string|in:delete',
'backup_uuid' => 'required|string|uuid',
'snapshot_id' => 'nullable|string',
],
'restore' => [
'operation' => 'required|string|in:restore',
'backup_uuid' => 'required|string|uuid',
'snapshot_id' => 'nullable|string',
'truncate_directory' => 'boolean',
],
'download' => [
'operation' => 'required|string|in:download',
'backup_uuid' => 'required|string|uuid',
],
'delete_all' => [
'operation' => 'required|string|in:delete_all',
],
default => throw new \Exception('Invalid or missing operation'),
};
@@ -81,6 +91,7 @@ class BackupJob implements Job
'delete' => $this->submitDeleteJob($server, $job, $elytraRepository),
'restore' => $this->submitRestoreJob($server, $job, $elytraRepository),
'download' => $this->submitDownloadJob($server, $job, $elytraRepository),
'delete_all' => $this->submitDeleteAllJob($server, $job, $elytraRepository),
default => throw new \Exception("Unsupported backup operation: {$operation}"),
};
}
@@ -100,11 +111,24 @@ class BackupJob implements Job
$jobType = $statusData['job_type'] ?? '';
$operation = $this->getOperationFromJobType($jobType);
Log::debug("processStatusUpdate called", [
'job_id' => $job->id,
'job_type' => $jobType,
'operation' => $operation,
'successful' => $successful,
'has_repository_size' => isset($statusData['repository_size']),
]);
$errorMessage = $successful ? null : ($statusData['error_message'] ?? 'Unknown error');
if ($errorMessage) {
$errorMessage = $this->sanitizeBackupError($errorMessage);
}
$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'),
'error_message' => $errorMessage,
'completed_at' => CarbonImmutable::now(),
]);
@@ -113,6 +137,7 @@ class BackupJob implements Job
'delete' => $this->handleDeleteCompletion($job, $statusData),
'restore' => $this->handleRestoreCompletion($job, $statusData),
'download' => $this->handleDownloadCompletion($job, $statusData),
'delete_all' => $this->handleDeleteAllCompletion($job, $statusData),
default => Log::warning("Unknown backup operation for status update: {$operation}"),
};
}
@@ -185,6 +210,7 @@ class BackupJob implements Job
$elytraJobData = [
'server_id' => $server->uuid,
'backup_uuid' => $backup->uuid,
'snapshot_id' => $backup->snapshot_id,
'adapter_type' => $backup->getElytraAdapterType(),
];
@@ -198,12 +224,27 @@ class BackupJob implements Job
$jobData = $job->job_data;
$backup = Backup::where('uuid', $jobData['backup_uuid'])->firstOrFail();
$downloadUrl = $jobData['download_url'] ?? null;
if ($backup->disk === Backup::ADAPTER_AWS_S3 && empty($downloadUrl)) {
try {
$downloadUrl = $this->generateS3DownloadUrl($backup);
} catch (\Exception $e) {
Log::error('Failed to generate S3 download URL for backup restoration', [
'backup_uuid' => $backup->uuid,
'error' => $e->getMessage(),
]);
throw new \Exception('Failed to generate S3 download URL: ' . $e->getMessage());
}
}
$elytraJobData = [
'server_id' => $server->uuid,
'backup_uuid' => $backup->uuid,
'snapshot_id' => $backup->snapshot_id,
'adapter_type' => $backup->getElytraAdapterType(),
'truncate_directory' => $jobData['truncate_directory'] ?? false,
'download_url' => $jobData['download_url'] ?? null,
'download_url' => $downloadUrl,
];
$response = $elytraRepository->setServer($server)->createJob('backup_restore', $elytraJobData);
@@ -230,6 +271,7 @@ class BackupJob implements Job
$server = $job->server;
$actualAdapter = $this->mapElytraAdapterToModel($statusData['adapter'] ?? 'rustic_local');
$isRusticBackup = in_array($actualAdapter, [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3]);
$backupData = [
'server_id' => $server->id,
@@ -239,6 +281,7 @@ class BackupJob implements Job
'disk' => $actualAdapter,
'is_successful' => true,
'is_locked' => false,
'is_automatic' => $jobData['is_automatic'] ?? false,
'checksum' => ($statusData['checksum_type'] ?? 'sha1') . ':' . ($statusData['checksum'] ?? ''),
'bytes' => $statusData['size'] ?? 0,
'snapshot_id' => $statusData['snapshot_id'] ?? null,
@@ -261,12 +304,27 @@ class BackupJob implements Job
$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),
]);
if ($isRusticBackup && isset($statusData['repository_size'])) {
$server->update(['repository_backup_bytes' => $statusData['repository_size']]);
Log::info("Backup record created successfully (rustic)", [
'backup_id' => $backup->id,
'backup_uuid' => $backup->uuid,
'disk' => $backup->disk,
'repository_size_mb' => round($statusData['repository_size'] / 1024 / 1024, 2),
]);
} else {
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),
]);
}
if ($backup->is_automatic) {
$this->pruneOldAutomaticBackups($server);
}
} else {
Log::error("Backup job failed", [
'backup_uuid' => $backupUuid,
@@ -278,12 +336,38 @@ class BackupJob implements Job
private function handleDeleteCompletion(ElytraJob $job, array $statusData): void
{
Log::debug("handleDeleteCompletion called", [
'job_id' => $job->id,
'statusData' => $statusData,
]);
if ($statusData['successful']) {
$jobData = $job->job_data;
$backup = Backup::where('uuid', $jobData['backup_uuid'])->first();
if ($backup) {
$server = $backup->server;
$isRusticBackup = in_array($backup->disk, [Backup::ADAPTER_RUSTIC_LOCAL, Backup::ADAPTER_RUSTIC_S3]);
Log::debug("Backup found for deletion", [
'backup_uuid' => $backup->uuid,
'disk' => $backup->disk,
'is_rustic' => $isRusticBackup,
'has_repository_size' => isset($statusData['repository_size']),
]);
$backup->delete();
// If this was a rustic backup and we got the updated repository size, update the server
if ($isRusticBackup && isset($statusData['repository_size'])) {
$server->update(['repository_backup_bytes' => $statusData['repository_size']]);
Log::info("Updated repository size after backup deletion", [
'server_uuid' => $server->uuid,
'repository_size_mb' => round($statusData['repository_size'] / 1024 / 1024, 2),
'adapter_type' => $backup->disk,
]);
}
}
}
}
@@ -296,6 +380,43 @@ class BackupJob implements Job
{
}
private function submitDeleteAllJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
// Get all backups for this server with necessary information
$backups = $server->backups()->get(['uuid', 'disk', 'snapshot_id', 'checksum'])->map(function ($backup) {
return [
'uuid' => $backup->uuid,
'adapter' => $backup->disk,
'snapshot_id' => $backup->snapshot_id,
'checksum' => $backup->checksum,
];
})->toArray();
$elytraJobData = [
'server_id' => $server->uuid,
'backups' => $backups,
];
$response = $elytraRepository->setServer($server)->createJob('backup_delete_all', $elytraJobData);
return $response['job_id'] ?? throw new \Exception('No job ID returned from Elytra');
}
private function handleDeleteAllCompletion(ElytraJob $job, array $statusData): void
{
if ($statusData['successful']) {
$server = $job->server;
$deletedCount = $server->backups()->delete();
$server->update(['repository_backup_bytes' => 0]);
Log::info("All backups deleted successfully", [
'server_uuid' => $server->uuid,
'deleted_count' => $deletedCount,
]);
}
}
private function getOperationFromJobType(string $jobType): string
{
return match ($jobType) {
@@ -303,6 +424,7 @@ class BackupJob implements Job
'backup_delete' => 'delete',
'backup_restore' => 'restore',
'backup_download' => 'download',
'backup_delete_all' => 'delete_all',
default => 'unknown',
};
}
@@ -342,4 +464,116 @@ class BackupJob implements Job
return array_values($files);
}
/**
* Generate a presigned S3 download URL for backup restoration
*/
private function generateS3DownloadUrl(Backup $backup): string
{
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
$request = $adapter->getClient()->createPresignedRequest(
$adapter->getClient()->getCommand('GetObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(15) // Longer timeout for restoration downloads
);
return $request->getUri()->__toString();
}
/**
* Prune old automatic backups for a server if the count exceeds the configured limit.
* Only unlocked automatic backups count toward the limit. Locked backups are preserved indefinitely.
* This ensures users don't accumulate hundreds of automatic backups without manual intervention.
*/
private function pruneOldAutomaticBackups(Server $server): void
{
$limit = config('backups.automatic_backup_limit', 32); // todo: make this configurable in the panel (maybe?) - ellie
if ($limit <= 0) {
return;
}
$unlockedAutomaticBackupCount = $server->backups()
->where('is_automatic', true)
->where('is_successful', true)
->where('is_locked', false)
->count();
if ($unlockedAutomaticBackupCount <= $limit) {
return;
}
$excessCount = $unlockedAutomaticBackupCount - $limit;
$oldBackups = $server->backups()
->where('is_automatic', true)
->where('is_successful', true)
->where('is_locked', false)
->orderBy('created_at', 'asc')
->limit($excessCount)
->get();
if ($oldBackups->isEmpty()) {
return;
}
$elytraRepository = app(\Pterodactyl\Repositories\Elytra\ElytraRepository::class);
$deletedCount = 0;
foreach ($oldBackups as $backup) {
try {
$elytraRepository->setServer($server)->createJob('backup_delete', [
'server_id' => $server->uuid,
'backup_uuid' => $backup->uuid,
'snapshot_id' => $backup->snapshot_id,
'adapter_type' => $backup->getElytraAdapterType(),
]);
$deletedCount++;
Log::info("Queued automatic backup for deletion due to limit", [
'server_id' => $server->id,
'backup_uuid' => $backup->uuid,
'backup_name' => $backup->name,
]);
} catch (\Exception $e) {
Log::error("Failed to queue automatic backup deletion", [
'server_id' => $server->id,
'backup_uuid' => $backup->uuid,
'error' => $e->getMessage(),
]);
}
}
$lockedCount = $server->backups()
->where('is_automatic', true)
->where('is_successful', true)
->where('is_locked', true)
->count();
Log::info("Automatic backup pruning completed", [
'server_id' => $server->id,
'unlocked_automatic_backup_count' => $unlockedAutomaticBackupCount,
'locked_automatic_backup_count' => $lockedCount,
'limit' => $limit,
'queued_deletions' => $deletedCount,
]);
}
/**
* Sanitize backup error messages to prevent leaking sensitive information.
* Never expose raw errors from backup systems as they may contain credentials or paths.
*
* @param string $errorMessage The raw error message from the backup system
* @return string Generic error message safe for frontend display
*/
private function sanitizeBackupError(string $errorMessage): string
{
return 'Backup operation failed. Please contact an administrator for details.'; // todo: better sanitization - elllie
}
}

View File

@@ -23,6 +23,7 @@ class BackupTransformer extends BaseClientTransformer
'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'is_locked' => $backup->is_locked,
'is_automatic' => $backup->is_automatic,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'checksum' => $backup->checksum,

View File

@@ -22,6 +22,12 @@ return [
// to 6 hours. To disable this feature, set the value to `0`.
'prune_age' => env('BACKUP_PRUNE_AGE', 360),
// The maximum number of unlocked automatic backups to keep per server. When this limit is
// exceeded, the oldest unlocked automatic backups will be automatically deleted. Locked
// automatic backups do not count toward this limit and are preserved indefinitely.
// Set to 0 to disable automatic pruning. Defaults to 32.
'automatic_backup_limit' => env('BACKUP_AUTOMATIC_LIMIT', 32),
'disks' => [
// There is no configuration for the local disk for Wings. That configuration
// is determined by the Daemon configuration, and not the Panel.

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->unsignedBigInteger('repository_backup_bytes')->default(0)->after('backup_storage_limit');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('repository_backup_bytes');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->boolean('is_automatic')->default(false)->after('is_locked')->comment('Whether this backup was created automatically by a schedule');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('is_automatic');
});
}
};

View File

@@ -10,6 +10,7 @@ export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
isLocked: boolean;
isAutomatic: boolean;
name: string;
ignoredFiles: string;
checksum: string;

View File

@@ -19,6 +19,12 @@ type BackupResponse = PaginatedResult<ServerBackup> & {
backupCount: number;
storage: {
used_mb: number;
legacy_usage_mb: number;
repository_usage_mb: number;
rustic_backup_sum_mb: number;
overhead_mb: number;
overhead_percent: number;
needs_pruning: boolean;
limit_mb: number | null;
has_limit: boolean;
usage_percentage: number | null;

View File

@@ -58,6 +58,7 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
isLocked: attributes.is_locked,
isAutomatic: attributes.is_automatic || false,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
checksum: attributes.checksum,

View File

@@ -17,7 +17,7 @@ const Checkbox = React.forwardRef<
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckboxPrimitive.Indicator className={cn('flex h-full w-full items-center justify-center text-current')}>
<HugeIconsCheck fill='currentColor' className='h-4 w-4' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View File

@@ -18,7 +18,7 @@ export const MainPageHeader: React.FC<MainPageHeaderProps> = ({
direction = 'row',
}) => {
return (
<HeaderWrapper className={clsx('flex flex-col', 'mb-4 gap-4 md:gap-8 mt-8 md:mt-0 select-none')}>
<HeaderWrapper className={clsx('flex flex-col', 'mb-8 gap-4 md:gap-8 mt-8 md:mt-0 select-none')}>
<div
className={clsx(
'flex',

View File

@@ -8,7 +8,7 @@ export default ({ children }: { children: React.ReactNode }) => {
const { setFooter } = useContext(DialogContext);
useDeepCompareEffect(() => {
setFooter(<div className={'px-4 py-4 flex items-center justify-end gap-4 rounded-b'}>{children}</div>);
setFooter(<div className={'px-6 py-4 pb-6 flex items-center justify-end gap-4 rounded-b'}>{children}</div>);
}, [children]);
return null;

View File

@@ -11,9 +11,9 @@ const PageListContainer = ({ className, children }: Props) => {
style={{
background: 'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(16, 16, 16) 0%, rgb(4, 4, 4) 100%)',
}}
className={clsx(className, 'p-1 border-[1px] border-[#ffffff12] rounded-xl')}
className={clsx(className, 'p-2 border-[1px] border-[#ffffff12] rounded-xl')}
>
<div className='flex h-full w-full flex-col gap-1 overflow-hidden rounded-lg'>{children}</div>
<div className='flex h-full w-full flex-col gap-3 overflow-hidden rounded-lg'>{children}</div>
</div>
);
};
@@ -24,7 +24,7 @@ const PageListItem = ({ className, children }: Props) => {
<div
className={clsx(
className,
'bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all flex items-center gap-4 flex-col sm:flex-row',
'bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] px-5 py-4 rounded-xl hover:border-[#ffffff20] transition-all flex items-center gap-3 flex-col sm:flex-row',
)}
>
{children}

View File

@@ -1,10 +1,13 @@
import { Form, Formik, Field as FormikField, FormikHelpers, useFormikContext } from 'formik';
import { useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useEffect, useState, createContext } from 'react';
import { boolean, object, string } from 'yup';
import { useStoreState } from 'easy-peasy';
import { toast } from 'sonner';
import FlashMessageRender from '@/components/FlashMessageRender';
import ActionButton from '@/components/elements/ActionButton';
import Can from '@/components/elements/Can';
import { Checkbox } from '@/components/elements/CheckboxNew';
import Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import FormikSwitchV2 from '@/components/elements/FormikSwitchV2';
@@ -17,13 +20,30 @@ import Spinner from '@/components/elements/Spinner';
import { PageListContainer } from '@/components/elements/pages/PageList';
import { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
import getServerBackups from '@/api/swr/getServerBackups';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ApplicationStore } from '@/state';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import { useUnifiedBackups } from './useUnifiedBackups';
import BackupItem from './BackupItem';
// Context to share live backup progress across components
export const LiveProgressContext = createContext<Record<string, {
status: string;
progress: number;
message: string;
canRetry: boolean;
lastUpdated: string;
completed: boolean;
isDeletion: boolean;
backupName?: string;
}>>({});
// Helper function to format storage values
const formatStorage = (mb: number | undefined | null): string => {
if (mb === null || mb === undefined) {
@@ -97,16 +117,33 @@ const BackupContainer = () => {
const { page, setPage } = useContext(ServerBackupContext);
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
const [createModalVisible, setCreateModalVisible] = useState(false);
const [deleteAllModalVisible, setDeleteAllModalVisible] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteAllPassword, setDeleteAllPassword] = useState('');
const [deleteAllTotpCode, setDeleteAllTotpCode] = useState('');
// Bulk operations state
const [selectedBackups, setSelectedBackups] = useState<Set<string>>(new Set());
const [bulkDeleteModalVisible, setBulkDeleteModalVisible] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [bulkDeletePassword, setBulkDeletePassword] = useState('');
const [bulkDeleteTotpCode, setBulkDeleteTotpCode] = useState('');
const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
const {
backups,
backupCount,
storage,
pagination,
error,
isValidating,
createBackup
createBackup,
retryBackup,
refresh
} = useUnifiedBackups();
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
@@ -132,6 +169,122 @@ const BackupContainer = () => {
}
};
const handleDeleteAll = async () => {
if (!deleteAllPassword) {
toast.error('Password is required to delete all backups.');
return;
}
if (hasTwoFactor && !deleteAllTotpCode) {
toast.error('Two-factor authentication code is required.');
return;
}
setIsDeleting(true);
try {
const http = (await import('@/api/http')).default;
await http.delete(`/api/client/servers/${uuid}/backups/delete-all`, {
data: {
password: deleteAllPassword,
...(hasTwoFactor ? { totp_code: deleteAllTotpCode } : {}),
},
});
toast.success('All backups and repositories are being deleted. This may take a few minutes.');
setDeleteAllModalVisible(false);
setDeleteAllPassword('');
setDeleteAllTotpCode('');
// Websocket events will handle the UI updates automatically
} catch (error) {
toast.error(httpErrorToHuman(error));
} finally {
setIsDeleting(false);
}
};
// Bulk selection handlers
const toggleBackupSelection = (backupUuid: string) => {
setSelectedBackups((prev) => {
const newSet = new Set(prev);
if (newSet.has(backupUuid)) {
newSet.delete(backupUuid);
} else {
newSet.add(backupUuid);
}
return newSet;
});
};
const toggleSelectAll = () => {
if (selectedBackups.size === selectableBackups.length) {
setSelectedBackups(new Set());
} else {
setSelectedBackups(new Set(selectableBackups.map((b) => b.uuid)));
}
};
const clearSelection = () => {
setSelectedBackups(new Set());
};
// Get backups that can be selected (completed and not active)
const selectableBackups = backups.filter(
(b) => b.status === 'completed' && b.isSuccessful && !b.isLiveOnly
);
const handleBulkDelete = async () => {
if (!bulkDeletePassword) {
addFlash({
key: 'backups:bulk_delete',
type: 'error',
message: 'Password is required to delete backups.',
});
return;
}
if (hasTwoFactor && !bulkDeleteTotpCode) {
addFlash({
key: 'backups:bulk_delete',
type: 'error',
message: 'Two-factor authentication code is required.',
});
return;
}
setIsBulkDeleting(true);
clearFlashes('backups:bulk_delete');
try {
const http = (await import('@/api/http')).default;
await http.post(`/api/client/servers/${uuid}/backups/bulk-delete`, {
backup_uuids: Array.from(selectedBackups),
password: bulkDeletePassword,
...(hasTwoFactor ? { totp_code: bulkDeleteTotpCode } : {}),
});
addFlash({
key: 'backups',
type: 'success',
message: `${selectedBackups.size} backup${selectedBackups.size > 1 ? 's are' : ' is'} being deleted.`,
});
setBulkDeleteModalVisible(false);
setBulkDeletePassword('');
setBulkDeleteTotpCode('');
clearSelection();
// Refresh the backup list to reflect the deletions
await refresh();
} catch (error) {
clearAndAddHttpError({ key: 'backups:bulk_delete', error });
} finally {
setIsBulkDeleting(false);
}
};
useEffect(() => {
if (!error) {
clearFlashes('backups');
@@ -189,35 +342,61 @@ const BackupContainer = () => {
{storage && (
<div className='flex flex-col gap-0.5'>
{backupStorageLimit === null ? (
<p
className='text-sm text-zinc-300 cursor-help'
title={`${storage.used_mb?.toFixed(2) || 0}MB used(No Limit)`}
>
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> storage used
</p>
<>
<p
className='text-sm text-zinc-300 cursor-help'
title={`${storage.used_mb?.toFixed(2) || 0}MB total (Repository: ${storage.repository_usage_mb?.toFixed(2) || 0}MB, Legacy: ${storage.legacy_usage_mb?.toFixed(2) || 0}MB)`}
>
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> storage used
</p>
{(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && (
<p className='text-xs text-zinc-400'>
{storage.repository_usage_mb > 0 && `${formatStorage(storage.repository_usage_mb)} deduplicated`}
{storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0 && ' + '}
{storage.legacy_usage_mb > 0 && `${formatStorage(storage.legacy_usage_mb)} legacy`}
</p>
)}
</>
) : (
<>
<p
className='text-sm text-zinc-300 cursor-help'
title={`${storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (${storage.available_mb?.toFixed(2) || 0}MB Available)`}
title={`${storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (Repository: ${storage.repository_usage_mb?.toFixed(2) || 0}MB, Legacy: ${storage.legacy_usage_mb?.toFixed(2) || 0}MB, ${storage.available_mb?.toFixed(2) || 0}MB Available)`}
>
<span className='font-medium'>{formatStorage(storage.used_mb)}</span> {' '}
{backupStorageLimit === null ?
"used" :
(<span className='font-medium'>of {formatStorage(backupStorageLimit)} used</span>)}
</p>
{(storage.repository_usage_mb > 0 || storage.legacy_usage_mb > 0) && (storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0) && (
<p className='text-xs text-zinc-400'>
{storage.repository_usage_mb > 0 && `${formatStorage(storage.repository_usage_mb)} deduplicated`}
{storage.repository_usage_mb > 0 && storage.legacy_usage_mb > 0 && ' + '}
{storage.legacy_usage_mb > 0 && `${formatStorage(storage.legacy_usage_mb)} legacy`}
</p>
)}
</>
)}
</div>
)}
</div>
{(backupLimit === null || backupLimit > backupCount) &&
(!backupStorageLimit || !storage?.is_over_limit) && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
<div className='flex gap-2'>
{backupCount > 0 && (
<ActionButton variant='danger' onClick={() => setDeleteAllModalVisible(true)}>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete All Backups
</ActionButton>
)}
{(backupLimit === null || backupLimit > backupCount) &&
(!backupStorageLimit || !storage?.is_over_limit) && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
)}
</div>
</div>
</Can>
}
@@ -242,6 +421,200 @@ const BackupContainer = () => {
</Formik>
)}
{deleteAllModalVisible && (
<Modal
visible={deleteAllModalVisible}
onDismissed={() => {
setDeleteAllModalVisible(false);
setDeleteAllPassword('');
setDeleteAllTotpCode('');
}}
title='Delete All Backups'
>
<div className="space-y-4">
<p className="text-sm text-zinc-300">
You are about to permanently delete{' '}
<span className="font-medium text-red-400">
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
</span>
{' '}and completely destroy the backup repository for this server.
</p>
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="text-sm">
<p className="font-medium text-red-300">This action cannot be undone</p>
<ul className="text-red-400 mt-2 space-y-1 list-disc list-inside">
<li>All backup data will be permanently deleted</li>
<li>Locked backups will also be deleted</li>
<li>The entire backup repository will be destroyed</li>
<li>This operation may take several minutes to complete</li>
<li>You will not be able to restore any of these backups</li>
</ul>
</div>
</div>
</div>
<div className="space-y-3">
<div>
<label htmlFor="password" className="block text-sm font-medium text-zinc-300 mb-1">
Password
</label>
<input
id="password"
type="password"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="Enter your password"
value={deleteAllPassword}
onChange={(e) => setDeleteAllPassword(e.target.value)}
disabled={isDeleting}
/>
</div>
{hasTwoFactor && (
<div>
<label htmlFor="totp_code" className="block text-sm font-medium text-zinc-300 mb-1">
Two-Factor Authentication Code
</label>
<input
id="totp_code"
type="text"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="6-digit code"
maxLength={6}
value={deleteAllTotpCode}
onChange={(e) => setDeleteAllTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isDeleting}
/>
</div>
)}
</div>
<div className="flex justify-end gap-3 pb-6 pt-2">
<ActionButton
variant='secondary'
onClick={() => {
setDeleteAllModalVisible(false);
setDeleteAllPassword('');
setDeleteAllTotpCode('');
}}
disabled={isDeleting}
>
Cancel
</ActionButton>
<ActionButton
variant='danger'
onClick={handleDeleteAll}
disabled={isDeleting}
>
{isDeleting && <Spinner size='small' />}
{isDeleting ? 'Deleting...' : 'Delete All Backups'}
</ActionButton>
</div>
</div>
</Modal>
)}
{/* Bulk delete modal */}
{bulkDeleteModalVisible && (
<Modal
visible={bulkDeleteModalVisible}
onDismissed={() => {
setBulkDeleteModalVisible(false);
setBulkDeletePassword('');
setBulkDeleteTotpCode('');
}}
title='Delete Selected Backups'
>
<FlashMessageRender byKey={'backups:bulk_delete'} />
<div className="space-y-4">
<p className="text-sm text-zinc-300">
You are about to permanently delete{' '}
<span className="font-medium text-red-400">
{selectedBackups.size} backup{selectedBackups.size > 1 ? 's' : ''}
</span>
. This action cannot be undone.
</p>
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="text-sm">
<p className="font-medium text-red-300">Warning</p>
<p className="text-red-400 mt-1">
The selected backup files and their snapshots will be permanently deleted. You will not be able to restore them.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div>
<label htmlFor="bulk-password" className="block text-sm font-medium text-zinc-300 mb-1">
Password
</label>
<input
id="bulk-password"
type="password"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="Enter your password"
value={bulkDeletePassword}
onChange={(e) => setBulkDeletePassword(e.target.value)}
disabled={isBulkDeleting}
/>
</div>
{hasTwoFactor && (
<div>
<label htmlFor="bulk-totp" className="block text-sm font-medium text-zinc-300 mb-1">
Two-Factor Authentication Code
</label>
<input
id="bulk-totp"
type="text"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="6-digit code"
maxLength={6}
value={bulkDeleteTotpCode}
onChange={(e) => setBulkDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isBulkDeleting}
/>
</div>
)}
</div>
<div className="flex justify-end gap-3 pb-6 pt-2">
<ActionButton
variant='secondary'
onClick={() => {
setBulkDeleteModalVisible(false);
setBulkDeletePassword('');
setBulkDeleteTotpCode('');
}}
disabled={isBulkDeleting}
>
Cancel
</ActionButton>
<ActionButton
variant='danger'
onClick={handleBulkDelete}
disabled={isBulkDeleting}
>
{isBulkDeleting && <Spinner size='small' />}
{isBulkDeleting ? 'Deleting...' : `Delete ${selectedBackups.size} Backup${selectedBackups.size > 1 ? 's' : ''}`}
</ActionButton>
</div>
</div>
</Modal>
)}
{backups.length === 0 ? (
<div className='flex flex-col items-center justify-center min-h-[60vh] py-12 px-4'>
<div className='text-center'>
@@ -265,11 +638,64 @@ const BackupContainer = () => {
</div>
</div>
) : (
<PageListContainer>
{backups.map((backup) => (
<BackupItem key={backup.uuid} backup={backup} />
))}
</PageListContainer>
<>
{/* Bulk action bar */}
{selectableBackups.length > 0 && (
<div className='mb-8 flex items-center justify-between px-4 py-3.5 rounded-xl bg-[#ffffff08] border border-zinc-700'>
<div className='flex items-center gap-4'>
<Checkbox
checked={selectedBackups.size === selectableBackups.length && selectableBackups.length > 0}
onCheckedChange={toggleSelectAll}
/>
<span className='text-sm text-zinc-300'>
{selectedBackups.size > 0 ? (
<>
<span className='font-medium'>{selectedBackups.size}</span> selected
</>
) : (
'Select backups'
)}
</span>
</div>
<div className={`flex items-center gap-3 transition-opacity ${selectedBackups.size > 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<ActionButton
variant='secondary'
onClick={clearSelection}
>
Clear
</ActionButton>
<Can action='backup.delete'>
<ActionButton
variant='danger'
onClick={() => setBulkDeleteModalVisible(true)}
>
Delete Selected ({selectedBackups.size})
</ActionButton>
</Can>
</div>
</div>
)}
<PageListContainer>
{backups.map((backup) => (
<BackupItem
key={backup.uuid}
backup={backup}
isSelected={selectedBackups.has(backup.uuid)}
onToggleSelect={() => toggleBackupSelection(backup.uuid)}
isSelectable={selectableBackups.some((b) => b.uuid === backup.uuid)}
retryBackup={retryBackup}
/>
))}
</PageListContainer>
{pagination && pagination.currentPage && pagination.totalPages && pagination.totalPages > 1 && (
<Pagination data={{ items: backups, pagination }} onPageSelect={setPage}>
{() => null}
</Pagination>
)}
</>
)}
</ServerContentBlock>
);
@@ -277,10 +703,144 @@ const BackupContainer = () => {
const BackupContainerWrapper = () => {
const [page, setPage] = useState<number>(1);
const { mutate } = getServerBackups();
const [liveProgress, setLiveProgress] = useState<Record<string, {
status: string;
progress: number;
message: string;
canRetry: boolean;
lastUpdated: string;
completed: boolean;
isDeletion: boolean;
backupName?: string;
}>>({});
// Single websocket listener for the entire page
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,
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) {
if (isDeletionOperation) {
// Optimistically remove the deleted backup from SWR cache immediately
// note: this is incredibly buggy sometimes, somebody please refactor how "live" backups work. - ellie
mutate(
(currentData) => {
if (!currentData) return currentData;
return {
...currentData,
items: currentData.items.filter(b => b.uuid !== backup_uuid),
backupCount: Math.max(0, (currentData.backupCount || 0) - 1),
};
},
{ revalidate: true }
);
// Remove from live progress
setTimeout(() => {
setLiveProgress(prev => {
const updated = { ...prev };
delete updated[backup_uuid];
return updated;
});
}, 500);
} else {
// For new backups, wait for them to appear in the API
mutate();
const checkForBackup = async (attempts = 0) => {
if (attempts > 10) {
setLiveProgress(prev => {
const updated = { ...prev };
delete updated[backup_uuid];
return updated;
});
return;
}
// Force fresh data
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);
return (
<ServerBackupContext.Provider value={{ page, setPage }}>
<BackupContainer />
</ServerBackupContext.Provider>
<LiveProgressContext.Provider value={liveProgress}>
<ServerBackupContext.Provider value={{ page, setPage }}>
<BackupContainer />
</ServerBackupContext.Provider>
</LiveProgressContext.Provider>
);
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useStoreState } from 'easy-peasy';
import ActionButton from '@/components/elements/ActionButton';
import Can from '@/components/elements/Can';
@@ -10,6 +11,7 @@ import {
DropdownMenuTrigger,
} from '@/components/elements/DropdownMenu';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Spinner from '@/components/elements/Spinner';
import { Dialog } from '@/components/elements/dialog';
import HugeIconsAlert from '@/components/elements/hugeicons/Alert';
import HugeIconsCloudUp from '@/components/elements/hugeicons/CloudUp';
@@ -18,6 +20,7 @@ import HugeIconsFileDownload from '@/components/elements/hugeicons/FileDownload'
import HugeIconsFileSecurity from '@/components/elements/hugeicons/FileSecurity';
import HugeIconsPencil from '@/components/elements/hugeicons/Pencil';
import HugeIconsHamburger from '@/components/elements/hugeicons/hamburger';
import FlashMessageRender from '@/components/FlashMessageRender';
import http, { httpErrorToHuman } from '@/api/http';
import {
@@ -25,6 +28,7 @@ import {
} from '@/api/server/backups';
import { ServerBackup } from '@/api/server/types';
import { ApplicationStore } from '@/state';
import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
@@ -41,8 +45,13 @@ const BackupContextMenu = ({ backup }: Props) => {
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(5);
const [newName, setNewName] = useState(backup.name);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [deletePassword, setDeletePassword] = useState('');
const [deleteTotpCode, setDeleteTotpCode] = useState('');
const [restorePassword, setRestorePassword] = useState('');
const [restoreTotpCode, setRestoreTotpCode] = useState('');
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups();
const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
const doDownload = () => {
setLoading(true);
@@ -59,25 +68,75 @@ const BackupContextMenu = ({ backup }: Props) => {
};
const doDeletion = async () => {
if (!deletePassword) {
addFlash({
key: 'backup:delete',
type: 'error',
message: 'Password is required to delete this backup.',
});
return;
}
if (hasTwoFactor && !deleteTotpCode) {
addFlash({
key: 'backup:delete',
type: 'error',
message: 'Two-factor authentication code is required.',
});
return;
}
setLoading(true);
clearFlashes('backups');
clearFlashes('backup:delete');
try {
await deleteBackup(backup.uuid);
await http.delete(`/api/client/servers/${uuid}/backups/${backup.uuid}`, {
data: {
password: deletePassword,
...(hasTwoFactor ? { totp_code: deleteTotpCode } : {}),
},
});
setLoading(false);
setModal('');
setDeletePassword('');
setDeleteTotpCode('');
// Refresh the backup list to reflect the deletion
await refresh();
} catch (error) {
clearAndAddHttpError({ key: 'backups', error });
clearAndAddHttpError({ key: 'backup:delete', error });
setLoading(false);
setModal('');
}
};
const doRestorationAction = async () => {
if (!restorePassword) {
addFlash({
key: 'backup:restore',
type: 'error',
message: 'Password is required to restore this backup.',
});
return;
}
if (hasTwoFactor && !restoreTotpCode) {
addFlash({
key: 'backup:restore',
type: 'error',
message: 'Two-factor authentication code is required.',
});
return;
}
setLoading(true);
clearFlashes('backups');
clearFlashes('backup:restore');
try {
await restoreBackup(backup.uuid);
await http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/restore`, {
password: restorePassword,
...(hasTwoFactor ? { totp_code: restoreTotpCode } : {}),
});
// Set server status to restoring
setServerFromState((s) => ({
@@ -87,10 +146,11 @@ const BackupContextMenu = ({ backup }: Props) => {
setLoading(false);
setModal('');
setRestorePassword('');
setRestoreTotpCode('');
} catch (error) {
clearAndAddHttpError({ key: 'backups', error });
clearAndAddHttpError({ key: 'backup:restore', error });
setLoading(false);
setModal('');
}
};
@@ -184,7 +244,12 @@ const BackupContextMenu = ({ backup }: Props) => {
>
This backup will no longer be protected from automated or accidental deletions.
</Dialog.Confirm>
<Dialog open={modal === 'restore'} onClose={() => setModal('')} title='Restore Backup'>
<Dialog open={modal === 'restore'} onClose={() => {
setModal('');
setRestorePassword('');
setRestoreTotpCode('');
}} title='Restore Backup'>
<FlashMessageRender byKey={'backup:restore'} />
<div className='space-y-4'>
<div className='space-y-2'>
<p className='text-sm font-medium text-zinc-200'>&quot;{backup.name}&quot;</p>
@@ -208,26 +273,141 @@ const BackupContextMenu = ({ backup }: Props) => {
</div>
</div>
</div>
<div className="space-y-3">
<div>
<label htmlFor="restore-password" className="block text-sm font-medium text-zinc-300 mb-1">
Password
</label>
<input
id="restore-password"
type="password"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="Enter your password"
value={restorePassword}
onChange={(e) => setRestorePassword(e.target.value)}
disabled={loading}
/>
</div>
{hasTwoFactor && (
<div>
<label htmlFor="restore-totp" className="block text-sm font-medium text-zinc-300 mb-1">
Two-Factor Authentication Code
</label>
<input
id="restore-totp"
type="text"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="6-digit code"
maxLength={6}
value={restoreTotpCode}
onChange={(e) => setRestoreTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={loading}
/>
</div>
)}
</div>
</div>
<Dialog.Footer>
<ActionButton onClick={() => setModal('')} variant='secondary'>
<ActionButton onClick={() => {
setModal('');
setRestorePassword('');
setRestoreTotpCode('');
}} variant='secondary' disabled={loading}>
Cancel
</ActionButton>
<ActionButton onClick={() => doRestorationAction()} variant='danger' disabled={countdown > 0}>
{countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'}
<ActionButton onClick={() => doRestorationAction()} variant='danger' disabled={countdown > 0 || loading}>
{loading && <Spinner size='small' />}
{loading ? 'Restoring...' : countdown > 0 ? `Delete All & Restore (${countdown}s)` : 'Delete All & Restore Backup'}
</ActionButton>
</Dialog.Footer>
</Dialog>
<Dialog open={modal === 'delete'} onClose={() => {
setModal('');
setDeletePassword('');
setDeleteTotpCode('');
}} title={`Delete "${backup.name}"`}>
<FlashMessageRender byKey={'backup:delete'} />
<div className="space-y-4">
<p className="text-sm text-zinc-300">
This is a permanent operation. The backup cannot be recovered once deleted.
</p>
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="text-sm">
<p className="font-medium text-red-300">Warning</p>
<p className="text-red-400 mt-1">
The backup file and its snapshot will be permanently deleted.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div>
<label htmlFor="delete-password" className="block text-sm font-medium text-zinc-300 mb-1">
Password
</label>
<input
id="delete-password"
type="password"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="Enter your password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
disabled={loading}
/>
</div>
{hasTwoFactor && (
<div>
<label htmlFor="delete-totp" className="block text-sm font-medium text-zinc-300 mb-1">
Two-Factor Authentication Code
</label>
<input
id="delete-totp"
type="text"
className="w-full px-4 py-2 rounded-lg outline-hidden bg-[#ffffff17] text-sm border border-zinc-700 focus:border-brand"
placeholder="6-digit code"
maxLength={6}
value={deleteTotpCode}
onChange={(e) => setDeleteTotpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={loading}
/>
</div>
)}
</div>
</div>
<Dialog.Footer>
<ActionButton
variant='secondary'
onClick={() => {
setModal('');
setDeletePassword('');
setDeleteTotpCode('');
}}
disabled={loading}
>
Cancel
</ActionButton>
<ActionButton
variant='danger'
onClick={doDeletion}
disabled={loading}
>
{loading && <Spinner size='small' />}
{loading ? 'Deleting...' : 'Delete Backup'}
</ActionButton>
</Dialog.Footer>
</Dialog>
<Dialog.Confirm
title={`Delete "${backup.name}"`}
confirm={'Continue'}
open={modal === 'delete'}
onClose={() => setModal('')}
onConfirmed={doDeletion}
>
This is a permanent operation. The backup cannot be recovered once deleted.
</Dialog.Confirm>
<SpinnerOverlay visible={loading} fixed />
{backup.isSuccessful ? (
<DropdownMenu>

View File

@@ -1,6 +1,7 @@
import { format, formatDistanceToNow } from 'date-fns';
import Can from '@/components/elements/Can';
import { Checkbox } from '@/components/elements/CheckboxNew';
import Spinner from '@/components/elements/Spinner';
import HugeIconsSquareLock from '@/components/elements/hugeicons/SquareLock';
import HugeIconsStorage from '@/components/elements/hugeicons/Storage';
@@ -10,7 +11,6 @@ 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';
@@ -22,6 +22,7 @@ export interface UnifiedBackup {
message: string;
isSuccessful?: boolean;
isLocked: boolean;
isAutomatic: boolean;
checksum?: string;
bytes?: number;
createdAt: Date;
@@ -36,11 +37,14 @@ export interface UnifiedBackup {
interface Props {
backup: UnifiedBackup;
isSelected?: boolean;
onToggleSelect?: () => void;
isSelectable?: boolean;
retryBackup: (backupUuid: string) => Promise<void>;
}
const BackupItem = ({ backup }: Props) => {
const BackupItem = ({ backup, isSelected = false, onToggleSelect, isSelectable = false, retryBackup }: Props) => {
const { addFlash, clearFlashes } = useFlash();
const { retryBackup } = useUnifiedBackups();
const handleRetry = async () => {
@@ -125,34 +129,50 @@ const BackupItem = ({ backup }: Props) => {
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'>
<div className='flex items-center gap-3 w-full'>
{/* Selection checkbox - always reserve space to prevent layout shift */}
<div className='flex-shrink-0 w-5'>
{isSelectable && onToggleSelect ? (
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelect}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
) : (
<div className='w-5 h-5' />
)}
</div>
<div className='flex-shrink-0 w-9 h-9 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'>
<div className='flex items-center gap-2 mb-1.5'>
{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>
{backup.isAutomatic && (
<span className='text-xs text-blue-400 font-medium bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded'>
Automatic
</span>
)}
{backup.isLocked && (
<span className='text-xs text-red-400 font-medium bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded'>
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'>
<div className='flex justify-between text-xs text-zinc-400 mb-1.5'>
<span>{backup.message || 'Processing...'}</span>
<span>{backup.progress}%</span>
</div>
<div className='w-full bg-zinc-700 rounded-full h-1.5'>
<div className='w-full bg-zinc-700 rounded-full h-2'>
<div
className={`h-1.5 rounded-full transition-all duration-300 ${
className={`h-2 rounded-full transition-all duration-300 ${
backup.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
}`}
style={{ width: `${backup.progress || 0}%` }}
@@ -163,7 +183,7 @@ const BackupItem = ({ backup }: Props) => {
{/* Error message for failed backups */}
{backup.status === 'failed' && backup.message && (
<p className='text-xs text-red-400 truncate mb-1'>{backup.message}</p>
<p className='text-xs text-red-400 truncate mb-1.5'>{backup.message}</p>
)}
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
@@ -171,23 +191,23 @@ const BackupItem = ({ backup }: Props) => {
</div>
{/* Size info for completed backups */}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[80px]'>
<div className='hidden sm:block flex-shrink-0 text-right min-w-[90px]'>
{backup.completedAt && backup.isSuccessful && backup.bytes ? (
<>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>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-xs text-transparent uppercase tracking-wide mb-1'>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>
<div className='hidden sm:block flex-shrink-0 text-right min-w-[130px]'>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Created</p>
<p
className='text-sm text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
@@ -196,14 +216,14 @@ const BackupItem = ({ backup }: Props) => {
</p>
</div>
{/* Actions */}
<div className='flex-shrink-0 flex items-center gap-2'>
{/* Actions - fixed width to prevent layout shifts */}
<div className='flex-shrink-0 flex items-center gap-2 min-w-[68px] justify-end'>
{/* 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'
className='p-2 rounded-lg 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' />

View File

@@ -1,132 +1,14 @@
import { useCallback, useState } from 'react';
import { SocketEvent } from '@/components/server/events';
import { useCallback, useContext } from 'react';
import { ServerContext } from '@/state/server';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import getServerBackups from '@/api/swr/getServerBackups';
import { UnifiedBackup } from './BackupItem';
import { LiveProgressContext } from './BackupContainer';
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 liveProgress = useContext(LiveProgressContext);
const createBackup = useCallback(async (name: string, ignored: string, isLocked: boolean) => {
const { default: createServerBackup } = await import('@/api/server/backups/createServerBackup');
@@ -181,6 +63,7 @@ export const useUnifiedBackups = () => {
message: live ? live.message : (backup.isSuccessful ? 'Completed' : 'Failed'),
isSuccessful: backup.isSuccessful,
isLocked: backup.isLocked,
isAutomatic: backup.isAutomatic,
checksum: backup.checksum,
bytes: backup.bytes,
createdAt: backup.createdAt,
@@ -208,6 +91,7 @@ export const useUnifiedBackups = () => {
message: live.message,
isSuccessful: false,
isLocked: false,
isAutomatic: false,
checksum: undefined,
bytes: undefined,
createdAt: new Date(),
@@ -227,6 +111,7 @@ export const useUnifiedBackups = () => {
backups: unifiedBackups,
backupCount: backups?.backupCount || 0,
storage: backups?.storage,
pagination: backups?.pagination,
error,
isValidating,
createBackup,

View File

@@ -35,6 +35,7 @@ import HugeIconsFolder from '@/components/elements/hugeicons/Folder';
import HugeIconsHome from '@/components/elements/hugeicons/Home';
import HugeIconsPencil from '@/components/elements/hugeicons/Pencil';
import HugeIconsPeople from '@/components/elements/hugeicons/People';
import HugeIconsZap from '@/components/elements/hugeicons/Zap';
import ConflictStateRenderer from '@/components/server/ConflictStateRenderer';
import InstallListener from '@/components/server/InstallListener';
import TransferListener from '@/components/server/TransferListener';

View File

@@ -151,6 +151,10 @@ Route::group([
Route::get('/', [Client\Servers\BackupsController::class, 'index']);
Route::post('/', [Client\Servers\BackupsController::class, 'store'])
->middleware('server.operation.rate-limit');
Route::delete('/delete-all', [Client\Servers\BackupsController::class, 'deleteAll'])
->middleware('throttle:2,60');
Route::post('/bulk-delete', [Client\Servers\BackupsController::class, 'bulkDelete'])
->middleware('throttle:10,60');
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'])