mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
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:
@@ -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
@@ -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>
|
||||
|
||||
+56
-74
@@ -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]">
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user