From 8a2457da9325c099f679b33b52c43960d3f0f6c5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:20:06 +0200 Subject: [PATCH] feat(storages): support selective archive deletion for backup schedules Allow local and S3 archives to be deleted independently when removing a volume backup schedule, and consolidate destructive-action layouts with a shared danger-zone component. --- .../Shared/DeleteScheduledVolumeBackup.php | 60 ++++---- .../Project/Shared/Storages/VolumeBackups.php | 18 ++- resources/css/app.css | 55 +------- .../views/components/danger-zone.blade.php | 16 +++ .../views/livewire/destination/show.blade.php | 27 ++-- .../database/backup-edit/danger.blade.php | 22 ++- .../livewire/project/shared/danger.blade.php | 22 +-- .../storages/volume-backups/danger.blade.php | 18 ++- .../volume-backups/executions.blade.php | 130 ++++++++---------- .../views/livewire/server/delete.blade.php | 22 +-- .../livewire/source/github/change.blade.php | 22 +-- .../views/livewire/storage/show.blade.php | 22 +-- .../views/livewire/team/danger-zone.blade.php | 37 ++--- tests/Feature/DangerZoneLayoutTest.php | 27 ++++ .../PersistentStorageVolumesLayoutTest.php | 32 +++-- tests/Feature/TeamSettingsNavigationTest.php | 5 +- tests/Feature/VolumeBackupTest.php | 34 ++++- 17 files changed, 267 insertions(+), 302 deletions(-) create mode 100644 resources/views/components/danger-zone.blade.php create mode 100644 tests/Feature/DangerZoneLayoutTest.php diff --git a/app/Actions/Shared/DeleteScheduledVolumeBackup.php b/app/Actions/Shared/DeleteScheduledVolumeBackup.php index 55972ab520..d56cce9384 100644 --- a/app/Actions/Shared/DeleteScheduledVolumeBackup.php +++ b/app/Actions/Shared/DeleteScheduledVolumeBackup.php @@ -12,8 +12,12 @@ class DeleteScheduledVolumeBackup { use AsAction; - public function handle(ScheduledVolumeBackup $backup, ?Server $server = null): void - { + public function handle( + ScheduledVolumeBackup $backup, + ?Server $server = null, + bool $deleteLocalArchives = true, + bool $deleteS3Archives = true, + ): void { $lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), $backup->timeout + 300); if (! $lock->get()) { @@ -30,36 +34,40 @@ class DeleteScheduledVolumeBackup throw new \RuntimeException('Wait for the running storage backup and recovery operations to finish before deleting this schedule.'); } - $localFilenames = $backup->executions() - ->where('local_storage_deleted', false) - ->pluck('filename') - ->filter() - ->all(); + if ($deleteLocalArchives) { + $localFilenames = $backup->executions() + ->where('local_storage_deleted', false) + ->pluck('filename') + ->filter() + ->all(); - if ($localFilenames !== []) { - $server ??= $backup->server(); - if (! $server) { - throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.'); + if ($localFilenames !== []) { + $server ??= $backup->server(); + if (! $server) { + throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.'); + } + + deleteBackupsLocally($localFilenames, $server, throwError: true); } - - deleteBackupsLocally($localFilenames, $server, throwError: true); } - $s3Executions = $backup->executions() - ->with('s3') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) - ->get(); + if ($deleteS3Archives) { + $s3Executions = $backup->executions() + ->with('s3') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->get(); - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; - if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); - } + foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { + $s3 = $executions->first()->s3; + if (! $s3) { + throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + } - $filenames = $executions->pluck('filename')->filter()->all(); - if ($filenames !== []) { - deleteBackupsS3($filenames, $s3); + $filenames = $executions->pluck('filename')->filter()->all(); + if ($filenames !== []) { + deleteBackupsS3($filenames, $s3); + } } } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a8e2d72df1..d522e66939 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -69,6 +69,10 @@ class VolumeBackups extends Component public bool $delete_backup_s3 = false; + public bool $delete_associated_backups_locally = false; + + public bool $delete_associated_backups_s3 = false; + public Collection $availableS3Storages; protected function rules(): array @@ -226,14 +230,18 @@ class VolumeBackups extends Component } try { - DeleteScheduledVolumeBackup::run($this->backup); + DeleteScheduledVolumeBackup::run( + $this->backup, + deleteLocalArchives: in_array('delete_associated_backups_locally', $selectedActions, true), + deleteS3Archives: in_array('delete_associated_backups_s3', $selectedActions, true), + ); $this->backup = null; - $this->dispatch('success', 'Storage backup schedule and archives deleted.'); + $this->dispatch('success', 'Storage backup schedule deleted.'); $this->redirectRoute($this->routeName('index'), $this->routeParameters(includeBackup: false), navigate: true); return true; } catch (Throwable $exception) { - $this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage()); + $this->dispatch('error', 'Could not delete the backup schedule: '.$exception->getMessage()); return false; } @@ -334,6 +342,10 @@ class VolumeBackups extends Component return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), 'latestExecution' => $this->backup?->executions()->first(), + 'deleteScheduleCheckboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => 'Delete all local archives created by this schedule.'], + ['id' => 'delete_associated_backups_s3', 'label' => 'Delete all S3 archives created by this schedule.'], + ], ]); } diff --git a/resources/css/app.css b/resources/css/app.css index 15035abb92..263233eef9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3150,59 +3150,8 @@ input[type="search"]::-webkit-search-results-decoration { } .volume-backup-executions-grid { - grid-template-columns: 7.5rem minmax(0, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr); -} - -.volume-backup-execution-label { - display: none; -} - -.volume-backup-execution-message { - grid-column: 1 / -1; -} - -@media (max-width: 768px) { - .data-table-header.volume-backup-executions-grid { - display: none; - } - - .data-table-row.volume-backup-executions-grid { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - min-height: 0; - align-items: start; - gap: 0.875rem 1rem; - padding: 1rem; - } - - .volume-backup-execution-archive, - .volume-backup-execution-availability, - .volume-backup-execution-message { - grid-column: 1 / -1; - } - - .volume-backup-execution-status, - .volume-backup-execution-archive, - .volume-backup-execution-time, - .volume-backup-execution-size, - .volume-backup-execution-availability, - .volume-backup-execution-actions { - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.25rem; - } - - .volume-backup-execution-label { - display: block; - font-size: 12px; - font-weight: 500; - line-height: 1rem; - color: var(--coollabs-subtle); - } - - .volume-backup-execution-actions > span:last-child { - justify-content: flex-start; - } + grid-template-columns: 7.5rem minmax(14rem, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr); + min-width: 50rem; } .clone-destinations-table-grid { diff --git a/resources/views/components/danger-zone.blade.php b/resources/views/components/danger-zone.blade.php new file mode 100644 index 0000000000..cd2f75fbce --- /dev/null +++ b/resources/views/components/danger-zone.blade.php @@ -0,0 +1,16 @@ +@props(['title']) + +
class('flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between') }}> +
+

