fix(ui): copy service backup path and show S3 badge only for runs stored in S3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-06 10:38:49 +01:00
co-authored by Claude Opus 5.5
parent 7fbc5d7797
commit 3115980c77
4 changed files with 37 additions and 44 deletions
@@ -179,11 +179,11 @@ class BackupExecutions extends Component
private function formatExecutions(Collection $rows): Collection
{
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages', 's3Replicas'])
->with(['scheduledDatabaseBackup.database', 's3Replicas.s3'])
->whereIn('id', $rows->where('type', 'database')->pluck('id'))
->get()->keyBy('id');
$volumeExecutions = ScheduledVolumeBackupExecution::query()
->with(['scheduledVolumeBackup.backupable.resource', 's3', 's3Replicas'])
->with(['scheduledVolumeBackup.backupable.resource', 's3Replicas.s3'])
->whereIn('id', $rows->where('type', 'storage')->pluck('id'))
->get()->keyBy('id');
@@ -193,21 +193,11 @@ class BackupExecutions extends Component
$isDatabase = $row->type === 'database';
$execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id);
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
$storages = match (true) {
! $isDatabase => collect([$execution->s3]),
! $schedule->save_s3 => collect(),
$schedule->s3Storages->isNotEmpty() => $schedule->s3Storages,
default => collect([$schedule->s3]),
};
$storageLabel = $storages
->filter(fn ($storage) => $storage?->team_id === $serviceTeamId)
->map(fn ($storage) => $storage->name.' (bucket: '.$storage->bucket.')')
->implode(', ') ?: 'Unavailable';
if ($isDatabase && ! $schedule->save_s3) {
$storageLabel = 'Not configured';
} elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
$storageLabel = 'No destination recorded';
}
$s3Storages = $execution->s3Replicas
->map(fn ($replica) => $replica->s3?->team_id === $serviceTeamId
? $replica->s3->name.' (bucket: '.$replica->s3->bucket.')'
: 'Unavailable')
->implode(', ');
return [
'id' => $row->type.':'.$execution->id,
@@ -215,7 +205,7 @@ class BackupExecutions extends Component
'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(),
'type' => $isDatabase ? 'Database' : $schedule->targetType(),
'schedule' => $schedule->frequency,
's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel,
's3_tooltip' => $s3Storages === '' ? null : 'S3 storage: '.$s3Storages,
'status' => $execution->status,
'started_at' => $execution->created_at,
'size' => $execution->size,
@@ -19,7 +19,13 @@
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Size</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['size'] ? formatBytes($selectedExecution['size']) : '-' }}</p></div>
</div>
@if ($selectedExecution['filename'])
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Backup path</p><code class="mt-1 block overflow-x-auto rounded-md bg-neutral-100 p-3 text-xs dark:bg-black/20">{{ $selectedExecution['filename'] }}</code></div>
<div>
<p class="text-xs text-neutral-500 dark:text-fg-dim">Backup path</p>
<div class="mt-1 flex items-center gap-2 rounded-md bg-neutral-100 p-3 dark:bg-black/20">
<code class="min-w-0 flex-1 overflow-x-auto text-xs">{{ $selectedExecution['filename'] }}</code>
<x-copy-button :value="$selectedExecution['filename']" label="Copy backup path" />
</div>
</div>
@endif
@if ($selectedExecution['message'])
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Output</p><pre class="mt-1 max-h-80 overflow-auto rounded-md bg-neutral-100 p-3 font-mono text-xs whitespace-pre-wrap dark:bg-black/20">{{ $selectedExecution['message'] }}</pre></div>
@@ -66,10 +72,11 @@
class="data-table-row service-backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 dark:text-fg-dim">
<span class="flex min-w-0 items-center gap-2 font-medium text-neutral-950 dark:text-fg">
<span class="truncate" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
<span tabindex="0" data-tooltip="{{ $execution['s3_tooltip'] }}"
aria-label="{{ $execution['s3_tooltip'] }}" class="shrink-0 text-neutral-500 dark:text-fg-dim">
<x-reicon name="cloud" class="size-3.5" aria-hidden="true" />
</span>
@if ($execution['s3_tooltip'])
<span tabindex="0" data-tooltip="{{ $execution['s3_tooltip'] }}"
aria-label="{{ $execution['s3_tooltip'] }}"
class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">S3</span>
@endif
</span>
<span>{{ $execution['type'] }}</span><span>{{ $execution['schedule'] }}</span>
<span><x-status-badge :status="str($execution['status'])->headline()" :type="$statusType" /></span>
@@ -189,7 +189,8 @@ test('service backup executions label the S3 storage of the service team after a
'database_id' => $this->serviceDatabase->id,
'database_type' => $this->serviceDatabase->getMorphClass(),
]);
ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $schedule->id, 'status' => 'success']);
ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $schedule->id, 'status' => 'success'])
->s3Replicas()->create(['s3_storage_id' => $ownS3->id, 's3_uploaded' => true]);
$component = Livewire::test(BackupExecutions::class, ['service' => $this->service]);
session(['currentTeam' => $this->teamB]);
+15 -20
View File
@@ -635,7 +635,7 @@ test('service backup lists identify the configured S3 storage without extra colu
}
});
test('execution tooltips distinguish current database storage from the recorded storage destination', function () {
test('execution tooltips show the S3 storage of each run and no icon for local-only runs', function () {
$original = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Cloudflare R2', 'bucket' => 'original']);
$current = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Railway S3', 'bucket' => 'current']);
$databaseSchedule = ScheduledDatabaseBackup::create([
@@ -646,7 +646,9 @@ test('execution tooltips distinguish current database storage from the recorded
'save_s3' => true,
's3_storage_id' => $current->id,
]);
$databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true]);
$databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true])
->s3Replicas()->create(['s3_storage_id' => $original->id, 's3_uploaded' => true]);
$databaseSchedule->executions()->create(['status' => 'success']);
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
@@ -659,24 +661,17 @@ test('execution tooltips distinguish current database storage from the recorded
'save_s3' => true,
's3_storage_id' => $current->id,
]);
$volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id]);
$volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id])
->s3Replicas()->create(['s3_storage_id' => $original->id, 's3_uploaded' => true]);
$volumeSchedule->executions()->create(['status' => 'success']);
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Railway S3 (bucket: current)"')
->assertSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"');
$html = Livewire::test(BackupExecutions::class, ['service' => $this->ownService])->html();
expect(substr_count($html, 'data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"'))->toBe(2)
->and(substr_count($html, 'data-tooltip="S3 storage:'))->toBe(2)
->and($html)->not->toContain('Railway S3');
$current->update(['team_id' => $this->teamB->id]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->assertDontSee('Railway S3')
->assertSeeHtml('data-tooltip="S3 storage: Unavailable"');
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertDontSee('Railway S3')
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Unavailable"');
$databaseSchedule->update(['save_s3' => false]);
$original->delete();
Livewire::test(BackupExecutions::class, ['service' => $this->ownService])
->assertSeeHtml('data-tooltip="Current schedule S3 storage: Not configured"')
->assertDontSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"')
->assertSeeHtml('data-tooltip="S3 storage: Unavailable"');
$original->update(['team_id' => $this->teamB->id]);
$html = Livewire::test(BackupExecutions::class, ['service' => $this->ownService])->html();
expect(substr_count($html, 'data-tooltip="S3 storage: Unavailable"'))->toBe(2)
->and($html)->not->toContain('Cloudflare R2');
});