mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
feature: deduplicated backups using elytra + backup storage quota + nullable server limits for unlimited (#359)
* feat: elytra integration with rustic * fix: rustic deduplication * fix: actually use snapshot id + don't guess type * fix: adapter_type * fix: wait no that's dumb * fix: use `disk` you moron (me) * fix: unique s3 repositories * fix: path duplication * fix: extra download verifications * fix: proper s3 prefix * feat: backup storage quota with deduplication + nullable limits * fix: actually handle unlimited, no access, etc * fix: more legacy code * fix: better formatting of backup limits * fix: snake_case lmao * fix: caveman brain * fix: don't try to estimate backup size * Made backup usage metrics better * fix: better results imo --------- Co-authored-by: naterfute <me@naterfute.dev>
This commit is contained in:
@@ -9,10 +9,9 @@ class TooManyBackupsException extends DisplayException
|
||||
/**
|
||||
* TooManyBackupsException constructor.
|
||||
*/
|
||||
public function __construct(int $backupLimit)
|
||||
public function __construct(int $backupLimit, ?string $customMessage = null)
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit)
|
||||
);
|
||||
$message = $customMessage ?? sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit);
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,26 @@ class BackupManager
|
||||
return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Rustic Local adapter.
|
||||
* Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly.
|
||||
*/
|
||||
public function createRusticLocalAdapter(array $config): FilesystemAdapter
|
||||
{
|
||||
// Return a minimal adapter since rustic operations are handled by Wings
|
||||
return new InMemoryFilesystemAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Rustic S3 adapter.
|
||||
* Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly.
|
||||
*/
|
||||
public function createRusticS3Adapter(array $config): FilesystemAdapter
|
||||
{
|
||||
// Return a minimal adapter since rustic operations are handled by Wings
|
||||
return new InMemoryFilesystemAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration associated with a given backup type.
|
||||
*/
|
||||
|
||||
@@ -145,7 +145,7 @@ class ServersController extends Controller
|
||||
$this->buildModificationService->handle($server, $request->only([
|
||||
'allocation_id', 'add_allocations', 'remove_allocations',
|
||||
'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk',
|
||||
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled',
|
||||
'database_limit', 'allocation_limit', 'backup_limit', 'backup_storage_limit', 'oom_disabled',
|
||||
'exclude_from_resource_calculation',
|
||||
]));
|
||||
} catch (DataValidationException $exception) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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\ServerStateService;
|
||||
@@ -33,6 +34,7 @@ class BackupController extends ClientApiController
|
||||
private DownloadLinkService $downloadLinkService,
|
||||
private BackupRepository $repository,
|
||||
private ServerStateService $serverStateService,
|
||||
private BackupStorageService $backupStorageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -56,10 +58,26 @@ class BackupController extends ClientApiController
|
||||
->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();
|
||||
}
|
||||
@@ -88,7 +106,11 @@ class BackupController extends ClientApiController
|
||||
|
||||
Activity::event('server:backup.start')
|
||||
->subject($backup)
|
||||
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
|
||||
->property([
|
||||
'name' => $backup->name,
|
||||
'locked' => (bool) $request->input('is_locked'),
|
||||
'adapter' => $backup->disk
|
||||
])
|
||||
->log();
|
||||
|
||||
return $this->fractal->item($backup)
|
||||
@@ -212,7 +234,14 @@ class BackupController extends ClientApiController
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) {
|
||||
$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.');
|
||||
}
|
||||
|
||||
@@ -277,10 +306,10 @@ class BackupController extends ClientApiController
|
||||
throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.');
|
||||
}
|
||||
|
||||
// If the backup is for an S3 file we need to generate a unique Download link for
|
||||
// it that will allow Wings to actually access the file.
|
||||
// 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 ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
if (in_array($backup->disk, [Backup::ADAPTER_AWS_S3, Backup::ADAPTER_RUSTIC_S3])) {
|
||||
try {
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -92,7 +92,11 @@ class NetworkAllocationController extends ClientApiController
|
||||
*/
|
||||
public function store(NewAllocationRequest $request, Server $server): array
|
||||
{
|
||||
if ($server->allocations()->count() >= $server->allocation_limit) {
|
||||
if (!$server->allowsAllocations()) {
|
||||
throw new DisplayException('Cannot assign allocations to this server: allocations are disabled.');
|
||||
}
|
||||
|
||||
if ($server->hasAllocationCountLimit() && $server->allocations()->count() >= $server->allocation_limit) {
|
||||
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class ScheduleTaskController extends ClientApiController
|
||||
throw new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit.");
|
||||
}
|
||||
|
||||
if ($server->backup_limit === 0 && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
|
||||
if (!$server->allowsBackups() && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server.");
|
||||
}
|
||||
|
||||
/** @var Task|null $lastTask */
|
||||
@@ -104,8 +104,8 @@ class ScheduleTaskController extends ClientApiController
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
if ($server->backup_limit === 0 && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
|
||||
if (!$server->allowsBackups() && $request->action === 'backup') {
|
||||
throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server.");
|
||||
}
|
||||
|
||||
$this->connection->transaction(function () use ($request, $schedule, $task) {
|
||||
|
||||
@@ -66,6 +66,7 @@ class BackupStatusController extends Controller
|
||||
'is_locked' => $successful ? $model->is_locked : false,
|
||||
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
|
||||
'bytes' => $successful ? $request->input('size') : 0,
|
||||
'snapshot_id' => $successful ? $request->input('snapshot_id') : null,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
])->save();
|
||||
|
||||
@@ -209,4 +210,5 @@ class BackupStatusController extends Controller
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
102
app/Http/Controllers/Api/Remote/RusticConfigController.php
Normal file
102
app/Http/Controllers/Api/Remote/RusticConfigController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Remote;
|
||||
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class RusticConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get rustic configuration for a server.
|
||||
* This endpoint is called by Wings to get the rustic backup configuration.
|
||||
*/
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$server = Server::where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$type = $request->query('type', 'local');
|
||||
|
||||
if (!in_array($type, ['local', 's3'])) {
|
||||
return response()->json(['error' => 'Invalid backup type'], 400);
|
||||
}
|
||||
|
||||
$config = [
|
||||
'backup_type' => $type,
|
||||
'repository_password' => $this->getRepositoryPassword($server),
|
||||
'repository_path' => $this->getRepositoryPath($server, $type),
|
||||
];
|
||||
|
||||
if ($type === 's3') {
|
||||
$s3Credentials = $this->getS3Credentials($server);
|
||||
if (!$s3Credentials) {
|
||||
return response()->json(['error' => 'S3 credentials not configured for this server'], 400);
|
||||
}
|
||||
$config['s3_credentials'] = $s3Credentials;
|
||||
}
|
||||
|
||||
return response()->json($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate server-specific repository password.
|
||||
* This password is derived from the server UUID and application key for consistency.
|
||||
*/
|
||||
private function getRepositoryPassword(Server $server): string
|
||||
{
|
||||
// Use a deterministic approach: hash server UUID with app key
|
||||
// This ensures the same password is generated each time for the same server
|
||||
return hash('sha256', $server->uuid . config('app.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server-specific repository path.
|
||||
*/
|
||||
private function getRepositoryPath(Server $server, string $type): string
|
||||
{
|
||||
if ($type === 'local') {
|
||||
$basePath = config('backups.disks.rustic_local.repository_base_path', '/var/lib/pterodactyl/rustic-repos');
|
||||
return rtrim($basePath, '/') . '/' . $server->uuid;
|
||||
}
|
||||
|
||||
// For S3, return the actual S3 path that rustic will use
|
||||
$s3Config = config('backups.disks.rustic_s3');
|
||||
$bucket = $s3Config['bucket'] ?? '';
|
||||
$prefix = rtrim($s3Config['prefix'] ?? 'pterodactyl-backups/', '/');
|
||||
|
||||
if (empty($bucket)) {
|
||||
throw new \InvalidArgumentException('S3 bucket not configured for rustic backups');
|
||||
}
|
||||
|
||||
// Return the full S3 path for this server's repository
|
||||
return sprintf('%s/%s/%s', $bucket, $prefix, $server->uuid);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get S3 credentials for rustic S3 backups from global configuration.
|
||||
*/
|
||||
private function getS3Credentials(Server $server): ?array
|
||||
{
|
||||
$config = config('backups.disks.rustic_s3');
|
||||
|
||||
if (empty($config['bucket'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_key_id' => $config['key'] ?? '',
|
||||
'secret_access_key' => $config['secret'] ?? '',
|
||||
'session_token' => '', // Not typically used for rustic
|
||||
'region' => $config['region'] ?? 'us-east-1',
|
||||
'bucket' => $config['bucket'],
|
||||
'endpoint' => $config['endpoint'] ?? '',
|
||||
'force_path_style' => $config['force_path_style'] ?? false,
|
||||
'disable_ssl' => $config['disable_ssl'] ?? false,
|
||||
'ca_cert_path' => $config['ca_cert_path'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class StoreServerRequest extends ApplicationApiRequest
|
||||
'feature_limits.databases' => $rules['database_limit'],
|
||||
'feature_limits.allocations' => $rules['allocation_limit'],
|
||||
'feature_limits.backups' => $rules['backup_limit'],
|
||||
'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'],
|
||||
|
||||
// Placeholders for rules added in withValidator() function.
|
||||
'allocation.default' => '',
|
||||
@@ -97,6 +98,7 @@ class StoreServerRequest extends ApplicationApiRequest
|
||||
'database_limit' => array_get($data, 'feature_limits.databases'),
|
||||
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
|
||||
'backup_limit' => array_get($data, 'feature_limits.backups'),
|
||||
'backup_storage_limit' => array_get($data, 'feature_limits.backup_storage_mb'),
|
||||
'oom_disabled' => array_get($data, 'oom_disabled'),
|
||||
'exclude_from_resource_calculation' => array_get($data, 'exclude_from_resource_calculation', false),
|
||||
];
|
||||
|
||||
@@ -49,6 +49,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
|
||||
'feature_limits.databases' => $rules['database_limit'],
|
||||
'feature_limits.allocations' => $rules['allocation_limit'],
|
||||
'feature_limits.backups' => $rules['backup_limit'],
|
||||
'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
|
||||
$data['database_limit'] = $data['feature_limits']['databases'] ?? null;
|
||||
$data['allocation_limit'] = $data['feature_limits']['allocations'] ?? null;
|
||||
$data['backup_limit'] = $data['feature_limits']['backups'] ?? null;
|
||||
$data['backup_storage_limit'] = $data['feature_limits']['backup_storage_mb'] ?? null;
|
||||
unset($data['allocation'], $data['feature_limits']);
|
||||
|
||||
// Adjust the limits field to match what is expected by the model.
|
||||
@@ -90,6 +92,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
|
||||
'feature_limits.databases' => 'Database Limit',
|
||||
'feature_limits.allocations' => 'Allocation Limit',
|
||||
'feature_limits.backups' => 'Backup Limit',
|
||||
'feature_limits.backup_storage_mb' => 'Backup Storage Limit (MB)',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ReportBackupCompleteRequest extends FormRequest
|
||||
'parts' => 'nullable|array',
|
||||
'parts.*.etag' => 'required|string',
|
||||
'parts.*.part_number' => 'required|numeric',
|
||||
'snapshot_id' => 'nullable|string|max:64',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
* @property string|null $checksum
|
||||
* @property int $bytes
|
||||
* @property string|null $upload_id
|
||||
* @property string|null $snapshot_id
|
||||
* @property \Carbon\CarbonImmutable|null $completed_at
|
||||
* @property \Carbon\CarbonImmutable $created_at
|
||||
* @property \Carbon\CarbonImmutable $updated_at
|
||||
@@ -36,6 +37,8 @@ class Backup extends Model
|
||||
|
||||
public const ADAPTER_WINGS = 'wings';
|
||||
public const ADAPTER_AWS_S3 = 's3';
|
||||
public const ADAPTER_RUSTIC_LOCAL = 'rustic_local';
|
||||
public const ADAPTER_RUSTIC_S3 = 'rustic_s3';
|
||||
|
||||
protected $table = 'backups';
|
||||
|
||||
@@ -57,10 +60,47 @@ class Backup extends Model
|
||||
'checksum' => null,
|
||||
'bytes' => 0,
|
||||
'upload_id' => null,
|
||||
'snapshot_id' => null,
|
||||
];
|
||||
|
||||
protected $guarded = ['id', 'created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Check if this backup uses the rustic backup system.
|
||||
*/
|
||||
public function isRustic(): bool
|
||||
{
|
||||
return in_array($this->disk, [self::ADAPTER_RUSTIC_LOCAL, self::ADAPTER_RUSTIC_S3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this backup is stored locally (not in cloud storage).
|
||||
*/
|
||||
public function isLocal(): bool
|
||||
{
|
||||
return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_RUSTIC_LOCAL]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository type for rustic backups.
|
||||
*/
|
||||
public function getRepositoryType(): ?string
|
||||
{
|
||||
return match($this->disk) {
|
||||
self::ADAPTER_RUSTIC_LOCAL => 'local',
|
||||
self::ADAPTER_RUSTIC_S3 => 's3',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this backup has a rustic snapshot ID.
|
||||
*/
|
||||
public function hasSnapshotId(): bool
|
||||
{
|
||||
return !empty($this->snapshot_id);
|
||||
}
|
||||
|
||||
public static array $validationRules = [
|
||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||
'uuid' => 'required|uuid',
|
||||
@@ -69,8 +109,9 @@ class Backup extends Model
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'server_state' => 'nullable|array',
|
||||
'disk' => 'required|string',
|
||||
'disk' => 'required|string|in:wings,s3,rustic_local,rustic_s3',
|
||||
'checksum' => 'nullable|string',
|
||||
'snapshot_id' => 'nullable|string|max:64',
|
||||
'bytes' => 'numeric',
|
||||
'upload_id' => 'nullable|string',
|
||||
];
|
||||
|
||||
@@ -203,12 +203,60 @@ class Node extends Model
|
||||
'sftp' => [
|
||||
'bind_port' => $this->daemonSFTP,
|
||||
],
|
||||
'backups' => [
|
||||
'rustic' => $this->getRusticBackupConfiguration(),
|
||||
],
|
||||
],
|
||||
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
|
||||
'remote' => route('index'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rustic backup configuration for Wings.
|
||||
* Matches the exact structure expected by elytra rustic implementation.
|
||||
*/
|
||||
private function getRusticBackupConfiguration(): array
|
||||
{
|
||||
$localConfig = config('backups.disks.rustic_local', []);
|
||||
$s3Config = config('backups.disks.rustic_s3', []);
|
||||
|
||||
return [
|
||||
// Path to rustic binary
|
||||
'binary_path' => $localConfig['binary_path'] ?? 'rustic',
|
||||
|
||||
// Repository version (optional, default handled by rustic)
|
||||
'repository_version' => $localConfig['repository_version'] ?? 2,
|
||||
|
||||
// Pack size configuration for performance tuning
|
||||
'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4,
|
||||
'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32,
|
||||
|
||||
// Local repository configuration
|
||||
'local' => [
|
||||
'enabled' => !empty($localConfig),
|
||||
'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos',
|
||||
'use_cold_storage' => $localConfig['use_cold_storage'] ?? false,
|
||||
'hot_repository_path' => $localConfig['hot_repository_path'] ?? '',
|
||||
],
|
||||
|
||||
// S3 repository configuration
|
||||
's3' => [
|
||||
'enabled' => !empty($s3Config['bucket']),
|
||||
'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',
|
||||
'force_path_style' => $s3Config['force_path_style'] ?? false,
|
||||
'disable_ssl' => $s3Config['disable_ssl'] ?? false,
|
||||
'ca_cert_path' => $s3Config['ca_cert_path'] ?? '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration in Yaml format.
|
||||
*/
|
||||
|
||||
@@ -42,7 +42,8 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
|
||||
* @property string $image
|
||||
* @property int|null $allocation_limit
|
||||
* @property int|null $database_limit
|
||||
* @property int $backup_limit
|
||||
* @property int|null $backup_limit
|
||||
* @property int|null $backup_storage_limit
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property \Illuminate\Support\Carbon|null $installed_at
|
||||
@@ -173,9 +174,10 @@ class Server extends Model
|
||||
'startup' => 'required|string',
|
||||
'skip_scripts' => 'sometimes|boolean',
|
||||
'image' => ['required', 'string', 'max:191', 'regex:/^~?[\w\.\/\-:@ ]*$/'],
|
||||
'database_limit' => 'present|nullable|integer|min:0',
|
||||
'allocation_limit' => 'sometimes|nullable|integer|min:0',
|
||||
'backup_limit' => 'present|nullable|integer|min:0',
|
||||
'database_limit' => 'nullable|integer|min:0',
|
||||
'allocation_limit' => 'nullable|integer|min:0',
|
||||
'backup_limit' => 'nullable|integer|min:0',
|
||||
'backup_storage_limit' => 'nullable|integer|min:0',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -199,6 +201,7 @@ class Server extends Model
|
||||
'database_limit' => 'integer',
|
||||
'allocation_limit' => 'integer',
|
||||
'backup_limit' => 'integer',
|
||||
'backup_storage_limit' => 'integer',
|
||||
self::CREATED_AT => 'datetime',
|
||||
self::UPDATED_AT => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
@@ -372,6 +375,56 @@ class Server extends Model
|
||||
return $this->hasMany(Backup::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this server has a backup storage limit configured.
|
||||
*/
|
||||
public function hasBackupStorageLimit(): bool
|
||||
{
|
||||
return !is_null($this->backup_storage_limit) && $this->backup_storage_limit > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backup storage limit in bytes.
|
||||
*/
|
||||
public function getBackupStorageLimitBytes(): ?int
|
||||
{
|
||||
if (!$this->hasBackupStorageLimit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) ($this->backup_storage_limit * 1024 * 1024);
|
||||
}
|
||||
|
||||
public function hasBackupCountLimit(): bool
|
||||
{
|
||||
return !is_null($this->backup_limit) && $this->backup_limit > 0;
|
||||
}
|
||||
|
||||
public function allowsBackups(): bool
|
||||
{
|
||||
return is_null($this->backup_limit) || $this->backup_limit > 0;
|
||||
}
|
||||
|
||||
public function hasDatabaseLimit(): bool
|
||||
{
|
||||
return !is_null($this->database_limit) && $this->database_limit > 0;
|
||||
}
|
||||
|
||||
public function allowsDatabases(): bool
|
||||
{
|
||||
return is_null($this->database_limit) || $this->database_limit > 0;
|
||||
}
|
||||
|
||||
public function hasAllocationLimit(): bool
|
||||
{
|
||||
return !is_null($this->allocation_limit) && $this->allocation_limit > 0;
|
||||
}
|
||||
|
||||
public function allowsAllocations(): bool
|
||||
{
|
||||
return is_null($this->allocation_limit) || $this->allocation_limit > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all mounts that have this server has mounted.
|
||||
*/
|
||||
|
||||
79
app/Services/Backups/BackupStorageService.php
Normal file
79
app/Services/Backups/BackupStorageService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,16 +23,24 @@ class DownloadLinkService
|
||||
*/
|
||||
public function handle(Backup $backup, User $user): string
|
||||
{
|
||||
// Validate backup can be downloaded
|
||||
$this->validateBackupForDownload($backup);
|
||||
|
||||
// Legacy S3 backups use pre-signed URLs
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
return $this->getS3BackupUrl($backup);
|
||||
}
|
||||
|
||||
// Wings local backups and Rustic backups (local & S3) use JWT tokens
|
||||
// Wings handles rustic downloads internally by calling back to get rustic config
|
||||
$token = $this->jwtService
|
||||
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
|
||||
->setUser($user)
|
||||
->setClaims([
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'server_uuid' => $backup->server->uuid,
|
||||
'backup_disk' => $backup->disk,
|
||||
'repository_type' => $backup->getRepositoryType(),
|
||||
])
|
||||
->handle($backup->server->node, $user->id . $backup->server->uuid);
|
||||
|
||||
@@ -59,4 +67,38 @@ class DownloadLinkService
|
||||
|
||||
return $request->getUri()->__toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a backup can be downloaded.
|
||||
*/
|
||||
protected function validateBackupForDownload(Backup $backup): void
|
||||
{
|
||||
// General backup validation
|
||||
if (!$backup->is_successful) {
|
||||
throw new \InvalidArgumentException('Cannot download a failed backup.');
|
||||
}
|
||||
|
||||
if (is_null($backup->completed_at)) {
|
||||
throw new \InvalidArgumentException('Cannot download backup that is still in progress.');
|
||||
}
|
||||
|
||||
// Rustic-specific validation
|
||||
if ($backup->isRustic()) {
|
||||
if (!$backup->hasSnapshotId()) {
|
||||
throw new \InvalidArgumentException('Rustic backup cannot be downloaded: missing snapshot ID.');
|
||||
}
|
||||
|
||||
// Validate snapshot ID format
|
||||
if (strlen($backup->snapshot_id) !== 64 && strlen($backup->snapshot_id) !== 8) {
|
||||
throw new \InvalidArgumentException('Rustic backup has invalid snapshot ID format.');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy S3 backup validation
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
if ($backup->bytes <= 0) {
|
||||
throw new \InvalidArgumentException('S3 backup has invalid size.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -20,6 +21,7 @@ class InitiateBackupService
|
||||
|
||||
private bool $isLocked = false;
|
||||
|
||||
|
||||
/**
|
||||
* InitiateBackupService constructor.
|
||||
*/
|
||||
@@ -30,6 +32,7 @@ class InitiateBackupService
|
||||
private DeleteBackupService $deleteBackupService,
|
||||
private BackupManager $backupManager,
|
||||
private ServerStateService $serverStateService,
|
||||
private BackupStorageService $backupStorageService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,6 +47,7 @@ class InitiateBackupService
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets the files to be ignored by this backup.
|
||||
*
|
||||
@@ -85,19 +89,26 @@ class InitiateBackupService
|
||||
throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.');
|
||||
}
|
||||
|
||||
// Check if the server has reached or exceeded its backup limit.
|
||||
// completed_at == null will cover any ongoing backups, while is_successful == true will cover any completed backups.
|
||||
$successful = $this->repository->getNonFailedBackups($server);
|
||||
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
|
||||
// Do not allow the user to continue if this server is already at its limit and can't override.
|
||||
if (!$override || $server->backup_limit <= 0) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
|
||||
// never be automatically purged). If we find a backup we will delete it and then continue with
|
||||
// this process. If no backup is found that can be used an exception is thrown.
|
||||
/** @var Backup $oldest */
|
||||
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
|
||||
if (!$oldest) {
|
||||
throw new TooManyBackupsException($server->backup_limit);
|
||||
@@ -107,27 +118,29 @@ class InitiateBackupService
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
// Sanitize backup name to prevent injection
|
||||
$backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString());
|
||||
$backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName);
|
||||
$backupName = substr($backupName, 0, 191); // Limit to database field length
|
||||
|
||||
$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' => $this->backupManager->getDefaultAdapter(),
|
||||
'disk' => $adapter,
|
||||
'is_locked' => $this->isLocked,
|
||||
'server_state' => $serverState,
|
||||
], true, true);
|
||||
|
||||
try {
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
->setBackupAdapter($this->backupManager->getDefaultAdapter())
|
||||
->setBackupAdapter($adapter)
|
||||
->backup($backup);
|
||||
} catch (\Exception $e) {
|
||||
// If daemon backup request fails, clean up the backup record
|
||||
@@ -160,4 +173,5 @@ class InitiateBackupService
|
||||
throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -76,9 +76,13 @@ class DatabaseManagementService
|
||||
}
|
||||
|
||||
if ($this->validateDatabaseLimit) {
|
||||
if (!$server->allowsDatabases()) {
|
||||
throw new TooManyDatabasesException();
|
||||
}
|
||||
|
||||
// If the server has a limit assigned and we've already reached that limit, throw back
|
||||
// an exception and kill the process.
|
||||
if (!is_null($server->database_limit) && $server->databases()->count() >= $server->database_limit) {
|
||||
if ($server->hasDatabaseCountLimit() && $server->databases()->count() >= $server->database_limit) {
|
||||
throw new TooManyDatabasesException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ class BuildModificationService
|
||||
$merge = Arr::only($data, ['oom_disabled', 'exclude_from_resource_calculation', 'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']);
|
||||
|
||||
$server->forceFill(array_merge($merge, [
|
||||
'database_limit' => Arr::get($data, 'database_limit', 0) ?? null,
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit', 0) ?? null,
|
||||
'backup_limit' => Arr::get($data, 'backup_limit', 0) ?? 0,
|
||||
'database_limit' => Arr::get($data, 'database_limit') === '' ? null : Arr::get($data, 'database_limit'),
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit') === '' ? null : Arr::get($data, 'allocation_limit'),
|
||||
'backup_limit' => Arr::get($data, 'backup_limit') === '' ? null : Arr::get($data, 'backup_limit'),
|
||||
'backup_storage_limit' => Arr::get($data, 'backup_storage_limit') === '' ? null : Arr::get($data, 'backup_storage_limit'),
|
||||
]))->saveOrFail();
|
||||
|
||||
return $server->refresh();
|
||||
|
||||
@@ -161,9 +161,10 @@ class ServerCreationService
|
||||
'egg_id' => Arr::get($data, 'egg_id'),
|
||||
'startup' => Arr::get($data, 'startup'),
|
||||
'image' => Arr::get($data, 'image'),
|
||||
'database_limit' => Arr::get($data, 'database_limit') ?? 0,
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0,
|
||||
'backup_limit' => Arr::get($data, 'backup_limit') ?? 0,
|
||||
'database_limit' => Arr::get($data, 'database_limit'),
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit'),
|
||||
'backup_limit' => Arr::get($data, 'backup_limit'),
|
||||
'backup_storage_limit' => Arr::get($data, 'backup_storage_limit'),
|
||||
]);
|
||||
|
||||
return $model;
|
||||
|
||||
@@ -75,6 +75,7 @@ class ServerTransformer extends BaseTransformer
|
||||
'databases' => $server->database_limit,
|
||||
'allocations' => $server->allocation_limit,
|
||||
'backups' => $server->backup_limit,
|
||||
'backup_storage_mb' => $server->backup_storage_limit,
|
||||
],
|
||||
'user' => $server->owner_id,
|
||||
'node' => $server->node_id,
|
||||
|
||||
@@ -27,6 +27,10 @@ class BackupTransformer extends BaseClientTransformer
|
||||
'ignored_files' => $backup->ignored_files,
|
||||
'checksum' => $backup->checksum,
|
||||
'bytes' => $backup->bytes,
|
||||
'size_gb' => round($backup->bytes / (1024 * 1024 * 1024), 3),
|
||||
'adapter' => $backup->disk,
|
||||
'is_rustic' => $backup->isRustic(),
|
||||
'snapshot_id' => $backup->snapshot_id,
|
||||
'created_at' => $backup->created_at->toAtomString(),
|
||||
'completed_at' => $backup->completed_at ? $backup->completed_at->toAtomString() : null,
|
||||
];
|
||||
|
||||
@@ -71,6 +71,7 @@ class ServerTransformer extends BaseClientTransformer
|
||||
'databases' => $server->database_limit,
|
||||
'allocations' => $server->allocation_limit,
|
||||
'backups' => $server->backup_limit,
|
||||
'backupStorageMb' => $server->backup_storage_limit,
|
||||
],
|
||||
'status' => $server->status,
|
||||
// This field is deprecated, please use "status".
|
||||
|
||||
@@ -6,7 +6,8 @@ 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.
|
||||
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_WINGS),
|
||||
// Options: wings, 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
|
||||
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.
|
||||
@@ -52,5 +53,53 @@ return [
|
||||
|
||||
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
|
||||
],
|
||||
|
||||
// Configuration for Rustic local backups. Rustic provides deduplicated,
|
||||
// encrypted backups with fast incremental snapshots.
|
||||
'rustic_local' => [
|
||||
'adapter' => Backup::ADAPTER_RUSTIC_LOCAL,
|
||||
|
||||
// Path to rustic binary
|
||||
'binary_path' => env('RUSTIC_BINARY_PATH', 'rustic'),
|
||||
|
||||
// Base path where rustic repositories will be stored (one per server)
|
||||
'repository_base_path' => env('RUSTIC_REPOSITORY_BASE_PATH', '/var/lib/pterodactyl/rustic-repos'),
|
||||
|
||||
// Repository version (optional, default handled by rustic)
|
||||
'repository_version' => env('RUSTIC_REPOSITORY_VERSION', 2),
|
||||
|
||||
// Pack size configuration for performance tuning
|
||||
'tree_pack_size_mb' => env('RUSTIC_TREE_PACK_SIZE_MB', 4),
|
||||
'data_pack_size_mb' => env('RUSTIC_DATA_PACK_SIZE_MB', 32),
|
||||
|
||||
// Hot/cold storage setup option
|
||||
'use_cold_storage' => env('RUSTIC_LOCAL_USE_COLD_STORAGE', false),
|
||||
'hot_repository_path' => env('RUSTIC_LOCAL_HOT_REPOSITORY_PATH', ''),
|
||||
],
|
||||
|
||||
// Configuration for Rustic S3 backups. Combines Rustic's features with S3 storage.
|
||||
'rustic_s3' => [
|
||||
'adapter' => Backup::ADAPTER_RUSTIC_S3,
|
||||
|
||||
// S3 configuration
|
||||
'endpoint' => env('RUSTIC_S3_ENDPOINT', env('AWS_ENDPOINT')),
|
||||
'region' => env('RUSTIC_S3_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')),
|
||||
'bucket' => env('RUSTIC_S3_BUCKET'),
|
||||
'prefix' => env('RUSTIC_S3_PREFIX', 'pterodactyl-backups/'),
|
||||
|
||||
// S3 credentials
|
||||
'key' => env('RUSTIC_S3_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')),
|
||||
'secret' => env('RUSTIC_S3_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')),
|
||||
|
||||
// Hot/cold storage configuration
|
||||
'use_cold_storage' => env('RUSTIC_S3_USE_COLD_STORAGE', false),
|
||||
'hot_bucket' => env('RUSTIC_S3_HOT_BUCKET', ''),
|
||||
'cold_storage_class' => env('RUSTIC_S3_COLD_STORAGE_CLASS', 'GLACIER'),
|
||||
|
||||
// Connection options
|
||||
'force_path_style' => env('RUSTIC_S3_FORCE_PATH_STYLE', false),
|
||||
'disable_ssl' => env('RUSTIC_S3_DISABLE_SSL', false),
|
||||
'ca_cert_path' => env('RUSTIC_S3_CA_CERT_PATH', ''),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -42,7 +42,8 @@ class ServerFactory extends Factory
|
||||
'image' => 'foo/bar:latest',
|
||||
'allocation_limit' => null,
|
||||
'database_limit' => null,
|
||||
'backup_limit' => 0,
|
||||
'backup_limit' => null,
|
||||
'backup_storage_limit' => null,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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) {
|
||||
// Modify the disk column to support rustic adapters
|
||||
$table->enum('disk', ['wings', 's3', 'rustic_local', 'rustic_s3'])->default('wings')->change();
|
||||
|
||||
// Add rustic-specific columns
|
||||
$table->string('snapshot_id', 64)->nullable()->after('checksum')->comment('Rustic snapshot ID for rustic backups');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
// Revert disk column to original enum values
|
||||
$table->enum('disk', ['wings', 's3'])->default('wings')->change();
|
||||
|
||||
// Drop rustic-specific columns
|
||||
$table->dropColumn('snapshot_id');
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
@@ -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->unsignedInteger('backup_storage_limit')->nullable()->after('backup_limit');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('backup_storage_limit');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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::table('servers', function (Blueprint $table) {
|
||||
$table->unsignedInteger('database_limit')->nullable()->change();
|
||||
$table->unsignedInteger('allocation_limit')->nullable()->change();
|
||||
$table->unsignedInteger('backup_limit')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->unsignedInteger('database_limit')->default(0)->change();
|
||||
$table->unsignedInteger('allocation_limit')->default(0)->change();
|
||||
$table->unsignedInteger('backup_limit')->default(0)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -39,6 +39,7 @@ export interface Server {
|
||||
databases: number;
|
||||
allocations: number;
|
||||
backups: number;
|
||||
backupStorageMb: number | null;
|
||||
};
|
||||
isTransferring: boolean;
|
||||
variables: ServerEggVariable[];
|
||||
|
||||
4
resources/scripts/api/server/types.d.ts
vendored
4
resources/scripts/api/server/types.d.ts
vendored
@@ -14,6 +14,10 @@ export interface ServerBackup {
|
||||
ignoredFiles: string;
|
||||
checksum: string;
|
||||
bytes: number;
|
||||
sizeGb: number;
|
||||
adapter: string;
|
||||
isRustic: boolean;
|
||||
snapshotId: string | null;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,23 @@ interface ctx {
|
||||
|
||||
export const Context = createContext<ctx>({ page: 1, setPage: () => 1 });
|
||||
|
||||
type BackupResponse = PaginatedResult<ServerBackup> & { backupCount: number };
|
||||
type BackupResponse = PaginatedResult<ServerBackup> & {
|
||||
backupCount: number;
|
||||
storage: {
|
||||
used_mb: number;
|
||||
limit_mb: number | null;
|
||||
has_limit: boolean;
|
||||
usage_percentage: number | null;
|
||||
available_mb: number | null;
|
||||
is_over_limit: boolean;
|
||||
};
|
||||
limits: {
|
||||
count_limit: number | null;
|
||||
has_count_limit: boolean;
|
||||
storage_limit_mb: number | null;
|
||||
has_storage_limit: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const { page } = useContext(Context);
|
||||
@@ -28,6 +44,8 @@ export default () => {
|
||||
items: (data.data || []).map(rawDataToServerBackup),
|
||||
pagination: getPaginationSet(data.meta.pagination),
|
||||
backupCount: data.meta.backup_count,
|
||||
storage: data.meta.storage,
|
||||
limits: data.meta.limits,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -62,6 +62,10 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
checksum: attributes.checksum,
|
||||
bytes: attributes.bytes,
|
||||
sizeGb: attributes.size_gb,
|
||||
adapter: attributes.adapter,
|
||||
isRustic: attributes.is_rustic,
|
||||
snapshotId: attributes.snapshot_id,
|
||||
createdAt: new Date(attributes.created_at),
|
||||
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
|
||||
});
|
||||
|
||||
@@ -104,9 +104,9 @@ interface ServerMobileMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
serverId?: string;
|
||||
databaseLimit?: number;
|
||||
backupLimit?: number;
|
||||
allocationLimit?: number;
|
||||
databaseLimit?: number | null;
|
||||
backupLimit?: number | null;
|
||||
allocationLimit?: number | null;
|
||||
subdomainSupported?: boolean;
|
||||
}
|
||||
|
||||
@@ -114,9 +114,9 @@ export const ServerMobileMenu = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
serverId,
|
||||
databaseLimit = 0,
|
||||
backupLimit = 0,
|
||||
allocationLimit = 0,
|
||||
databaseLimit,
|
||||
backupLimit,
|
||||
allocationLimit,
|
||||
subdomainSupported = false,
|
||||
}: ServerMobileMenuProps) => {
|
||||
const NavigationItem = ({
|
||||
@@ -162,7 +162,7 @@ export const ServerMobileMenu = ({
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
{databaseLimit > 0 && (
|
||||
{databaseLimit !== 0 && (
|
||||
<Can action={'database.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/databases`} icon={HugeIconsDatabase} end>
|
||||
Databases
|
||||
@@ -170,7 +170,7 @@ export const ServerMobileMenu = ({
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{backupLimit > 0 && (
|
||||
{backupLimit !== 0 && (
|
||||
<Can action={'backup.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/backups`} icon={HugeIconsCloudUp} end>
|
||||
Backups
|
||||
|
||||
@@ -24,6 +24,17 @@ import { ServerContext } from '@/state/server';
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
// Helper function to format storage values
|
||||
const formatStorage = (mb: number | undefined | null): string => {
|
||||
if (mb === null || mb === undefined) {
|
||||
return '0MB';
|
||||
}
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(1)}GB`;
|
||||
}
|
||||
return `${mb.toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
interface BackupValues {
|
||||
name: string;
|
||||
ignored: string;
|
||||
@@ -90,6 +101,8 @@ const BackupContainer = () => {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
const hasBackupsInProgress = backups?.items.some((backup) => backup.completedAt === null) || false;
|
||||
|
||||
@@ -166,16 +179,57 @@ const BackupContainer = () => {
|
||||
titleChildren={
|
||||
<Can action={'backup.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
{backupLimit > 0 && (
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{backups.backupCount} of {backupLimit} backups
|
||||
</p>
|
||||
)}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
<div className='flex flex-col gap-1 text-center sm:text-right'>
|
||||
{/* Backup Count Display */}
|
||||
{backupLimit === null && (
|
||||
<p className='text-sm text-zinc-300'>
|
||||
{backups.backupCount} backups
|
||||
</p>
|
||||
)}
|
||||
{backupLimit > 0 && (
|
||||
<p className='text-sm text-zinc-300'>
|
||||
{backups.backupCount} of {backupLimit} backups
|
||||
</p>
|
||||
)}
|
||||
{backupLimit === 0 && (
|
||||
<p className='text-sm text-red-400'>
|
||||
Backups disabled
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Storage Usage Display */}
|
||||
{backups.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)`}
|
||||
>
|
||||
<span className='font-medium'>{formatStorage(backups.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)`}
|
||||
>
|
||||
<span className='font-medium'>{formatStorage(backups.storage.used_mb)}</span> {' '}
|
||||
{backupStorageLimit === null ?
|
||||
"used" :
|
||||
(<span className='font-medium'>of {formatStorage(backupStorageLimit)} used</span>)}
|
||||
</p>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(backupLimit === null || backupLimit > backups.backupCount) &&
|
||||
(!backupStorageLimit || !backups.storage?.is_over_limit) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
}
|
||||
@@ -215,12 +269,12 @@ const BackupContainer = () => {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
|
||||
{backupLimit > 0 ? 'No backups found' : 'Backups unavailable'}
|
||||
{backupLimit === 0 ? 'Backups unavailable' : 'No backups found'}
|
||||
</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm'>
|
||||
{backupLimit > 0
|
||||
? 'Your server does not have any backups. Create one to get started.'
|
||||
: 'Backups cannot be created for this server.'}
|
||||
{backupLimit === 0
|
||||
? 'Backups cannot be created for this server.'
|
||||
: 'Your server does not have any backups. Create one to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
|
||||
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => {
|
||||
try {
|
||||
// When backup completes, refresh the backup list from API to get accurate completion time
|
||||
// This ensures we get the exact completion timestamp from the database, not the websocket receive time
|
||||
await mutate();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
@@ -36,7 +34,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
return (
|
||||
<PageListItem>
|
||||
<div className='flex items-center gap-4 w-full py-1'>
|
||||
{/* Status Icon */}
|
||||
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
{backup.completedAt === null ? (
|
||||
<Spinner size={'small'} />
|
||||
@@ -49,7 +46,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
{backup.completedAt !== null && !backup.isSuccessful && (
|
||||
@@ -69,7 +65,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
|
||||
</div>
|
||||
|
||||
{/* Size Info */}
|
||||
{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>
|
||||
@@ -77,7 +72,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Info */}
|
||||
<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
|
||||
@@ -88,7 +82,6 @@ const BackupRow = ({ backup }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions Menu */}
|
||||
<div className='flex-shrink-0'>
|
||||
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
|
||||
{backup.completedAt ? <BackupContextMenu backup={backup} /> : null}
|
||||
|
||||
@@ -92,12 +92,22 @@ const DatabasesContainer = () => {
|
||||
titleChildren={
|
||||
<Can action={'database.create'}>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
|
||||
{databaseLimit === null && (
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{databases.length} databases (unlimited)
|
||||
</p>
|
||||
)}
|
||||
{databaseLimit > 0 && (
|
||||
<p className='text-sm text-zinc-300 text-center sm:text-right'>
|
||||
{databases.length} of {databaseLimit} databases
|
||||
</p>
|
||||
)}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length && (
|
||||
{databaseLimit === 0 && (
|
||||
<p className='text-sm text-red-400 text-center sm:text-right'>
|
||||
Databases disabled
|
||||
</p>
|
||||
)}
|
||||
{(databaseLimit === null || (databaseLimit > 0 && databaseLimit !== databases.length)) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
New Database
|
||||
</ActionButton>
|
||||
@@ -177,12 +187,12 @@ const DatabasesContainer = () => {
|
||||
<HugeIconsDatabase className='w-8 h-8 text-zinc-400' fill='currentColor' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
|
||||
{databaseLimit > 0 ? 'No databases found' : 'Databases unavailable'}
|
||||
{databaseLimit === 0 ? 'Databases unavailable' : 'No databases found'}
|
||||
</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm'>
|
||||
{databaseLimit > 0
|
||||
? 'Your server does not have any databases. Create one to get started.'
|
||||
: 'Databases cannot be created for this server.'}
|
||||
{databaseLimit === 0
|
||||
? 'Databases cannot be created for this server.'
|
||||
: 'Your server does not have any databases. Create one to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,13 +73,25 @@ const NetworkContainer = () => {
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-6 shadow-sm mt-8'>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<h3 className='text-xl font-extrabold tracking-tight'>Port Allocations</h3>
|
||||
{data && allocationLimit > 0 && (
|
||||
{data && (
|
||||
<Can action={'allocation.create'}>
|
||||
<div className='flex items-center gap-4'>
|
||||
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
{data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit}
|
||||
</span>
|
||||
{allocationLimit > data.filter((allocation) => !allocation.isDefault).length && (
|
||||
{allocationLimit === null && (
|
||||
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
{data.filter((allocation) => !allocation.isDefault).length} allocations (unlimited)
|
||||
</span>
|
||||
)}
|
||||
{allocationLimit > 0 && (
|
||||
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
{data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit}
|
||||
</span>
|
||||
)}
|
||||
{allocationLimit === 0 && (
|
||||
<span className='text-sm text-red-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
Allocations disabled
|
||||
</span>
|
||||
)}
|
||||
{(allocationLimit === null || (allocationLimit > 0 && allocationLimit > data.filter((allocation) => !allocation.isDefault).length)) && (
|
||||
<ActionButton variant='primary' onClick={onCreateAllocation} size='sm'>
|
||||
New Allocation
|
||||
</ActionButton>
|
||||
@@ -120,12 +132,12 @@ const NetworkContainer = () => {
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className='text-lg font-medium text-zinc-200 mb-2'>
|
||||
{allocationLimit > 0 ? 'No allocations found' : 'Allocations unavailable'}
|
||||
{allocationLimit === 0 ? 'Allocations unavailable' : 'No allocations found'}
|
||||
</h4>
|
||||
<p className='text-sm text-zinc-400 max-w-sm text-center'>
|
||||
{allocationLimit > 0
|
||||
? 'Create your first allocation to get started.'
|
||||
: 'Network allocations cannot be created for this server.'}
|
||||
{allocationLimit === 0
|
||||
? 'Network allocations cannot be created for this server.'
|
||||
: 'Create your first allocation to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,9 +51,9 @@ const blank_egg_prefix = '@';
|
||||
// Sidebar item components that check both permissions and feature limits
|
||||
const DatabasesSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
|
||||
// Hide if no database access (limit is 0)
|
||||
// Hide if databases are disabled (limit is 0)
|
||||
if (databaseLimit === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -76,9 +76,9 @@ DatabasesSidebarItem.displayName = 'DatabasesSidebarItem';
|
||||
|
||||
const BackupsSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
|
||||
// Hide if no backup access (limit is 0)
|
||||
// Hide if backups are disabled (limit is 0)
|
||||
if (backupLimit === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -165,9 +165,9 @@ const ServerRouter = () => {
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const clearServerState = ServerContext.useStoreActions((actions) => actions.clearServerState);
|
||||
const egg_id = ServerContext.useStoreState((state) => state.server.data?.egg);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0);
|
||||
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations ?? 0);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations);
|
||||
|
||||
// Mobile menu state
|
||||
const [isMobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
|
||||
@@ -112,23 +112,31 @@
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="pDatabaseLimit" class="control-label">Database Limit</label>
|
||||
<div>
|
||||
<input type="text" id="pDatabaseLimit" name="database_limit" class="form-control" value="{{ old('database_limit', 0) }}"/>
|
||||
<input type="text" id="pDatabaseLimit" name="database_limit" class="form-control" value="{{ old('database_limit') }}" placeholder="Leave blank for unlimited"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of databases a user is allowed to create for this server.</p>
|
||||
<p class="text-muted small">The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="pAllocationLimit" class="control-label">Allocation Limit</label>
|
||||
<div>
|
||||
<input type="text" id="pAllocationLimit" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', 0) }}"/>
|
||||
<input type="text" id="pAllocationLimit" name="allocation_limit" class="form-control" value="{{ old('allocation_limit') }}" placeholder="Leave blank for unlimited"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of allocations a user is allowed to create for this server.</p>
|
||||
<p class="text-muted small">The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="pBackupLimit" class="control-label">Backup Limit</label>
|
||||
<div>
|
||||
<input type="text" id="pBackupLimit" name="backup_limit" class="form-control" value="{{ old('backup_limit', 0) }}"/>
|
||||
<input type="text" id="pBackupLimit" name="backup_limit" class="form-control" value="{{ old('backup_limit') }}" placeholder="Leave blank for unlimited"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of backups that can be created for this server.</p>
|
||||
<p class="text-muted small">The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="pBackupStorageLimit" class="control-label">Backup Storage Limit</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="pBackupStorageLimit" name="backup_storage_limit" data-multiplicator="true" class="form-control" value="{{ old('backup_storage_limit') }}" placeholder="Leave blank for unlimited"/>
|
||||
<span class="input-group-addon">MiB</span>
|
||||
</div>
|
||||
<p class="text-muted small">The total storage space that can be used for backups. Leave blank for unlimited storage.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,21 +127,29 @@
|
||||
<div>
|
||||
<input type="text" name="database_limit" class="form-control" value="{{ old('database_limit', $server->database_limit) }}"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of databases a user is allowed to create for this server.</p>
|
||||
<p class="text-muted small">The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="allocation_limit" class="control-label">Allocation Limit</label>
|
||||
<div>
|
||||
<input type="text" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', $server->allocation_limit) }}"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of allocations a user is allowed to create for this server.</p>
|
||||
<p class="text-muted small">The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="backup_limit" class="control-label">Backup Limit</label>
|
||||
<div>
|
||||
<input type="text" name="backup_limit" class="form-control" value="{{ old('backup_limit', $server->backup_limit) }}"/>
|
||||
</div>
|
||||
<p class="text-muted small">The total number of backups that can be created for this server.</p>
|
||||
<p class="text-muted small">The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-6">
|
||||
<label for="backup_storage_limit" class="control-label">Backup Storage Limit</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="backup_storage_limit" data-multiplicator="true" class="form-control" value="{{ old('backup_storage_limit', $server->backup_storage_limit) }}"/>
|
||||
<span class="input-group-addon">MiB</span>
|
||||
</div>
|
||||
<p class="text-muted small">The total storage space that can be used for backups. Leave blank for unlimited storage.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ Route::group(['prefix' => '/servers/{uuid}'], function () {
|
||||
Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']);
|
||||
Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']);
|
||||
|
||||
Route::get('/rustic-config', [Remote\RusticConfigController::class, 'show']);
|
||||
|
||||
Route::get('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
|
||||
Route::get('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']);
|
||||
Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
|
||||
|
||||
Reference in New Issue
Block a user