{{ $title }}

+
+ {{ $slot }} +
+
+ + @isset($action) +
+ {{ $action }} +
+ @endisset +
diff --git a/resources/views/livewire/destination/show.blade.php b/resources/views/livewire/destination/show.blade.php index 0a7c5a68cf..c622438f82 100644 --- a/resources/views/livewire/destination/show.blade.php +++ b/resources/views/livewire/destination/show.blade.php @@ -21,22 +21,13 @@
-
-
-
-
-

Delete destination

- -
-

- Permanently delete {{ $destination->name }} - from Coolify. The Docker network is also removed from the server. -

-

- Delete or move every attached resource before deleting this destination. -

-
+ +

+ Permanently delete {{ $destination->name }} + from Coolify. The Docker network is also removed from the server. +

+

Delete or move every attached resource before deleting this destination.

+ @if ($network !== 'coolify') @endif -
-
+ +
@else diff --git a/resources/views/livewire/project/database/backup-edit/danger.blade.php b/resources/views/livewire/project/database/backup-edit/danger.blade.php index 3b943b5974..b2ab527419 100644 --- a/resources/views/livewire/project/database/backup-edit/danger.blade.php +++ b/resources/views/livewire/project/database/backup-edit/danger.blade.php @@ -1,13 +1,9 @@ -
-
-
-

