Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-07-20 12:09:17 +02:00
commit ee36124300
82 files changed
+8190 -463

No files matched your search

@@ -0,0 +1,145 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
#[Locked]
public Application $application;
public ?string $selectedTargetKey = null;
public ?string $targetKey = null;
public bool $targetLocked = false;
public string $frequency = 'daily';
public Collection $targets;
protected function rules(): array
{
return [
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
'frequency' => ['required', 'string'],
];
}
public function mount(): void
{
$this->authorize('view', $this->application);
$volumes = $this->application->persistentStorages()
->orderBy('name')
->get()
->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id,
'type' => 'Volume',
'name' => $volume->name,
]);
$directories = $this->application->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->orderBy('fs_path')
->get()
->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id,
'type' => 'Directory',
'name' => $directory->fs_path,
]);
$this->targets = $volumes->concat($directories)->values();
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
public function updatedTargetKey(): void
{
$this->loadSelectedBackup();
}
public function submit(): void
{
$this->authorize('update', $this->application);
$this->validate();
$target = $this->selectedTarget();
if (! $target) {
$this->addError('targetKey', 'Select a volume or directory owned by this application.');
return;
}
if (! validate_cron_expression($this->frequency)) {
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
return;
}
try {
$backup = $target->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
],
);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
redirectRoute($this, 'project.application.backup.show', [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
'backup_uuid' => $backup->uuid,
]);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.application.backup.create');
}
private function loadSelectedBackup(): void
{
$target = $this->selectedTarget();
if (! $target) {
return;
}
$backup = $target->scheduledBackups()->first();
if ($backup) {
$this->frequency = $backup->frequency;
}
}
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
{
[$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null);
if (! ctype_digit((string) $id)) {
return null;
}
return match ($type) {
'volume' => $this->application->persistentStorages()->whereKey((int) $id)->first(),
'directory' => $this->application->fileStorages()
->whereKey((int) $id)
->where('is_directory', true)
->where('is_host_file', false)
->first(),
default => null,
};
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Index extends Component
{
use AuthorizesRequests;
public Application $application;
public array $parameters;
public string $search = '';
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
public function mount(): void
{
$this->application = $this->findApplication();
$this->authorize('view', $this->application);
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
}
public function render(): View
{
$backups = ScheduledVolumeBackup::query()
->with(['backupable', 'latestExecution'])
->withCount('executions')
->forApplication($this->application)
->latest()
->get();
return view('livewire.project.application.backup.index', ['backups' => $backups]);
}
private function findApplication(): Application
{
$project = currentTeam()->projects()
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
return $environment->applications()
->with('destination.server')
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Application $application;
public ScheduledVolumeBackup $backup;
public array $parameters;
public string $section = 'general';
public function mount(): void
{
$project = currentTeam()->projects()
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
->where('uuid', request()->route('environment_uuid'))
->firstOrFail();
$this->application = $environment->applications()
->with('destination.server')
->where('uuid', request()->route('application_uuid'))
->firstOrFail();
$this->authorize('view', $this->application);
$this->backup = ScheduledVolumeBackup::query()
->with('backupable')
->where('uuid', request()->route('backup_uuid'))
->forApplication($this->application)
->firstOrFail();
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.application.backup.s3' => 's3',
'project.application.backup.retention' => 'retention',
'project.application.backup.executions' => 'executions',
'project.application.backup.danger' => 'danger',
default => 'general',
};
}
public function render()
{
return view('livewire.project.application.backup.show');
}
}
@@ -15,6 +15,10 @@ class Execution extends Component
public $s3s;
public array $parameters = [];
public string $section = 'general';
public function mount()
{
$backup_uuid = request()->route('backup_uuid');
@@ -39,6 +43,14 @@ class Execution extends Component
$this->backup = $backup;
$this->executions = $executions;
$this->s3s = currentTeam()->s3s;
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.database.backup.s3' => 's3',
'project.database.backup.retention' => 'retention',
'project.database.backup.executions' => 'executions',
'project.database.backup.danger' => 'danger',
default => 'general',
};
}
public function render()
+85 -1
View File
@@ -9,6 +9,7 @@ use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -19,6 +20,8 @@ class BackupEdit extends Component
public ScheduledDatabaseBackup $backup;
public string $section = 'general';
#[Locked]
public $availableS3Storages;
@@ -82,17 +85,49 @@ class BackupEdit extends Component
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int|string $timeout = 3600;
public function getListeners(): array
{
// Keep "Backup Now" in sync when the database starts/stops without a full page refresh.
$listeners = ['databaseUpdated' => 'refreshStatus'];
$user = Auth::user();
if (! $user) {
return $listeners;
}
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
$team = $user->currentTeam();
if ($team) {
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
}
return $listeners;
}
public function mount()
{
try {
$this->authorize('view', $this->backup->database);
$this->parameters = get_route_parameters();
$this->syncData();
$this->refreshStatus();
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refreshStatus(): void
{
$database = $this->backup->database;
if (! $database) {
return;
}
$database->refresh();
$this->status = $database->status;
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
@@ -184,7 +219,11 @@ class BackupEdit extends Component
'stack_service_uuid' => $serviceDatabase->uuid,
]);
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
return redirect()->route('project.database.backup.index', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'database_uuid' => $this->parameters['database_uuid'],
]);
}
} catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
@@ -200,6 +239,25 @@ class BackupEdit extends Component
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
$database = $this->backup->database;
if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [
'project_uuid' => $database->service->project()->uuid,
'environment_uuid' => $database->service->environment->uuid,
'service_uuid' => $database->service->uuid,
'stack_service_uuid' => $database->uuid,
'backup_uuid' => $this->backup->uuid,
]);
}
return redirect()->route('project.database.backup.executions', [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $this->backup->uuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -217,11 +275,37 @@ class BackupEdit extends Component
}
}
public function toggleEnabled(): void
{
try {
$this->authorize('manageBackups', $this->backup->database);
$this->backupEnabled = ! $this->backupEnabled;
$this->backup->update(['enabled' => $this->backupEnabled]);
$this->dispatch('success', $this->backupEnabled ? 'Backup enabled.' : 'Backup disabled.');
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
}
}
public function updatedS3StorageId(): void
{
$this->instantSave();
}
public function toggleS3(): void
{
if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) {
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
return;
}
$this->saveS3 = ! $this->saveS3;
$this->disableLocalBackup = $this->saveS3 && $this->disableLocalBackup;
$this->instantSave();
}
private function customValidate()
{
if (! is_numeric($this->backup->s3_storage_id)) {
@@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -18,31 +16,11 @@ class CreateScheduledBackup extends Component
#[Validate(['required', 'string'])]
public $frequency;
#[Validate(['required', 'boolean'])]
public bool $saveToS3 = false;
#[Locked]
public $database;
public bool $enabled = true;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = null;
public Collection $definedS3s;
public function mount()
{
try {
$this->definedS3s = currentTeam()->s3s;
if ($this->definedS3s->count() > 0) {
$this->s3StorageId = $this->definedS3s->first()->id;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
@@ -56,20 +34,6 @@ class CreateScheduledBackup extends Component
$this->validate();
if ($this->saveToS3) {
$s3StorageExists = ! is_null($this->s3StorageId)
&& S3Storage::where('team_id', currentTeam()->id)
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
if (! $s3StorageExists) {
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
return;
}
}
$isValid = validate_cron_expression($this->frequency);
if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.');
@@ -80,8 +44,8 @@ class CreateScheduledBackup extends Component
$payload = [
'enabled' => true,
'frequency' => $this->frequency,
'save_s3' => $this->saveToS3,
's3_storage_id' => $this->s3StorageId,
'save_s3' => false,
's3_storage_id' => null,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => currentTeam()->id,
@@ -99,11 +63,22 @@ class CreateScheduledBackup extends Component
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
$service = $this->database->service;
$this->redirectRoute('project.service.database.backup.show', [
'project_uuid' => $service->project()->uuid,
'environment_uuid' => $service->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $this->database->uuid,
'backup_uuid' => $databaseBackup->uuid,
], navigate: true);
} else {
$this->dispatch('refreshScheduledBackups');
$this->redirectRoute('project.database.backup.execution', [
'project_uuid' => $this->database->project()->uuid,
'environment_uuid' => $this->database->environment->uuid,
'database_uuid' => $this->database->uuid,
'backup_uuid' => $databaseBackup->uuid,
], navigate: true);
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@@ -2,8 +2,8 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -17,42 +17,18 @@ class ScheduledBackups extends Component
public $type;
public ?ScheduledDatabaseBackup $selectedBackup;
public $selectedBackupId;
public $s3s;
public string $custom_type = 'mysql';
protected $listeners = ['refreshScheduledBackups'];
protected $queryString = ['selectedBackupId'];
public function mount(): void
{
if ($this->selectedBackupId) {
$this->setSelectedBackup($this->selectedBackupId, true);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->type = 'service-database';
} else {
$this->type = 'database';
}
$this->s3s = currentTeam()->s3s;
}
public function setSelectedBackup($backupId, $force = false)
{
if ($this->selectedBackupId === $backupId && ! $force) {
return;
}
$this->selectedBackupId = $backupId;
$this->selectedBackup = $this->database->scheduledBackups->find($backupId);
if (is_null($this->selectedBackup)) {
$this->selectedBackupId = null;
}
}
public function setCustomType()
@@ -86,9 +62,13 @@ class ScheduledBackups extends Component
public function refreshScheduledBackups(?int $id = null): void
{
$this->database->refresh();
if ($id) {
$this->setSelectedBackup($id);
}
$this->dispatch('refreshScheduledBackups');
}
public function render(): View
{
$this->database->loadMissing('scheduledBackups.s3');
return view('livewire.project.database.scheduled-backups');
}
}
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -17,16 +18,28 @@ class DatabaseBackups extends Component
public array $parameters;
public array $backupParameters = [];
public array $query;
public bool $isImportSupported = false;
public ?ScheduledDatabaseBackup $backup = null;
public string $section = 'index';
public $s3s;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->parameters = array_filter(
get_route_parameters(),
fn (string $key): bool => $key !== 'backup_uuid',
ARRAY_FILTER_USE_KEY,
);
$this->query = request()->query();
$project = currentTeam()
->projects()
@@ -58,6 +71,21 @@ class DatabaseBackups extends Component
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
if (request()->route('backup_uuid')) {
$this->backup = $this->serviceDatabase->scheduledBackups()
->where('uuid', request()->route('backup_uuid'))
->firstOrFail();
$this->s3s = currentTeam()->s3s;
$this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid];
$this->section = match (request()->route()?->getName()) {
'project.service.database.backup.s3' => 's3',
'project.service.database.backup.retention' => 'retention',
'project.service.database.backup.executions' => 'executions',
'project.service.database.backup.danger' => 'danger',
default => 'general',
};
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\ScheduledVolumeBackup;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
@@ -15,6 +16,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -34,6 +36,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
#[Validate(['nullable'])]
public ?string $content = null;
@@ -65,6 +71,53 @@ class FileStorage extends Component
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
$this->refreshBackupStatus();
}
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$backup = $this->fileStorage->is_directory
? $this->fileStorage->scheduledBackups()->first()
: null;
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup) {
return;
}
if ($this->resource instanceof ServiceDatabase) {
$this->backupUrl = route('project.service.database.backups', [
'project_uuid' => $this->resource->service->project()->uuid,
'environment_uuid' => $this->resource->service->environment->uuid,
'service_uuid' => $this->resource->service->uuid,
'stack_service_uuid' => $this->resource->uuid,
]);
return;
}
if (! $this->resource instanceof Application) {
$this->hasEnabledBackup = false;
return;
}
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->fileStorage->fs_path])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function syncData(bool $toModel = false): void
@@ -135,6 +188,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this directory backup schedule and its archives before converting it to a file.');
}
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
@@ -162,6 +219,12 @@ class FileStorage extends Component
return 'The provided password is incorrect.';
}
if ($this->fileStorage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this directory backup schedule and its archives before deleting the directory.');
return false;
}
try {
$message = 'File deleted.';
if ($this->fileStorage->is_directory) {
@@ -174,6 +237,7 @@ class FileStorage extends Component
$this->fileStorage->deleteStorageOnServer();
}
$this->fileStorage->delete();
$this->dispatch('configurationChanged');
$this->dispatch('success', $message);
} catch (\Throwable $e) {
return handleError($e, $this);
+5
View File
@@ -131,10 +131,12 @@ class Storage extends Component
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -163,6 +165,7 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->clearForm();
@@ -195,6 +198,7 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
@@ -231,6 +235,7 @@ class Storage extends Component
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->clearForm();
+43 -1
View File
@@ -2,9 +2,12 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\Application;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class Show extends Component
@@ -32,6 +35,10 @@ class Show extends Component
public bool $isPreviewSuffixEnabled = true;
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
@@ -81,10 +88,38 @@ class Show extends Component
}
}
public function mount()
public function mount(): void
{
$this->syncData(false);
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
$this->refreshBackupStatus();
}
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$backup = $this->storage->scheduledBackups()->first();
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
return;
}
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function instantSave(): void
@@ -115,8 +150,15 @@ class Show extends Component
return 'The provided password is incorrect.';
}
if ($this->storage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
return false;
}
$this->storage->delete();
$this->dispatch('refreshStorages');
$this->dispatch('configurationChanged');
return true;
}
@@ -0,0 +1,388 @@
<?php
namespace App\Livewire\Project\Shared\Storages;
use App\Actions\Shared\DeleteScheduledVolumeBackup;
use App\Jobs\VolumeBackupJob;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;
use Throwable;
class VolumeBackups extends Component
{
use AuthorizesRequests;
use WithPagination;
public LocalPersistentVolume|LocalFileVolume $storage;
public $resource;
public ?ScheduledVolumeBackup $backup = null;
public string $section = 'general';
public string $frequency = 'daily';
public bool $enabled = false;
public bool $saveToS3 = false;
public bool $disableLocalBackup = false;
public bool $stopDuringBackup = false;
public ?int $s3StorageId = null;
public int $retentionAmountLocally = 7;
public int $retentionDaysLocally = 0;
public float $retentionMaxStorageLocally = 0;
public int $retentionAmountS3 = 7;
public int $retentionDaysS3 = 0;
public float $retentionMaxStorageS3 = 0;
public string $timezone = '';
public int $timeout = 3600;
public bool $delete_backup_s3 = false;
public Collection $availableS3Storages;
protected function rules(): array
{
return [
'frequency' => ['required', 'string'],
'enabled' => ['required', 'boolean'],
'saveToS3' => ['required', 'boolean'],
'disableLocalBackup' => ['required', 'boolean'],
'stopDuringBackup' => ['required', 'boolean'],
's3StorageId' => ['nullable', 'integer'],
'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'],
'retentionDaysLocally' => ['required', 'integer', 'min:0'],
'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'],
'retentionAmountS3' => ['required', 'integer', 'min:0', 'max:10000'],
'retentionDaysS3' => ['required', 'integer', 'min:0'],
'retentionMaxStorageS3' => ['required', 'numeric', 'min:0'],
'timeout' => ['required', 'integer', 'min:60', 'max:36000'],
];
}
public function mount(): void
{
$this->authorize('view', $this->resource);
$this->availableS3Storages = S3Storage::ownedByCurrentTeam()
->where('is_usable', true)
->get();
$this->backup = $this->storage->scheduledBackups()->first();
$server = $this->backup?->server() ?? data_get($this->resource, 'destination.server');
$this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone');
if ($this->backup) {
$this->frequency = $this->backup->frequency;
$this->enabled = $this->backup->enabled;
$this->saveToS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup;
$this->stopDuringBackup = $this->backup->stop_during_backup;
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id;
$this->retentionAmountLocally = $this->backup->retention_amount_locally;
$this->retentionDaysLocally = $this->backup->retention_days_locally;
$this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally;
$this->retentionAmountS3 = $this->backup->retention_amount_s3;
$this->retentionDaysS3 = $this->backup->retention_days_s3;
$this->retentionMaxStorageS3 = $this->backup->retention_max_storage_s3;
$this->timeout = $this->backup->timeout;
} else {
$this->s3StorageId = $this->availableS3Storages->first()?->id;
}
}
public function save(): void
{
$this->authorize('update', $this->resource);
if (! $this->validateSettings()) {
return;
}
$this->backup = $this->persistBackup($this->enabled);
$this->dispatch('success', 'Storage backup schedule saved.');
}
public function instantSave(): void
{
$this->save();
}
public function updatedS3StorageId(): void
{
$this->authorize('update', $this->resource);
if (! $this->hasValidS3Storage()) {
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
return;
}
$this->resetErrorBag('s3StorageId');
$this->backup?->update(['s3_storage_id' => $this->s3StorageId]);
$this->dispatch('success', 'S3 storage updated.');
}
public function toggleS3(): void
{
$this->authorize('update', $this->resource);
if (! $this->saveToS3 && ! $this->hasValidS3Storage()) {
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
return;
}
$this->saveToS3 = ! $this->saveToS3;
$this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup;
$this->backup?->update([
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
's3_storage_id' => $this->s3StorageId,
]);
$this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.');
}
public function toggleEnabled(): void
{
$this->authorize('update', $this->resource);
if (! $this->backup) {
if (! $this->validateSettings()) {
return;
}
$this->enabled = true;
$this->backup = $this->persistBackup(true);
} else {
$this->enabled = ! $this->enabled;
$this->backup->update(['enabled' => $this->enabled]);
}
$this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.');
}
public function backupNow(): Redirector|RedirectResponse|null
{
$this->authorize('update', $this->resource);
if (! $this->backup) {
if (! $this->validateSettings()) {
return null;
}
$this->enabled = false;
$this->backup = $this->persistBackup(false);
}
VolumeBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Storage backup queued.');
return redirect()->route('project.application.backup.executions', [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
'backup_uuid' => $this->backup->uuid,
]);
}
public function delete(?string $password = null, array $selectedActions = []): bool|string
{
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
if (! $this->backup) {
return false;
}
try {
DeleteScheduledVolumeBackup::run($this->backup);
$this->backup = null;
$this->dispatch('success', 'Storage backup schedule and archives deleted.');
$this->redirectRoute('project.application.backup.index', [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
], navigate: true);
return true;
} catch (Throwable $exception) {
$this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage());
return false;
}
}
public function cleanupFailed(): void
{
$this->authorize('update', $this->resource);
$deletedCount = $this->backup?->executions()
->where('status', 'failed')
->where('stop_recovery_pending', false)
->where('s3_cleanup_pending', false)
->where(fn ($query) => $query
->whereNull('filename')
->orWhere('local_storage_deleted', true))
->delete() ?? 0;
$this->dispatch(
$deletedCount > 0 ? 'success' : 'info',
$deletedCount > 0 ? 'Failed backup entries cleaned up.' : 'No safely removable failed backup entries found.',
);
}
public function cleanupDeleted(): void
{
$this->authorize('update', $this->resource);
$deletedCount = $this->backup?->executions()
->where('local_storage_deleted', true)
->where(fn ($query) => $query
->where('s3_storage_deleted', true)
->orWhereNull('s3_uploaded')
->orWhere('s3_uploaded', false))
->delete() ?? 0;
$this->dispatch(
$deletedCount > 0 ? 'success' : 'info',
$deletedCount > 0 ? "Cleaned up {$deletedCount} deleted backup entries." : 'No deleted backup entries found.',
);
}
public function deleteBackup(int $executionId, string $password, array $selectedActions = []): bool|string
{
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$execution = $this->backup?->executions()->whereKey($executionId)->first();
if (! $execution) {
$this->dispatch('error', 'Backup execution not found.');
return false;
}
if ($execution->status === 'running' || $execution->stop_recovery_pending || $execution->s3_cleanup_pending) {
$this->dispatch('error', 'Wait for the backup and recovery operations to finish before deleting it.');
return false;
}
try {
$server = $this->backup->server();
if (! $execution->local_storage_deleted && filled($execution->filename)) {
if (! $server) {
throw new \RuntimeException('The server is unavailable.');
}
deleteBackupsLocally($execution->filename, $server, throwError: true);
}
if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
if (! $execution->s3) {
throw new \RuntimeException('The S3 storage is unavailable.');
}
deleteBackupsS3($execution->filename, $execution->s3);
}
$execution->delete();
$this->delete_backup_s3 = false;
$this->dispatch('success', 'Backup deleted.');
return true;
} catch (Throwable $exception) {
$this->dispatch('error', 'Failed to delete backup: '.$exception->getMessage());
return false;
}
}
public function render()
{
$executions = $this->backup?->executions()->paginate(10);
return view('livewire.project.shared.storages.volume-backups', [
'executions' => $executions ?? collect(),
'latestExecution' => $this->backup?->executions()->first(),
]);
}
private function validateSettings(): bool
{
$this->validate();
if (! validate_cron_expression($this->frequency)) {
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
return false;
}
if ($this->saveToS3 && ! $this->hasValidS3Storage()) {
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
return false;
}
$this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup;
return true;
}
private function persistBackup(bool $enabled): ScheduledVolumeBackup
{
return $this->storage->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => $enabled,
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
'stop_during_backup' => $this->stopDuringBackup,
's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id,
'retention_amount_locally' => $this->retentionAmountLocally,
'retention_days_locally' => $this->retentionDaysLocally,
'retention_max_storage_locally' => $this->retentionMaxStorageLocally,
'retention_amount_s3' => $this->retentionAmountS3,
'retention_days_s3' => $this->retentionDaysS3,
'retention_max_storage_s3' => $this->retentionMaxStorageS3,
'timeout' => $this->timeout,
],
);
}
private function hasValidS3Storage(): bool
{
return $this->s3StorageId !== null
&& S3Storage::query()
->whereKey($this->s3StorageId)
->where('team_id', currentTeam()->id)
->where('is_usable', true)
->exists();
}
}