feat(storage): add storage management for backup schedules

Add ability to move backups between S3 storages and disable S3 backups.
Refactor storage resources view from cards to table layout with search
functionality and storage selection dropdowns.
This commit is contained in:
Andras Bacsai 2026-03-19 12:48:52 +01:00
parent 86c8ec9c20
commit ce5e736b00
3 changed files with 165 additions and 79 deletions

View file

@ -10,6 +10,61 @@ class Resources extends Component
{
public S3Storage $storage;
public array $selectedStorages = [];
public function mount(): void
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->get();
foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id;
}
}
public function disableS3(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
}
public function moveBackup(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
return;
}
$newStorage = S3Storage::where('id', $newStorageId)
->where('team_id', $this->storage->team_id)
->first();
if (! $newStorage) {
$this->dispatch('error', 'Storage not found.');
return;
}
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
@ -18,8 +73,13 @@ public function render()
->get()
->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id);
$allStorages = S3Storage::where('team_id', $this->storage->team_id)
->orderBy('name')
->get(['id', 'name', 'is_usable']);
return view('livewire.storage.resources', [
'groupedBackups' => $backups,
'allStorages' => $allStorages,
]);
}
}

View file

@ -163,7 +163,7 @@ tbody {
}
tr {
@apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200;
@apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100;
}
tr th {

View file

@ -1,81 +1,107 @@
<div>
@forelse ($groupedBackups as $backups)
@php
$firstBackup = $backups->first();
$database = $firstBackup->database;
$databaseName = $database?->name ?? 'Deleted database';
$resourceLink = null;
$backupParams = null;
if ($database && $database instanceof \App\Models\ServiceDatabase) {
$service = $database->service;
if ($service) {
$environment = $service->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = 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) {
$resourceLink = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
$backupParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
}
}
@endphp
<div class="pb-6">
<div class="flex items-center gap-2 pb-2">
@if ($resourceLink)
<a {{ wireNavigate() }} href="{{ $resourceLink }}" class="text-lg font-bold dark:text-white hover:underline">{{ $databaseName }}</a>
@else
<span class="text-lg font-bold dark:text-white">{{ $databaseName }}</span>
@endif
</div>
<div class="grid gap-4 lg:grid-cols-2">
@foreach ($backups as $backup)
@php
$backupLink = null;
if ($backupParams) {
$backupLink = route('project.database.backup.execution', array_merge($backupParams, [
'backup_uuid' => $backup->uuid,
]));
}
@endphp
@if ($backupLink)
<a {{ wireNavigate() }} href="{{ $backupLink }}" @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">{{ $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 ($backupLink)
</a>
@else
</div>
@endif
@endforeach
<div x-data="{ search: '' }">
<x-forms.input placeholder="Search resources..." x-model="search" id="null" />
@if ($groupedBackups->count() > 0)
<div class="overflow-x-auto pt-4">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Database</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Frequency</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Status</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">S3 Storage</th>
</tr>
</thead>
<tbody>
@foreach ($groupedBackups as $backups)
@php
$firstBackup = $backups->first();
$database = $firstBackup->database;
$databaseName = $database?->name ?? 'Deleted database';
$resourceLink = null;
$backupParams = null;
if ($database && $database instanceof \App\Models\ServiceDatabase) {
$service = $database->service;
if ($service) {
$environment = $service->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = 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) {
$resourceLink = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
$backupParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
}
}
@endphp
@foreach ($backups as $backup)
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100" x-show="search === '' || '{{ strtolower(addslashes($databaseName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<td class="px-5 py-4 text-sm whitespace-nowrap">
@if ($resourceLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $resourceLink }}">{{ $databaseName }} <x-internal-link /></a>
@else
{{ $databaseName }}
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
@php
$backupLink = null;
if ($backupParams) {
$backupLink = route('project.database.backup.execution', array_merge($backupParams, [
'backup_uuid' => $backup->uuid,
]));
}
@endphp
@if ($backupLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $backupLink }}">{{ $backup->frequency }} <x-internal-link /></a>
@else
{{ $backup->frequency }}
@endif
</td>
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
@if ($backup->enabled)
<span class="text-green-500">Enabled</span>
@else
<span class="text-yellow-500">Disabled</span>
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
<div class="flex items-center gap-2">
<select wire:model="selectedStorages.{{ $backup->id }}" class="w-full input">
@foreach ($allStorages as $s3)
<option value="{{ $s3->id }}" @disabled(!$s3->is_usable)>{{ $s3->name }}@if (!$s3->is_usable) (unusable)@endif</option>
@endforeach
</select>
<x-forms.button wire:click="moveBackup({{ $backup->id }})">Save</x-forms.button>
<x-forms.button isError wire:click="disableS3({{ $backup->id }})" wire:confirm="Are you sure you want to disable S3 for this backup schedule?">Disable S3</x-forms.button>
</div>
</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@empty
<div>No backup schedules are using this storage.</div>
@endforelse
@else
<div class="pt-4">No backup schedules are using this storage.</div>
@endif
</div>