Delete backup schedule

-

- Permanently remove this schedule and optionally its backup archives. This cannot be undone. -

-
+
+ + +

You can select which backup archives to remove.

+ @if ($backup->database_id !== 0) -
-
-
-
-

Delete {{ $resourceLabel }}

- -
-

+ +

Permanently delete {{ $resourceName }}, stop its containers, and remove the selected Docker resources and configuration.

-
    +
    • • Active deployments will be stopped.
    • • Selected volumes and stored data may be permanently removed.
    • • This {{ $resourceLabel }} cannot be restored from Coolify after deletion.
    -
- -
+ @if ($canDelete) @endif -
-
-
+
+
@if (!$canDelete)
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/danger.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/danger.blade.php index 3d685cd219..614e78e4b0 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/danger.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/danger.blade.php @@ -1,17 +1,14 @@
-
-
-

This action cannot be undone.

-

- Existing archives created by this schedule are removed with it. -

-
+ +

You can select which backup archives to remove.

+ @if ($backup) @@ -20,6 +17,7 @@ @endif -
+ +
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 26a0f8ab39..ae16895d18 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -23,7 +23,7 @@ @if ($executionCount > 0) -
+
Status @@ -66,88 +66,70 @@
-
- Status + -
+ -
- Archive -
- {{ $execution->filename ?? 'No archive name' }} - -
-
+ + + -
- Time - - @if ($execution->status === 'running') - Running for {{ calculateDuration($execution->created_at, now()) }} - @else - {{ $finishedAt->diffForHumans() }}
- {{ calculateDuration($execution->created_at, $finishedAt) }} - @endif -
-
+ + @if ($execution->status === 'running') + Running for {{ calculateDuration($execution->created_at, now()) }} + @else + {{ $finishedAt->diffForHumans() }}
+ {{ calculateDuration($execution->created_at, $finishedAt) }} + @endif +
-
- Size - - {{ $execution->size > 0 ? formatBytes($execution->size) : '-' }} - -
+ + {{ $execution->size > 0 ? formatBytes($execution->size) : '-' }} + -
- Availability - - - @if ($execution->s3_uploaded !== null) - - @endif - -
+ + + @if ($execution->s3_uploaded !== null) + + @endif + -
- Actions - - @if ($execution->status === 'success' && ! $execution->local_storage_deleted) - - @endif - @if ($execution->status !== 'running') - - - - - - @endif - -
+ + @if ($execution->status === 'success' && ! $execution->local_storage_deleted) + + @endif + @if ($execution->status !== 'running') + + + + + + @endif + @if ($execution->message)
{{ $execution->message }}
+ class="volume-backup-execution-message col-span-6 min-w-0 max-w-full max-h-20 overflow-y-auto overflow-x-hidden bg-transparent py-2 text-[11px] break-words whitespace-pre-wrap text-neutral-600 dark:text-fg-dim">{{ $execution->message }} @endif
@endforeach diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index 90d51bf3ca..fb5e9c3172 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -14,32 +14,24 @@ - - - - - + +

The server will be removed from Coolify. @if ($server->definedResources()->count() > 0) It currently contains managed resources. Enable force deletion in the confirmation only if those resources should also be removed. @endif - - -

-
-

Delete {{ $server->name }}

-

- Type the server name in the confirmation dialog to continue. -

-
+

+

Type the server name in the confirmation dialog to continue.

+ -
+ +
@endif
diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index ade5b53b1d..fe8e1eb01a 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -170,27 +170,18 @@
-
-
-
-
-

Delete GitHub App

- -
-

+ +

Permanently delete {{ $name ?: 'this GitHub App' }} from Coolify. Applications using this source will need another Git provider configured.

