fix(backups): default S3 storage for backup schedules

Show the S3 storage selector even when S3 backups are disabled, save
storage changes immediately, and improve responsive confirmation buttons.
This commit is contained in:
Andras Bacsai 2026-07-02 12:47:54 +02:00
parent 76d429fb74
commit 74f4d04f53
5 changed files with 121 additions and 40 deletions

View file

@ -131,7 +131,7 @@ public function syncData(bool $toModel = false)
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
$this->saveS3 = $this->backup->save_s3; $this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false; $this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id; $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
$this->databasesToBackup = $this->backup->databases_to_backup; $this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all; $this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout; $this->timeout = $this->backup->timeout;
@ -217,6 +217,11 @@ public function instantSave()
} }
} }
public function updatedS3StorageId(): void
{
$this->instantSave();
}
private function customValidate() private function customValidate()
{ {
if (! is_numeric($this->backup->s3_storage_id)) { if (! is_numeric($this->backup->s3_storage_id)) {
@ -225,17 +230,13 @@ private function customValidate()
// S3 backup cannot be enabled without a valid S3 storage owned by the team // S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = $this->availableS3StorageIds(); $availableS3Ids = $this->availableS3StorageIds();
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) { if ($availableS3Ids->isEmpty()) {
if ($availableS3Ids->isEmpty()) { $this->backup->s3_storage_id = $this->s3StorageId = null;
$this->backup->s3_storage_id = $this->s3StorageId = null; if ($this->backup->save_s3) {
$this->backup->save_s3 = $this->saveS3 = false; $this->backup->save_s3 = $this->saveS3 = false;
} elseif ($this->backup->s3_storage_id === null && $availableS3Ids->count() === 1) {
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
} else {
$this->backup->s3_storage_id = $this->s3StorageId = null;
throw new Exception('Please select a valid S3 storage to enable S3 backups.');
} }
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
} }
// Validate that disable_local_backup can only be true when S3 backup is enabled // Validate that disable_local_backup can only be true when S3 backup is enabled
@ -254,9 +255,14 @@ private function availableS3StorageIds(): Collection
{ {
$storages = collect($this->availableS3Storages); $storages = collect($this->availableS3Storages);
$storageIds = $storages->pluck('id')->filter()->all(); $storageIds = $storages->pluck('id')->filter()->all();
if (empty($storageIds)) {
return collect();
}
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all(); $teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
if (empty($storageIds) || empty($teamIds)) { if (empty($teamIds)) {
return collect(); return collect();
} }

View file

@ -132,10 +132,10 @@
@class([ @class([
'relative h-auto', 'relative h-auto',
'w-full' => $buttonFullWidth, 'w-full' => $buttonFullWidth,
'w-auto' => ! $buttonFullWidth, 'w-full sm:w-auto' => ! $buttonFullWidth,
])> ])>
@if (isset($trigger)) @if (isset($trigger))
<div @click="modalOpen=true"> <div class="w-full" @click="modalOpen=true">
{{ $trigger }} {{ $trigger }}
</div> </div>
@elseif ($customButton) @elseif ($customButton)

View file

@ -1,7 +1,7 @@
<form wire:submit="submit"> <form wire:submit="submit">
<div class="flex flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between"> <div class="flex flex-col gap-3 pb-4 sm:flex-row sm:items-center">
<h2>Scheduled Backup</h2> <h2>Scheduled Backup</h2>
<div class="grid grid-cols-1 gap-2 min-[420px]:grid-cols-2 sm:flex sm:flex-wrap sm:justify-end"> <div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button type="submit" class="w-full sm:w-auto"> <x-forms.button type="submit" class="w-full sm:w-auto">
Save Save
</x-forms.button> </x-forms.button>
@ -9,16 +9,19 @@
<x-forms.button wire:click='backupNow' class="w-full sm:w-auto">Backup Now</x-forms.button> <x-forms.button wire:click='backupNow' class="w-full sm:w-auto">Backup Now</x-forms.button>
@endif @endif
@if ($backup->database_id !== 0) @if ($backup->database_id !== 0)
<div class="min-[420px]:col-span-2 sm:col-span-1"> <div class="w-full sm:w-auto">
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" <x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
buttonTitle="Delete Backups and Schedule" buttonFullWidth isErrorButton submitAction="delete"
:checkboxes="$checkboxes" :actions="[ :checkboxes="$checkboxes" :actions="[
'The selected backup schedule will be deleted.', 'The selected backup schedule will be deleted.',
'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).', 'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).',
]" ]"
confirmationText="{{ $backup->database->name }}" confirmationText="{{ $backup->database->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below" confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below"
shortConfirmationLabel="Database Name" /> shortConfirmationLabel="Database Name">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete Backups and Schedule</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div> </div>
@endif @endif
</div> </div>
@ -39,16 +42,27 @@
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." /> helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@endif @endif
</div> </div>
@if ($saveS3) <div class="w-full max-w-md pb-6">
<div class="w-full max-w-md pb-6"> <div class="flex gap-1 items-center mb-1 text-sm font-medium">
<x-forms.select id="s3StorageId" label="S3 Storage" required> <span>S3 Storage</span>
<option value="default" disabled>Select a S3 storage</option> @if (!$saveS3)
<span class="text-xs font-normal text-warning">(currently disabled)</span>
@endif
@if ($saveS3)
<x-highlighted text="*" />
@endif
</div>
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveS3"
:disabled="$availableS3Storages->isEmpty()">
@if ($availableS3Storages->isEmpty())
<option value="">No S3 storage available</option>
@else
@foreach ($availableS3Storages as $s3) @foreach ($availableS3Storages as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option> <option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach @endforeach
</x-forms.select> @endif
</div> </x-forms.select>
@endif </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h3>Settings</h3> <h3>Settings</h3>
<div class="flex gap-2 flex-col "> <div class="flex gap-2 flex-col ">

View file

@ -1,6 +1,6 @@
<div wire:init='refreshBackupExecutions'> <div wire:init='refreshBackupExecutions'>
@isset($backup) @isset($backup)
<div class="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 py-4 sm:flex-row sm:flex-wrap sm:items-center">
<h3 class="py-0">Executions <span class="text-xs">({{ $executions_count }})</span></h3> <h3 class="py-0">Executions <span class="text-xs">({{ $executions_count }})</span></h3>
@if ($executions_count > 0) @if ($executions_count > 0)
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -21,14 +21,18 @@
</x-forms.button> </x-forms.button>
</div> </div>
@endif @endif
<div class="grid grid-cols-1 gap-2 min-[420px]:grid-cols-2 sm:flex sm:flex-wrap sm:justify-end"> <div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button wire:click='cleanupFailed' class="w-full sm:w-auto">Cleanup Failed Backups</x-forms.button> <x-forms.button wire:click='cleanupFailed' class="w-full sm:w-auto">Cleanup Failed Backups</x-forms.button>
<x-modal-confirmation title="Cleanup Deleted Backup Entries?" buttonTitle="Cleanup Deleted" isErrorButton <x-modal-confirmation title="Cleanup Deleted Backup Entries?" isErrorButton
buttonFullWidth submitAction="cleanupDeleted()" submitAction="cleanupDeleted()"
:actions="['This will permanently delete all backup execution entries that are marked as deleted from local storage.', 'This only removes database entries, not actual backup files.']" :actions="['This will permanently delete all backup execution entries that are marked as deleted from local storage.', 'This only removes database entries, not actual backup files.']"
confirmationText="cleanup deleted backups" confirmationText="cleanup deleted backups"
confirmationLabel="Please confirm by typing 'cleanup deleted backups' below" confirmationLabel="Please confirm by typing 'cleanup deleted backups' below"
shortConfirmationLabel="Confirmation" /> shortConfirmationLabel="Confirmation">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Cleanup Deleted</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div> </div>
</div> </div>
<div @if (!$skip) wire:poll.5000ms="refreshBackupExecutions" @endif <div @if (!$skip) wire:poll.5000ms="refreshBackupExecutions" @endif
@ -156,9 +160,9 @@ class="flex flex-col gap-4">
<pre class="whitespace-pre-wrap text-sm">{{ data_get($execution, 'message') }}</pre> <pre class="whitespace-pre-wrap text-sm">{{ data_get($execution, 'message') }}</pre>
</div> </div>
@endif @endif
<div class="flex flex-col gap-2 mt-4 min-[420px]:flex-row min-[420px]:flex-wrap"> <div class="grid grid-cols-2 gap-2 mt-4 sm:flex sm:flex-wrap">
@if (data_get($execution, 'status') === 'success') @if (data_get($execution, 'status') === 'success')
<x-forms.button class="w-full dark:hover:bg-coolgray-400 min-[420px]:w-auto" <x-forms.button class="w-full dark:hover:bg-coolgray-400 sm:w-auto"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button> x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@endif @endif
@php @php
@ -177,11 +181,15 @@ class="flex flex-col gap-4">
$deleteActions[] = 'This backup execution record will be deleted.'; $deleteActions[] = 'This backup execution record will be deleted.';
} }
@endphp @endphp
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton <x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes" submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes"
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}" :actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below" confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename" 1 /> shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div> </div>
</div> </div>
@empty @empty

View file

@ -117,8 +117,8 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
expect($backup->s3_storage_id)->toBe($s3->id); expect($backup->s3_storage_id)->toBe($s3->id);
}); });
it('requires an explicit S3 selection when multiple storages are available', function () { it('defaults to the first available storage when multiple storages are available', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3'); $firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3'); createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [ $backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false, 'save_s3' => false,
@ -126,13 +126,14 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
]); ]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('s3StorageId', $firstS3->id)
->set('saveS3', true) ->set('saveS3', true)
->call('instantSave') ->call('instantSave')
->assertDispatched('error'); ->assertDispatched('success');
$backup->refresh(); $backup->refresh();
expect($backup->save_s3)->toBeFalsy(); expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBeNull(); expect($backup->s3_storage_id)->toBe($firstS3->id);
}); });
it('accepts the S3 storage scope passed to the component', function () { it('accepts the S3 storage scope passed to the component', function () {
@ -152,3 +153,55 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
expect($backup->save_s3)->toBeTruthy(); expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id); expect($backup->s3_storage_id)->toBe($s3->id);
}); });
it('shows available S3 storages even when S3 backup is disabled', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
});
it('shows when S3 backups are currently disabled', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
it('saves selected S3 storage immediately when it changes', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('s3StorageId', $secondS3->id)
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});