feat(storage): add resources tab and improve S3 deletion handling

Add new Resources tab to storage show page displaying backup schedules using
that storage. Refactor storage show layout with navigation tabs for General
and Resources sections. Move delete action from form to show component.

Implement cascade deletion in S3Storage model to automatically disable S3
backups when storage is deleted. Improve error handling in DatabaseBackupJob
to throw exception when S3 storage is missing instead of silently returning.

- New Storage/Resources Livewire component
- Add resources.blade.php view
- Add storage.resources route
- Move delete() method from Form to Show component
- Add deleting event listener to S3Storage model
- Track backup count and current route in Show component
- Add #[On('submitStorage')] attribute to form submission
This commit is contained in:
Andras Bacsai 2026-03-19 11:42:29 +01:00
parent d0b4dc1c63
commit ca3ae289eb
10 changed files with 285 additions and 48 deletions

View file

@ -625,10 +625,16 @@ private function calculate_size()
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
if (is_null($this->s3)) {
return;
}
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;

View file

@ -6,6 +6,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
class Form extends Component
@ -131,19 +132,7 @@ public function testConnection()
}
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
#[On('submitStorage')]
public function submit()
{
try {

View file

@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Livewire\Component;
class Resources extends Component
{
public S3Storage $storage;
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->with('database')
->get();
return view('livewire.storage.resources', [
'backups' => $backups,
]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -12,6 +13,10 @@ class Show extends Component
public $storage = null;
public string $currentRoute = '';
public int $backupCount = 0;
public function mount()
{
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
@ -19,6 +24,21 @@ public function mount()
abort(404);
}
$this->authorize('view', $this->storage);
$this->currentRoute = request()->route()->getName();
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()

View file

@ -40,6 +40,13 @@ protected static function boot(): void
$storage->secret = trim($storage->secret);
}
});
static::deleting(function (S3Storage $storage) {
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
@ -59,6 +66,11 @@ public function team()
return $this->belongsTo(Team::class);
}
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";

View file

@ -1,36 +1,5 @@
<div>
<form class="flex flex-col gap-2 pb-6" wire:submit='submit'>
<div class="flex items-start gap-2">
<div class="">
<h1>Storage Details</h1>
<div class="subtitle">{{ $storage->name }}</div>
<div class="flex items-center gap-2 pb-4">
<div>Current Status:</div>
@if ($isUsable)
<span
class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span
class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
</div>
</div>
<x-forms.button canGate="update" :canResource="$storage" type="submit">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="[
'The selected storage location will be permanently deleted from Coolify.',
'If the storage location is in use by any backup jobs those backup jobs will only store the backup locally on the server.',
]" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />

View file

@ -0,0 +1,62 @@
<div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($backups as $backup)
@php
$database = $backup->database;
$databaseName = $database?->name ?? 'Deleted database';
$link = null;
if ($database && $database instanceof \App\Models\ServiceDatabase) {
$service = $database->service;
if ($service) {
$environment = $service->environment;
$project = $environment?->project;
if ($project && $environment) {
$link = route('project.service.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
]);
}
}
} elseif ($database) {
$environment = $database->environment;
$project = $environment?->project;
if ($project && $environment) {
$link = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
}
}
@endphp
@if ($link)
<a {{ wireNavigate() }} href="{{ $link }}" @class(['gap-2 border cursor-pointer coolbox group'])>
@else
<div @class(['gap-2 border coolbox'])>
@endif
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $databaseName }}
</div>
<div class="box-description">
Frequency: {{ $backup->frequency }}
</div>
@if (!$backup->enabled)
<span class="px-2 py-1 text-xs font-semibold text-yellow-800 bg-yellow-100 rounded dark:text-yellow-100 dark:bg-yellow-800 w-fit">
Disabled
</span>
@endif
</div>
@if ($link)
</a>
@else
</div>
@endif
@empty
<div>
<div>No backup schedules are using this storage.</div>
</div>
@endforelse
</div>
</div>

View file

@ -2,5 +2,51 @@
<x-slot:title>
{{ data_get_str($storage, 'name')->limit(10) }} >Storages | Coolify
</x-slot>
<livewire:storage.form :storage="$storage" />
<div class="flex items-center gap-2">
<h1>Storage Details</h1>
@if ($storage->is_usable)
<span class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
<x-forms.button canGate="update" :canResource="$storage" wire:click="$dispatch('submitStorage')" :disabled="$currentRoute !== 'storage.show'">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="array_filter([
'The selected storage location will be permanently deleted from Coolify.',
$backupCount > 0
? $backupCount . ' backup schedule(s) will be updated to no longer save to S3 and will only store backups locally on the server.'
: null,
])" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="subtitle">{{ $storage->name }}</div>
<div class="navbar-main">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('storage.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.show', ['storage_uuid' => $storage->uuid]) }}">
General
</a>
<a class="{{ request()->routeIs('storage.resources') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.resources', ['storage_uuid' => $storage->uuid]) }}">
Resources
</a>
</nav>
</div>
<div class="pt-4">
@if ($currentRoute === 'storage.show')
<livewire:storage.form :storage="$storage" />
@elseif ($currentRoute === 'storage.resources')
<livewire:storage.resources :storage="$storage" />
@endif
</div>
</div>

View file

@ -140,6 +140,7 @@
Route::prefix('storages')->group(function () {
Route::get('/', StorageIndex::class)->name('storage.index');
Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show');
Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources');
});
Route::prefix('shared-variables')->group(function () {
Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index');

View file

@ -1,6 +1,10 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
@ -35,3 +39,108 @@
expect($casts)->toHaveKey('s3_storage_deleted');
expect($casts['s3_storage_deleted'])->toBe('boolean');
});
test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => 99999,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$s3Property = $reflection->getProperty('s3');
$s3Property->setValue($job, null);
$method = $reflection->getMethod('upload_to_s3');
expect(fn () => $method->invoke($job))
->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
test('deleting s3 storage disables s3 on linked backups', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
$backup1 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$backup2 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandaloneMysql',
'database_id' => 2,
'team_id' => $team->id,
]);
// Unrelated backup should not be affected
$unrelatedBackup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 3,
'team_id' => $team->id,
]);
$s3->delete();
$backup1->refresh();
$backup2->refresh();
$unrelatedBackup->refresh();
expect($backup1->save_s3)->toBeFalsy();
expect($backup1->s3_storage_id)->toBeNull();
expect($backup2->save_s3)->toBeFalsy();
expect($backup2->s3_storage_id)->toBeNull();
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
expect($s3->scheduledBackups()->count())->toBe(1);
});