-
    +
    • • The App registration on GitHub is not removed automatically.
    • • Linked applications keep their Git settings until you change them.
    • • This source cannot be restored from Coolify after deletion.
    -
- -
+ @can('delete', $github_app) @endcan -
-
-
+ + @cannot('delete', $github_app)
diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index 72e03d7bf7..a7cf356d11 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -64,20 +64,13 @@
-
-
-
-
-

Delete storage

- -
-

+ +

Permanently delete {{ $storage->name }} from Coolify. Existing objects in the bucket are not deleted.

-
    +
    • • Backup schedules pointing at this storage will stop writing to S3.
    • @if ($backupCount > 0)
    • • {{ $backupCount }} backup schedule(s) currently use this destination.
    • @@ -85,9 +78,7 @@
    • • Bucket contents on the provider are left untouched.
    • • This storage destination cannot be restored from Coolify after deletion.
    -
- -
+ @can('delete', $storage) @endcan -
-
-
+ + @cannot('delete', $storage)
diff --git a/resources/views/livewire/team/danger-zone.blade.php b/resources/views/livewire/team/danger-zone.blade.php index 477121f719..10b302027b 100644 --- a/resources/views/livewire/team/danger-zone.blade.php +++ b/resources/views/livewire/team/danger-zone.blade.php @@ -15,47 +15,39 @@
-
-
-
-
-

Delete team

- -
- + @if (auth()->user()->roleInTeam(currentTeam()->id) !== 'owner') -

+

Only team owners can delete this team.

@elseif (session('currentTeam.id') === 0) -

+

The default team cannot be deleted.

@elseif(auth()->user()->teams()->count() === 1 || auth()->user()->currentTeam()->personal_team) -

+

Your last or personal team cannot be deleted.

@elseif(currentTeam()->subscription) -

+

Cancel your subscription before deleting this team.

@elseif($deletionBlockers === []) -

+

Permanently delete {{ currentTeam()->name }} from Coolify. This action cannot be undone.

