Skip to content

Commit a52b0f3

Browse files
author
David Courtey
authored
feat: add per-backup trigger button on database server index (#226)
When a server has multiple backup configurations, each backup card now shows its own download button so users can trigger a specific config instead of running all at once. The backup display label is included in the success toast for clarity. Also simplifies the backup card layout (single-line, whitespace-nowrap) and narrows the Name column (w-80 → w-64) to give the Backup column more room.
1 parent cfd805d commit a52b0f3

3 files changed

Lines changed: 99 additions & 24 deletions

File tree

app/Livewire/DatabaseServer/Index.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Livewire\DatabaseServer;
44

55
use App\Enums\DatabaseType;
6+
use App\Models\Backup;
67
use App\Models\DatabaseServer;
78
use App\Models\NotificationChannel;
89
use App\Queries\DatabaseServerQuery;
@@ -70,7 +71,7 @@ public function clear(): void
7071
public function headers(): array
7172
{
7273
return [
73-
['key' => 'name', 'label' => __('Name'), 'class' => 'w-80'],
74+
['key' => 'name', 'label' => __('Name'), 'class' => 'w-64'],
7475
['key' => 'backup', 'label' => __('Backup'), 'sortable' => false],
7576
['key' => 'jobs', 'label' => __('Jobs'), 'sortable' => false, 'class' => 'w-32'],
7677
];
@@ -129,6 +130,24 @@ public function confirmRestore(string $id): void
129130
$this->dispatch('open-restore-modal', targetServerId: $id);
130131
}
131132

133+
public function runBackup(string $backupId, TriggerBackupAction $action): void
134+
{
135+
$backup = Backup::with(['databaseServer', 'volume', 'backupSchedule'])->findOrFail($backupId);
136+
137+
$this->authorize('backup', $backup->databaseServer);
138+
139+
try {
140+
$userId = auth()->id();
141+
$action->execute($backup, is_int($userId) ? $userId : null);
142+
$this->success(
143+
title: __('Backup started successfully!'),
144+
description: $backup->getDisplayLabel(),
145+
);
146+
} catch (\Throwable $e) {
147+
$this->error($e->getMessage(), timeout: 0);
148+
}
149+
}
150+
132151
public function runBackupAll(string $serverId, TriggerBackupAction $action): void
133152
{
134153
$server = DatabaseServer::with(['backups.volume', 'backups.backupSchedule'])->findOrFail($serverId);

resources/views/livewire/database-server/index.blade.php

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,31 +104,38 @@ class="btn-ghost btn-sm"
104104
@elseif($server->backups->isEmpty())
105105
<span class="text-base-content/50">—</span>
106106
@else
107-
<div class="flex flex-col gap-1 min-w-[200px] max-w-[400px]">
107+
<div class="flex flex-col gap-1">
108108
@foreach($server->backups as $backup)
109109
@php $label = $backup->getDisplayLabel(false); @endphp
110-
<div class="rounded-md bg-base-200/60 border border-base-300 px-2 py-1.5" title="{{ $backup->getDisplayLabel() }}">
111-
<div class="flex items-center gap-1.5 min-w-0 flex-wrap">
112-
{{-- Primary: schedule → volume --}}
113-
<x-icon name="o-clock" class="w-3 h-3 shrink-0 text-primary/80" />
114-
<span class="text-xs font-semibold text-base-content truncate">{{ $label['schedule'] }}</span>
115-
<span class="text-base-content/30 text-[0.625rem] shrink-0">→</span>
116-
<x-volume-type-icon :type="$backup->volume->type" class="w-3 h-3 shrink-0 text-primary/80" />
117-
<span class="text-xs font-semibold text-base-content truncate">{{ $label['volume'] }}</span>
118-
{{-- Secondary: databases + retention badges (wrap to next line if needed) --}}
119-
@if($label['databases'])
120-
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.625rem] font-medium leading-none bg-base-300/60 text-base-content/60">
121-
<x-icon name="o-circle-stack" class="w-2.5 h-2.5 shrink-0" />
122-
<span class="max-w-[120px] truncate">{{ $label['databases'] }}</span>
123-
</span>
124-
@endif
125-
@if($label['retention'])
126-
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.625rem] font-medium leading-none bg-info/10 text-info">
127-
<x-icon name="o-archive-box" class="w-2.5 h-2.5 shrink-0" />
128-
<span>{{ $label['retention'] }}</span>
129-
</span>
130-
@endif
131-
</div>
110+
<div class="flex items-center gap-1.5 whitespace-nowrap rounded-md bg-base-200/60 border border-base-300 px-2 py-1.5" title="{{ $backup->getDisplayLabel() }}">
111+
<x-icon name="o-clock" class="w-3 h-3 shrink-0 text-primary/80" />
112+
<span class="text-xs font-semibold text-base-content">{{ $label['schedule'] }}</span>
113+
<span class="text-base-content/30 text-[0.625rem]">→</span>
114+
<x-volume-type-icon :type="$backup->volume->type" class="w-3 h-3 shrink-0 text-primary/80" />
115+
<span class="text-xs font-semibold text-base-content">{{ $label['volume'] }}</span>
116+
@if($label['databases'])
117+
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.625rem] font-medium leading-none bg-base-300/60 text-base-content/60">
118+
<x-icon name="o-circle-stack" class="w-2.5 h-2.5" />
119+
{{ $label['databases'] }}
120+
</span>
121+
@endif
122+
@if($label['retention'])
123+
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.625rem] font-medium leading-none bg-info/10 text-info">
124+
<x-icon name="o-archive-box" class="w-2.5 h-2.5" />
125+
{{ $label['retention'] }}
126+
</span>
127+
@endif
128+
@if($server->backups->count() > 1)
129+
@can('backup', $server)
130+
<x-button
131+
icon="o-arrow-down-tray"
132+
wire:click="runBackup('{{ $backup->id }}')"
133+
spinner
134+
tooltip="{{ __('Backup now') }}"
135+
class="btn-ghost btn-xs text-info ml-auto -mr-1"
136+
/>
137+
@endcan
138+
@endif
132139
</div>
133140
@endforeach
134141
</div>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
use App\Jobs\ProcessBackupJob;
4+
use App\Livewire\DatabaseServer\Index;
5+
use App\Models\Backup;
6+
use App\Models\DatabaseServer;
7+
use App\Models\User;
8+
use Illuminate\Support\Facades\Queue;
9+
use Livewire\Livewire;
10+
11+
beforeEach(function () {
12+
Queue::fake();
13+
});
14+
15+
test('runBackup triggers backup for a specific backup configuration', function () {
16+
$user = User::factory()->create(['role' => User::ROLE_ADMIN]);
17+
$server = DatabaseServer::factory()->withoutBackups()->create();
18+
$backup = Backup::factory()->for($server)->selected(['test_db'])->create();
19+
20+
Livewire::actingAs($user)
21+
->test(Index::class)
22+
->call('runBackup', $backup->id);
23+
24+
Queue::assertPushed(ProcessBackupJob::class, 1);
25+
});
26+
27+
test('runBackup includes backup display label in success toast', function () {
28+
$user = User::factory()->create(['role' => User::ROLE_ADMIN]);
29+
$server = DatabaseServer::factory()->withoutBackups()->create();
30+
$backup = Backup::factory()->for($server)->selected(['test_db'])->create();
31+
32+
Livewire::actingAs($user)
33+
->test(Index::class)
34+
->call('runBackup', $backup->id);
35+
36+
// Verify a backup job was created
37+
Queue::assertPushed(ProcessBackupJob::class, 1);
38+
});
39+
40+
test('runBackup fails with authorization error if user is viewer', function () {
41+
$user = User::factory()->create(['role' => User::ROLE_VIEWER]);
42+
$server = DatabaseServer::factory()->withoutBackups()->create();
43+
$backup = Backup::factory()->for($server)->selected(['test_db'])->create();
44+
45+
Livewire::actingAs($user)
46+
->test(Index::class)
47+
->call('runBackup', $backup->id)
48+
->assertForbidden();
49+
});

0 commit comments

Comments
 (0)