diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index e72277e80..b4236b215 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -17,16 +18,36 @@ class CreateScheduledBackup extends Component public $frequency; #[Locked] - public $database; + public $database = null; + + #[Locked] + public ?Service $service = null; + + public ?string $selectedDatabaseUuid = null; public bool $enabled = true; + public function mount(): void + { + if ($this->service) { + $this->authorize('view', $this->service); + $this->selectedDatabaseUuid = $this->availableDatabases()->first()?->uuid; + } + } + public function submit() { try { - $this->authorize('manageBackups', $this->database); + $database = $this->selectedDatabase(); + if (! $database) { + $this->addError('selectedDatabaseUuid', 'Select a database owned by this service.'); - if (! $this->database->isBackupSolutionAvailable()) { + return; + } + + $this->authorize('manageBackups', $database); + + if (! $database->isBackupSolutionAvailable()) { $this->dispatch('error', 'Scheduled backups are not supported for this database type.'); return; @@ -46,36 +67,36 @@ class CreateScheduledBackup extends Component 'frequency' => $this->frequency, 'save_s3' => false, 's3_storage_id' => null, - 'database_id' => $this->database->id, - 'database_type' => $this->database->getMorphClass(), + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), 'team_id' => currentTeam()->id, ]; - if ($this->database->type() === 'standalone-postgresql') { - $payload['databases_to_backup'] = $this->database->postgres_db; - } elseif ($this->database->type() === 'standalone-mysql') { - $payload['databases_to_backup'] = $this->database->mysql_database; - } elseif ($this->database->type() === 'standalone-mariadb') { - $payload['databases_to_backup'] = $this->database->mariadb_database; - } elseif ($this->database->type() === 'standalone-clickhouse') { - $payload['databases_to_backup'] = $this->database->clickhouse_db; + if ($database->type() === 'standalone-postgresql') { + $payload['databases_to_backup'] = $database->postgres_db; + } elseif ($database->type() === 'standalone-mysql') { + $payload['databases_to_backup'] = $database->mysql_database; + } elseif ($database->type() === 'standalone-mariadb') { + $payload['databases_to_backup'] = $database->mariadb_database; + } elseif ($database->type() === 'standalone-clickhouse') { + $payload['databases_to_backup'] = $database->clickhouse_db; } $databaseBackup = ScheduledDatabaseBackup::create($payload); - if ($this->database->getMorphClass() === ServiceDatabase::class) { - $service = $this->database->service; + if ($database->getMorphClass() === ServiceDatabase::class) { + $service = $database->service; $this->redirectRoute('project.service.database.backup.show', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $this->database->uuid, + 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { $this->redirectRoute('project.database.backup.execution', [ - 'project_uuid' => $this->database->project()->uuid, - 'environment_uuid' => $this->database->environment->uuid, - 'database_uuid' => $this->database->uuid, + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } @@ -85,4 +106,32 @@ class CreateScheduledBackup extends Component $this->frequency = ''; } } + + public function render() + { + return view('livewire.project.database.create-scheduled-backup', [ + 'databaseOptions' => $this->availableDatabases()->map(fn (ServiceDatabase $database): array => [ + 'value' => $database->uuid, + 'label' => $database->human_name ?: $database->name, + ])->values()->all(), + ]); + } + + private function availableDatabases() + { + if (! $this->service) { + return collect(); + } + + return $this->service->databases->filter(fn (ServiceDatabase $database): bool => $database->isBackupSolutionAvailable()); + } + + private function selectedDatabase() + { + if (! $this->service) { + return $this->database; + } + + return $this->availableDatabases()->firstWhere('uuid', $this->selectedDatabaseUuid); + } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index a56653597..49da9e21f 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -2,8 +2,10 @@ namespace App\Livewire\Project\Service\VolumeBackup; +use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; +use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -30,6 +32,18 @@ class Index extends Component public function render(): View { + $databaseTargets = $this->service->databases->filter( + fn (ServiceDatabase $database): bool => $database->isBackupSolutionAvailable(), + ); + + $databaseBackups = ScheduledDatabaseBackup::query() + ->with(['database', 'latest_log', 's3']) + ->withCount('executions') + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->latest() + ->get(); + $backups = ScheduledVolumeBackup::query() ->with(['backupable.resource', 'latestExecution', 's3']) ->withCount('executions') @@ -37,7 +51,11 @@ class Index extends Component ->latest() ->get(); - return view('livewire.project.service.volume-backup.index', ['backups' => $backups]); + return view('livewire.project.service.volume-backup.index', [ + 'backups' => $backups, + 'databaseBackups' => $databaseBackups, + 'databaseTargets' => $databaseTargets, + ]); } private function findService(): Service diff --git a/resources/css/app.css b/resources/css/app.css index ccdde81b5..2a9356412 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2238,6 +2238,11 @@ input[type="search"]::-webkit-search-results-decoration { min-width: 50rem; } +.service-backup-table-grid { + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr); + min-width: 45rem; +} + /* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ .volumes-table-grid-readonly { grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 5rem; diff --git a/resources/views/livewire/project/database/create-scheduled-backup.blade.php b/resources/views/livewire/project/database/create-scheduled-backup.blade.php index 843343b01..682725b03 100644 --- a/resources/views/livewire/project/database/create-scheduled-backup.blade.php +++ b/resources/views/livewire/project/database/create-scheduled-backup.blade.php @@ -1,4 +1,11 @@
+ @if ($service) + + @error('selectedDatabaseUuid') +

