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.
This commit is contained in:
Andras Bacsai
2026-09-17 21:20:06 +02:00
parent 84e80142bc
commit 8a2457da93
17 changed files with 267 additions and 302 deletions
@@ -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);
}
}
}
@@ -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.'],
],
]);
}
+2 -53
View File
@@ -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 {
@@ -0,0 +1,16 @@
@props(['title'])
<div {{ $attributes->class('flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between') }}>
<div class="min-w-0">
<h4 class="text-sm font-semibold text-red-700 dark:text-red-300">{{ $title }}</h4>
<div class="mt-2 max-w-2xl space-y-2 text-[13px] leading-5 text-red-700/80 dark:text-red-300/80">
{{ $slot }}
</div>
</div>
@isset($action)
<div class="shrink-0">
{{ $action }}
</div>
@endisset
</div>
@@ -21,22 +21,13 @@
<div class="application-settings-form">
<x-application.settings-section id="destination-danger-section" title="Danger zone"
helper="Destructive actions for this destination cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete destination</h4>
<x-status-badge status="Permanent" type="error" />
</div>
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
Permanently delete <strong class="font-semibold text-black dark:text-fg">{{ $destination->name }}</strong>
from Coolify. The Docker network is also removed from the server.
</p>
<p class="mt-2 text-xs text-neutral-500 dark:text-fg-dim">
Delete or move every attached resource before deleting this destination.
</p>
</div>
<x-danger-zone title="Delete destination">
<p>
Permanently delete <strong class="font-semibold">{{ $destination->name }}</strong>
from Coolify. The Docker network is also removed from the server.
</p>
<p>Delete or move every attached resource before deleting this destination.</p>
<x-slot:action>
@if ($network !== 'coolify')
<x-modal-confirmation title="Confirm Destination Deletion?"
buttonTitle="Delete destination" isErrorButton submitAction="delete"
@@ -51,8 +42,8 @@
Delete destination
</x-forms.button>
@endif
</div>
</div>
</x-slot:action>
</x-danger-zone>
</x-application.settings-section>
</div>
@else
@@ -1,13 +1,9 @@
<section
class="overflow-hidden rounded-[10px] border border-red-300 bg-red-50/80 dark:border-red-500/25 dark:bg-red-500/[0.06]">
<div
class="flex items-start justify-between gap-4 border-b border-red-200 px-5 py-4 dark:border-red-500/20">
<div>
<h2 class="text-sm font-semibold text-red-800 dark:text-red-300">Delete backup schedule</h2>
<p class="mt-1 max-w-2xl text-sm text-red-700/80 dark:text-red-200/70">
Permanently remove this schedule and optionally its backup archives. This cannot be undone.
</p>
</div>
<div class="application-settings-form">
<x-application.settings-section title="Delete backup schedule"
description="Permanently remove this schedule and optionally its backup archives.">
<x-danger-zone title="This action cannot be undone.">
<p>You can select which backup archives to remove.</p>
<x-slot:action>
@if ($backup->database_id !== 0)
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
:checkboxes="$checkboxes" :actions="[
@@ -22,5 +18,7 @@
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</section>
</x-slot:action>
</x-danger-zone>
</x-application.settings-section>
</div>
@@ -10,27 +10,18 @@
@endphp
<x-application.settings-section id="danger-zone-section" title="Danger zone"
helper="Destructive resource actions cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete {{ $resourceLabel }}</h4>
<x-status-badge status="Permanent" type="error" />
</div>
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<x-danger-zone title="Delete {{ $resourceLabel }}">
<p>
Permanently delete
<strong class="font-semibold text-black dark:text-fg">{{ $resourceName }}</strong>,
stop its containers, and remove the selected Docker resources and configuration.
</p>
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
<ul class="space-y-1 text-xs">
<li> Active deployments will be stopped.</li>
<li> Selected volumes and stored data may be permanently removed.</li>
<li> This {{ $resourceLabel }} cannot be restored from Coolify after deletion.</li>
</ul>
</div>
<div class="shrink-0">
<x-slot:action>
@if ($canDelete)
<x-modal-confirmation title="Delete {{ $resourceLabel }}?"
buttonTitle="Delete {{ $resourceLabel }}"
@@ -44,9 +35,8 @@
Delete {{ $resourceLabel }}
</x-forms.button>
@endif
</div>
</div>
</div>
</x-slot:action>
</x-danger-zone>
@if (!$canDelete)
<div class="mt-4">
@@ -1,17 +1,14 @@
<div class="application-settings-form">
<x-application.settings-section title="Delete backup schedule"
description="Permanently delete this schedule and every local and S3 archive created by it."
description="Permanently delete this schedule and optionally delete its local and S3 archives."
class="danger-section">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[13px] font-medium text-red-700 dark:text-red-300">This action cannot be undone.</p>
<p class="mt-1 text-[12px] text-red-600/80 dark:text-red-300/70">
Existing archives created by this schedule are removed with it.
</p>
</div>
<x-danger-zone title="This action cannot be undone.">
<p>You can select which backup archives to remove.</p>
<x-slot:action>
@if ($backup)
<x-modal-confirmation title="Delete backup schedule?" isErrorButton submitAction="delete"
:actions="['Delete the selected backup schedule.', 'Delete every local and S3 archive created by this schedule.']"
:checkboxes="$deleteScheduleCheckboxes"
:actions="['Delete the selected backup schedule.']"
confirmationText="{{ $backup->targetName() }}"
confirmationLabel="Enter the {{ $backup->targetType() }} identifier to confirm."
shortConfirmationLabel="{{ $backup->targetType() }} identifier">
@@ -20,6 +17,7 @@
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</x-slot:action>
</x-danger-zone>
</x-application.settings-section>
</div>
@@ -23,7 +23,7 @@
</x-slot:actions>
@if ($executionCount > 0)
<div class="data-table w-full">
<div class="data-table w-full overflow-x-auto">
<div
class="data-table-header volume-backup-executions-grid border-b border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.05]">
<span>Status</span>
@@ -66,88 +66,70 @@
<div wire:key="volume-backup-execution-{{ $execution->id }}"
class="data-table-row volume-backup-executions-grid min-h-16 items-center gap-x-3 border-b border-neutral-200 text-[12px] last:border-b-0 dark:border-white/[0.07]">
<div class="volume-backup-execution-status min-w-0">
<span class="volume-backup-execution-label">Status</span>
<span>
<x-status-badge :status="$statusLabel" :type="$statusType" />
</div>
</span>
<div class="volume-backup-execution-archive min-w-0">
<span class="volume-backup-execution-label">Archive</span>
<div class="flex min-w-0 items-center gap-1">
<code class="select-all break-all font-mono text-[11px] text-neutral-600 md:truncate dark:text-fg-dim"
title="Backup path: {{ $execution->filename ?? 'No archive name' }}">{{ $execution->filename ?? 'No archive name' }}</code>
<x-copy-button :value="$execution->filename ?? ''" label="Copy backup path" />
</div>
</div>
<span class="min-w-0">
<x-forms.copy-button :text="$execution->filename ?? 'No archive name'" />
</span>
<div class="volume-backup-execution-time min-w-0">
<span class="volume-backup-execution-label">Time</span>
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
@if ($execution->status === 'running')
Running for {{ calculateDuration($execution->created_at, now()) }}
@else
{{ $finishedAt->diffForHumans() }}<br>
{{ calculateDuration($execution->created_at, $finishedAt) }}
@endif
</span>
</div>
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
@if ($execution->status === 'running')
Running for {{ calculateDuration($execution->created_at, now()) }}
@else
{{ $finishedAt->diffForHumans() }}<br>
{{ calculateDuration($execution->created_at, $finishedAt) }}
@endif
</span>
<div class="volume-backup-execution-size min-w-0">
<span class="volume-backup-execution-label">Size</span>
<span class="tabular-nums text-neutral-500 dark:text-fg-dim">
{{ $execution->size > 0 ? formatBytes($execution->size) : '-' }}
</span>
</div>
<span class="tabular-nums text-neutral-500 dark:text-fg-dim">
{{ $execution->size > 0 ? formatBytes($execution->size) : '-' }}
</span>
<div class="volume-backup-execution-availability min-w-0">
<span class="volume-backup-execution-label">Availability</span>
<span class="flex flex-wrap gap-1">
<x-status-badge :status="$execution->local_storage_deleted ? 'Local deleted' : 'Local'"
:type="$execution->local_storage_deleted ? 'neutral' : 'success'" />
@if ($execution->s3_uploaded !== null)
<x-status-badge
:status="$execution->s3_storage_deleted
? 'S3 deleted'
: ($execution->s3_uploaded ? 'S3' : 'S3 failed')"
:type="$execution->s3_storage_deleted
? 'neutral'
: ($execution->s3_uploaded ? 'success' : 'error')" />
@endif
</span>
</div>
<span class="flex flex-wrap gap-1">
<x-status-badge :status="$execution->local_storage_deleted ? 'Local deleted' : 'Local'"
:type="$execution->local_storage_deleted ? 'neutral' : 'success'" />
@if ($execution->s3_uploaded !== null)
<x-status-badge
:status="$execution->s3_storage_deleted
? 'S3 deleted'
: ($execution->s3_uploaded ? 'S3' : 'S3 failed')"
:type="$execution->s3_storage_deleted
? 'neutral'
: ($execution->s3_uploaded ? 'success' : 'error')" />
@endif
</span>
<div class="volume-backup-execution-actions min-w-0">
<span class="volume-backup-execution-label">Actions</span>
<span class="flex items-center justify-end gap-1">
@if ($execution->status === 'success' && ! $execution->local_storage_deleted)
<button type="button" class="icon-button shrink-0"
x-on:click="download_volume_backup_file('{{ $execution->id }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
@if ($execution->status !== 'running')
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ $execution->filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</span>
</div>
<span class="flex items-center justify-end gap-1">
@if ($execution->status === 'success' && ! $execution->local_storage_deleted)
<button type="button" class="icon-button shrink-0"
x-on:click="download_volume_backup_file('{{ $execution->id }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
@if ($execution->status !== 'running')
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ $execution->filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</span>
@if ($execution->message)
<pre
class="volume-backup-execution-message mt-2 max-h-32 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 text-[11px] whitespace-pre-wrap text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-dim">{{ $execution->message }}</pre>
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 }}</pre>
@endif
</div>
@endforeach
@@ -14,32 +14,24 @@
<x-application.settings-section id="server-danger-section" title="Delete server"
helper="Permanently remove this server and its configuration from Coolify."
class="server-danger-section">
<x-slot:actions>
<x-status-badge status="Irreversible" type="error" />
</x-slot:actions>
<x-callout type="danger" title="This action cannot be undone">
<x-danger-zone title="This action cannot be undone">
<p>
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
</x-callout>
<div class="mt-4 flex items-center justify-between gap-4 rounded-lg bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-500/[0.08] dark:ring-red-500/20">
<div>
<p class="text-sm font-medium text-red-900 dark:text-red-200">Delete {{ $server->name }}</p>
<p class="mt-1 text-xs leading-5 text-red-700 dark:text-red-300/80">
Type the server name in the confirmation dialog to continue.
</p>
</div>
</p>
<p>Type the server name in the confirmation dialog to continue.</p>
<x-slot:action>
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton
buttonTitle="Delete server" submitAction="delete"
:actions="['This server will be permanently deleted from Coolify.']"
:checkboxes="$checkboxes" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the Server Name below"
shortConfirmationLabel="Server Name" />
</div>
</x-slot:action>
</x-danger-zone>
</x-application.settings-section>
@endif
</div>
@@ -170,27 +170,18 @@
<div class="application-settings-form">
<x-application.settings-section id="github-app-danger-section" title="Danger zone"
helper="Destructive actions for this GitHub App source cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete GitHub App</h4>
<x-status-badge status="Permanent" type="error" />
</div>
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<x-danger-zone title="Delete GitHub App">
<p>
Permanently delete
<strong class="font-semibold text-black dark:text-fg">{{ $name ?: 'this GitHub App' }}</strong>
from Coolify. Applications using this source will need another Git provider configured.
</p>
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
<ul class="space-y-1 text-xs">
<li> The App registration on GitHub is not removed automatically.</li>
<li> Linked applications keep their Git settings until you change them.</li>
<li> This source cannot be restored from Coolify after deletion.</li>
</ul>
</div>
<div class="shrink-0">
<x-slot:action>
@can('delete', $github_app)
<x-modal-confirmation title="Confirm GitHub App Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete"
@@ -204,9 +195,8 @@
Delete
</x-forms.button>
@endcan
</div>
</div>
</div>
</x-slot:action>
</x-danger-zone>
@cannot('delete', $github_app)
<div class="mt-4">
@@ -64,20 +64,13 @@
<div class="application-settings-form">
<x-application.settings-section id="storage-danger-section" title="Danger zone"
helper="Destructive actions for this S3 storage destination cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete storage</h4>
<x-status-badge status="Permanent" type="error" />
</div>
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<x-danger-zone title="Delete storage">
<p>
Permanently delete
<strong class="font-semibold text-black dark:text-fg">{{ $storage->name }}</strong>
from Coolify. Existing objects in the bucket are not deleted.
</p>
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
<ul class="space-y-1 text-xs">
<li> Backup schedules pointing at this storage will stop writing to S3.</li>
@if ($backupCount > 0)
<li> {{ $backupCount }} backup schedule(s) currently use this destination.</li>
@@ -85,9 +78,7 @@
<li> Bucket contents on the provider are left untouched.</li>
<li> This storage destination cannot be restored from Coolify after deletion.</li>
</ul>
</div>
<div class="shrink-0">
<x-slot:action>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete"
@@ -106,9 +97,8 @@
Delete
</x-forms.button>
@endcan
</div>
</div>
</div>
</x-slot:action>
</x-danger-zone>
@cannot('delete', $storage)
<div class="mt-4">
@@ -15,47 +15,39 @@
<div class="application-settings-form">
<x-application.settings-section id="team-danger-zone" title="Danger zone"
helper="Destructive actions for this team cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete team</h4>
<x-status-badge status="Permanent" type="error" />
</div>
<x-danger-zone title="Delete team">
@if (auth()->user()->roleInTeam(currentTeam()->id) !== 'owner')
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
Only team owners can delete this team.
</p>
@elseif (session('currentTeam.id') === 0)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
The default team cannot be deleted.
</p>
@elseif(auth()->user()->teams()->count() === 1 || auth()->user()->currentTeam()->personal_team)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
Your last or personal team cannot be deleted.
</p>
@elseif(currentTeam()->subscription)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
Cancel your <a class="font-medium text-coollabs hover:underline dark:text-warning"
{{ wireNavigate() }} href="{{ route('subscription.show') }}">subscription</a>
before deleting this team.
</p>
@elseif($deletionBlockers === [])
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
Permanently delete <strong class="font-semibold text-black dark:text-fg">{{ currentTeam()->name }}</strong>
from Coolify. This action cannot be undone.
</p>
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
<ul class="space-y-1 text-xs">
<li> All members will lose access to this team.</li>
<li> This team cannot be restored from Coolify after deletion.</li>
</ul>
@else
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
This team still owns:
</p>
<ul class="mt-2 space-y-1 text-[13px] text-neutral-600 dark:text-fg-dim">
<ul class="space-y-1">
@foreach ($deletionBlockers as $type => $count)
<li>
<a class="font-medium text-coollabs hover:underline dark:text-warning"
@@ -65,13 +57,11 @@
</li>
@endforeach
</ul>
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
<p>
Remove or move these resources before deleting the team.
</p>
@endif
</div>
<div class="shrink-0">
<x-slot:action>
@if (
session('currentTeam.id') !== 0 &&
auth()->user()->roleInTeam(currentTeam()->id) === 'owner' &&
@@ -92,9 +82,8 @@
Delete team
</x-forms.button>
@endif
</div>
</div>
</div>
</x-slot:action>
</x-danger-zone>
@if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && (currentTeam()->projects->isNotEmpty() || currentTeam()->servers->isNotEmpty()))
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 dark:border-white/[0.08]">
+27
View File
@@ -0,0 +1,27 @@
<?php
it('uses one shared visual treatment for all danger zone actions', function () {
$component = file_get_contents(resource_path('views/components/danger-zone.blade.php'));
expect($component)
->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('<x-danger-zone')
->not->toContain('rounded-lg border border-red-300 bg-red-50');
}
});
@@ -1,23 +1,18 @@
<?php
it('stacks volume backup executions and keeps archive paths visible on mobile', function () {
it('keeps the volume backup executions table horizontally scrollable on mobile', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->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('<x-reicon name="trash" class="size-3.5" />');
});
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('/<x-callout[^>]*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('<span>Time</span>')
->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'));
+1 -4
View File
@@ -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('<x-danger-zone title="Delete team">')
->toContain('<table class="w-full text-left text-sm">')
->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')
+32 -2
View File
@@ -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();