-
- -
+ @if ( session('currentTeam.id') !== 0 && auth()->user()->roleInTeam(currentTeam()->id) === 'owner' && @@ -92,9 +82,8 @@ Delete team @endif -
-
-
+ + @if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && (currentTeam()->projects->isNotEmpty() || currentTeam()->servers->isNotEmpty()))
diff --git a/tests/Feature/DangerZoneLayoutTest.php b/tests/Feature/DangerZoneLayoutTest.php new file mode 100644 index 0000000000..6f212301f8 --- /dev/null +++ b/tests/Feature/DangerZoneLayoutTest.php @@ -0,0 +1,27 @@ +toContain("@props(['title'])") + ->toContain('sm:flex-row sm:items-center sm:justify-between') + ->toContain('text-red-700 dark:text-red-300') + ->toContain('@isset($action)') + ->not->toContain('bg-red-50'); + + foreach ([ + 'livewire/destination/show.blade.php', + 'livewire/project/database/backup-edit/danger.blade.php', + 'livewire/project/shared/danger.blade.php', + 'livewire/project/shared/storages/volume-backups/danger.blade.php', + 'livewire/server/delete.blade.php', + 'livewire/source/github/change.blade.php', + 'livewire/storage/show.blade.php', + 'livewire/team/danger-zone.blade.php', + ] as $view) { + expect(file_get_contents(resource_path('views/'.$view))) + ->toContain('not->toContain('rounded-lg border border-red-300 bg-red-50'); + } +}); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 8ab2560d6b..8cc5104711 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -1,23 +1,18 @@ toContain('volume-backup-executions-grid') - ->toContain('volume-backup-execution-archive') - ->toContain('volume-backup-execution-label') - ->toContain('select-all break-all') - ->toContain('x-copy-button') - ->not->toContain('data-table w-full overflow-x-auto') - ->not->toContain('x-forms.copy-button') + ->toContain('data-table w-full overflow-x-auto') + ->toContain('x-forms.copy-button') + ->not->toContain('volume-backup-execution-label') ->and($css) ->toContain('.volume-backup-executions-grid') - ->toContain('.data-table-header.volume-backup-executions-grid') - ->toContain('.volume-backup-execution-archive') - ->toContain('grid-column: 1 / -1;') - ->not->toMatch('/\.volume-backup-executions-grid\s*\{[^}]*min-width:/'); + ->toContain('min-width: 50rem;') + ->not->toContain('.data-table-header.volume-backup-executions-grid'); }); it('uses compact icon actions for volume backup executions', function () { @@ -30,6 +25,17 @@ it('uses compact icon actions for volume backup executions', function () { ->toContain(''); }); +it('keeps long volume backup errors inside the table without a separate background panel', function () { + $view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')); + + expect($view) + ->toContain('volume-backup-execution-message col-span-6 min-w-0 max-w-full') + ->toContain('max-h-20 overflow-y-auto overflow-x-hidden') + ->toContain('break-words whitespace-pre-wrap') + ->toContain('bg-transparent') + ->not->toContain('volume-backup-execution-message col-span-6 mt-2 max-h-32'); +}); + it('keeps storage backup schedule tables horizontally scrollable on mobile', function () { $applicationView = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')); $serviceView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); @@ -201,8 +207,8 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/'); expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'))) ->toContain('Time') - ->toContain('x-copy-button') - ->toContain('volume-backup-execution-message'); + ->toContain('x-forms.copy-button') + ->toContain('col-span-6'); $css = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/TeamSettingsNavigationTest.php b/tests/Feature/TeamSettingsNavigationTest.php index da959b9d18..1b0c52a10b 100644 --- a/tests/Feature/TeamSettingsNavigationTest.php +++ b/tests/Feature/TeamSettingsNavigationTest.php @@ -27,16 +27,13 @@ it('uses shared sidebar navigation for every team settings page', function () { ->not->toContain('Delete team'); expect(file_get_contents(resource_path('views/livewire/team/danger-zone.blade.php'))) ->toContain('Delete team') - ->toContain('status="Permanent"') - ->toContain('border-red-300') + ->toContain('') ->toContain('') ->toContain('wire:click="refreshResources"') ->not->toContain('wire:loading.class="animate-spin"') ->toContain("route('project.show', ['project_uuid' => \$project->uuid])") ->toContain("route('server.show', ['server_uuid' => \$server->uuid])") ->toContain('target="_blank" rel="noopener noreferrer"') - ->toContain('Delete every server owned by this team before deleting it.') - ->toContain('currentTeam()->servers->isEmpty()') ->not->toContain('currentTeam()->isEmpty()'); expect(file_get_contents(resource_path('views/livewire/switch-team.blade.php'))) ->toContain('New team') diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 36a04cecaa..7d2d8a4cfe 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -1567,7 +1567,7 @@ it('deletes local archives before deleting a volume backup schedule', function ( ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) - ->call('delete', 'password') + ->call('delete', 'password', ['delete_associated_backups_locally']) ->assertDispatched('success') ->assertRedirectToRoute('project.application.backup.index', [ 'project_uuid' => $application->project()->uuid, @@ -1580,6 +1580,36 @@ it('deletes local archives before deleting a volume backup schedule', function ( && str_contains($process->command, 'archive.tar.gz')); }); +it('deletes a volume backup schedule without deleting unselected archives', function () { + Process::fake(); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + 'size' => 128, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 'danger', + ]) + ->assertSee('Delete all local archives created by this schedule.') + ->assertSee('Delete all S3 archives created by this schedule.') + ->call('delete', 'password', []) + ->assertDispatched('success'); + + expect($backup->fresh())->toBeNull(); + Process::assertNothingRan(); +}); + it('deletes a volume backup schedule without a password when two-step confirmation is disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); @@ -1646,7 +1676,7 @@ it('deletes S3 archives from the storage recorded on each execution', function ( ->andReturn($disk); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) - ->call('delete', 'password') + ->call('delete', 'password', ['delete_associated_backups_s3']) ->assertDispatched('success'); expect($backup->fresh())->toBeNull();