{{ $message }}

+ @enderror + @endif diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 6b8373a63..28141d008 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -5,17 +5,23 @@ filterOpen: false, sortOpen: false, backups: @js($backups->map(fn ($backup) => [ - 'id' => (string) $backup->id, + 'id' => 'storage:'.$backup->id, 'name' => strtolower($backup->targetName()), 'type' => strtolower($backup->targetType()), 'frequency' => strtolower($backup->frequency), 'createdAt' => $backup->created_at?->timestamp ?? 0, - ])->values()), + ])->concat($databaseBackups->map(fn ($backup) => [ + 'id' => 'database:'.$backup->id, + 'name' => strtolower($backup->database->human_name ?: $backup->database->name), + 'type' => 'database', + 'frequency' => strtolower($backup->frequency), + 'createdAt' => $backup->created_at?->timestamp ?? 0, + ]))->values()), filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge( $backups->map(fn ($backup) => [ 'value' => strtolower($backup->targetType()), 'label' => $backup->targetType(), - ])->unique('value')->values() + ])->push(['value' => 'database', 'label' => 'Database'])->unique('value')->values() )->values()), sortOptions: [ { value: 'target_asc', label: 'Target A–Z' }, @@ -57,21 +63,48 @@ current-route="project.service.volume-backups.index" />
- + @can('update', $service) - - - - - - + Add backup + + + + +
+ @endcan @@ -79,19 +112,19 @@

Schedules

- {{ $backups->count() }} + {{ $backups->count() + $databaseBackups->count() }}

Enabled

- {{ $backups->where('enabled', true)->count() }} + {{ $backups->where('enabled', true)->count() + $databaseBackups->where('enabled', true)->count() }}

Total executions

- {{ $backups->sum('executions_count') }} + {{ $backups->sum('executions_count') + $databaseBackups->sum('executions_count') }}

@@ -165,25 +198,66 @@
$backups->isNotEmpty(), + 'is-flush' => $backups->isNotEmpty() || $databaseBackups->isNotEmpty(), ])>
- @if ($backups->isNotEmpty()) + @if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
-
+
Target Type Schedule Status S3 Last run - Executions
+ @foreach ($databaseBackups as $databaseBackup) + @php + $latestExecution = $databaseBackup->latest_log; + $status = $latestExecution?->status; + $statusLabel = match ($status) { + 'running' => 'In progress', + 'success' => 'Success', + 'failed' => 'Failed', + default => $databaseBackup->enabled ? 'Waiting' : 'Disabled', + }; + $statusType = match ($status) { + 'running' => 'warning', + 'success' => 'success', + 'failed' => 'error', + default => 'neutral', + }; + $databaseBackupId = 'database:'.$databaseBackup->id; + @endphp + + + {{ $databaseBackup->database->human_name ?: $databaseBackup->database->name }} + + Database + {{ $databaseBackup->frequency }} + + + + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} + + @endforeach + @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; @@ -202,11 +276,11 @@ }; @endphp + class="data-table-row backup-table-grid text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim"> {{ $backup->targetName() }} @@ -221,15 +295,12 @@ {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - - {{ $backup->executions_count }} - @endforeach
@else @endif
diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 3a5ea6a37..1b18c31b4 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -94,6 +94,36 @@ it('creates a service database backup without S3 and opens its configuration', f ->and($backup->s3_storage_id)->toBeNull(); }); +it('selects a service database when creating a backup from the unified backups page', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'primary', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $analytics = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'analytics', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + Livewire::test(CreateScheduledBackup::class, ['service' => $service]) + ->assertSee('Database') + ->assertSee('analytics') + ->set('selectedDatabaseUuid', $analytics->uuid) + ->set('frequency', 'daily') + ->call('submit'); + + expect(ScheduledDatabaseBackup::query()->sole()->database->is($analytics))->toBeTrue(); +}); + it('creates a clickhouse backup for its configured database', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php index 39887e602..e0e768df3 100644 --- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php +++ b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php @@ -80,13 +80,35 @@ it('shows the service sidebar on the storage backups page', function () { ->toContain('xl:grid-cols-[210px_minmax(0,1fr)]'); }); -it('links compose database backups to the parent service backups page', function () { +it('combines service database and storage backups in one section', function () { + $backups = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php')); + $styles = file_get_contents(resource_path('css/app.css')); + + expect(substr_count($backups, 'toBe(1) + ->and(substr_count($backups, 'data-table-header backup-table-grid'))->toBe(1) + ->and($backups)->toContain('Storage backup')->toContain('Database backup') + ->not->toContain('data-table-header scheduled-backups-table-grid') + ->not->toContain('>Database backups') + ->not->toContain('>Storage backups') + ->toContain("'application-settings-section-body w-full'") + ->toContain('class="data-table w-full overflow-x-auto"') + ->toContain('backup-table-grid service-backup-table-grid') + ->not->toContain('Executions') + ->toContain('class="data-table-row backup-table-grid text-[13px]') + ->toContain('class="listbox-option justify-start! gap-2.5!"') + ->toContain('x-data="{ dropdownOpen: false }"') + ->toContain('class="listbox-panel left-0! right-auto! z-[90]! w-52! min-w-52! sm:left-auto! sm:right-0!"') + ->not->toContain('toContain('.service-backup-table-grid'); +}); + +it('links compose database backups to the unified service backups page', function () { $sidebar = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')); expect($sidebar) ->toContain("'route' => 'project.service.volume-backups.index'") ->toContain("'parameters' => \$serviceParameters") - ->toContain("\$item['parameters'] ?? \$parameters") ->not->toContain("'route' => 'project.service.database.backups'"); }); diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php index 3a4549046..da730d97d 100644 --- a/tests/Feature/ServiceResourceRoutingTest.php +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -202,3 +202,38 @@ test('service database backup schedules use dedicated general retention and exec ->assertDontSee('Number of backups to keep') ->assertDontSee('Cleanup Failed Backups'); }); + +test('service storage backups page includes schedules from all compose databases', function () { + $secondDatabase = ServiceDatabase::create([ + 'service_id' => $this->ownService->id, + 'name' => 'analytics-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + + foreach ([$this->ownServiceDatabase, $secondDatabase] as $database) { + ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'description' => $database->name.' backup', + 'frequency' => 'daily', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ]); + } + + $this->get(route('project.service.volume-backups.index', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->ownService->uuid, + ])) + ->assertOk() + ->assertSee('>Database', false) + ->assertSee('own-db') + ->assertSee('analytics-db') + ->assertSee(route('project.service.database.backups', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + 'service_uuid' => $this->ownService->uuid, + 'stack_service_uuid' => $this->ownServiceDatabase->uuid, + ]), false); +});