mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 12:23:48 +00:00
feat(ui): show S3 backup status and move domain action
Eager-load S3 on volume backup lists and add an S3 status column with Configured/Unavailable/Not set badges. Surface Local vs S3 on the persistent storages backup column. Move service resource domain links to a globe action in the card footer, and update related tests and grid CSS.
This commit is contained in:
@@ -31,7 +31,7 @@ class Index extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable', 'latestExecution'])
|
||||
->with(['backupable', 'latestExecution', 's3'])
|
||||
->withCount('executions')
|
||||
->forApplication($this->application)
|
||||
->latest()
|
||||
|
||||
@@ -31,7 +31,7 @@ class Index extends Component
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable.resource', 'latestExecution'])
|
||||
->with(['backupable.resource', 'latestExecution', 's3'])
|
||||
->withCount('executions')
|
||||
->forService($this->service)
|
||||
->latest()
|
||||
|
||||
@@ -28,7 +28,7 @@ class All extends Component
|
||||
/**
|
||||
* Precomputed per-volume backup badge/link data.
|
||||
*
|
||||
* @var array<int, array{enabled: bool, url: ?string}>
|
||||
* @var array<int, array{enabled: bool, s3: bool, url: ?string}>
|
||||
*/
|
||||
public array $volumeBackupMeta = [];
|
||||
|
||||
@@ -241,6 +241,7 @@ class All extends Component
|
||||
|
||||
$this->volumeBackupMeta[(int) $storage->id] = [
|
||||
'enabled' => $enabled,
|
||||
's3' => $enabled && (bool) $backup?->save_s3,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2149,7 +2149,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
|
||||
}
|
||||
|
||||
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
|
||||
@@ -2383,11 +2383,12 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem minmax(8rem, 1fr);
|
||||
grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem 6.5rem;
|
||||
}
|
||||
|
||||
.backup-table-grid > :nth-child(3),
|
||||
.backup-table-grid > :nth-child(6) {
|
||||
.backup-table-grid > :nth-child(6),
|
||||
.backup-table-grid > :nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2443,11 +2444,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-table-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 7.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) 7.5rem 6.5rem;
|
||||
}
|
||||
|
||||
.backup-table-grid > :nth-child(2),
|
||||
.backup-table-grid > :nth-child(5) {
|
||||
.backup-table-grid > :nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
@@ -212,6 +213,10 @@
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
|
||||
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
|
||||
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
|
||||
</span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
|
||||
@@ -39,17 +39,10 @@
|
||||
@endif
|
||||
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
<div class="mt-2 flex min-w-0 items-center gap-1.5">
|
||||
<div class="mt-2 min-w-0">
|
||||
<span class="min-w-0 truncate text-xs text-neutral-500 dark:text-fg-dim">
|
||||
{{ $resource->fqdn }}
|
||||
</span>
|
||||
@can('update', $service)
|
||||
<a class="icon-button shrink-0" title="Edit domains" aria-label="Edit domains"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -63,6 +56,14 @@
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
@can('update', $service)
|
||||
<a class="icon-button" title="Manage domains" aria-label="Manage domains" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="globe" class="size-4" />
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
@@ -109,6 +110,14 @@
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
@if ($isApplication && $resource->fqdn)
|
||||
@can('update', $service)
|
||||
<a class="icon-button" title="Manage domains" aria-label="Manage domains" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.domains', $parameters) }}">
|
||||
<x-reicon name="globe" class="size-4" />
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
@@ -213,6 +214,10 @@
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
|
||||
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
|
||||
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
|
||||
</span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
if (! $form) {
|
||||
continue;
|
||||
}
|
||||
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 'url' => null];
|
||||
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 's3' => false, 'url' => null];
|
||||
$hasEnabledBackup = $backupMeta['enabled'];
|
||||
$hasS3Backup = $backupMeta['s3'];
|
||||
$backupUrl = $backupMeta['url'];
|
||||
$inputsReadonly = $form['isReadOnly'];
|
||||
$displayHostPath = filled($form['hostPath']) ? $form['hostPath'] : '—';
|
||||
@@ -78,7 +79,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-backup data-table-cell-check">
|
||||
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
|
||||
<span class="volumes-mobile-label volumes-field-label">Backup</span>
|
||||
@if ($hasEnabledBackup)
|
||||
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
|
||||
@@ -87,6 +88,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
|
||||
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
|
||||
{{ $hasS3Backup ? 'S3' : 'Local' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
@endif
|
||||
@@ -153,7 +158,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-backup data-table-cell-check">
|
||||
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
|
||||
<span class="volumes-mobile-label volumes-field-label">Backup</span>
|
||||
@if ($hasEnabledBackup)
|
||||
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
|
||||
@@ -162,6 +167,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
|
||||
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
|
||||
{{ $hasS3Backup ? 'S3' : 'Local' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
@endif
|
||||
|
||||
@@ -66,8 +66,8 @@ it('styles icon buttons with a visible hover state', function () {
|
||||
->toContain('dark:hover:bg-white/[0.07]');
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('class="icon-button shrink-0"')
|
||||
->toContain('title="Edit domains"');
|
||||
->toContain('class="icon-button"')
|
||||
->toContain('title="Manage domains"');
|
||||
});
|
||||
|
||||
it('exposes enable and disable public access methods on service index', function () {
|
||||
|
||||
@@ -107,11 +107,12 @@ it('renders volume rows without nesting Livewire Show components', function () {
|
||||
it('batches volume backup meta and exposes forms for every volume', function () {
|
||||
[$application, , $team] = createPerfApplicationWithVolumes(5);
|
||||
|
||||
foreach ($application->persistentStorages as $storage) {
|
||||
foreach ($application->persistentStorages as $index => $storage) {
|
||||
$storage->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
'frequency' => 'daily',
|
||||
'enabled' => true,
|
||||
'save_s3' => $index === 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -120,7 +121,10 @@ it('batches volume backup meta and exposes forms for every volume', function ()
|
||||
expect($component->get('volumeBackupMeta'))->toHaveCount(5)
|
||||
->and($component->get('forms'))->toHaveCount(5)
|
||||
->and($component->html())->toContain('volumes-col-backup')
|
||||
->not->toContain('table-badge table-badge-success');
|
||||
->toContain('Backups are saved to S3')
|
||||
->toContain('Backups are stored locally only');
|
||||
|
||||
expect($component->get("volumeBackupMeta.{$application->persistentStorages->first()->id}.s3"))->toBeTrue();
|
||||
|
||||
foreach ($component->get('volumeBackupMeta') as $meta) {
|
||||
expect($meta['enabled'])->toBeTrue()
|
||||
|
||||
@@ -44,3 +44,13 @@ it('aligns compose resource columns and uses icon actions', function () {
|
||||
->not->toContain('>Settings</a>')
|
||||
->not->toContain('>Backups</a>');
|
||||
});
|
||||
|
||||
it('distinguishes domain management from resource settings', function () {
|
||||
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
|
||||
|
||||
expect($resourceCard)
|
||||
->toContain('title="Manage domains" aria-label="Manage domains"')
|
||||
->toContain('<x-reicon name="globe" class="size-4" />')
|
||||
->toContain('title="Resource settings" aria-label="Resource settings"')
|
||||
->not->toContain('title="Edit domains" aria-label="Edit domains"');
|
||||
});
|
||||
|
||||
@@ -56,6 +56,21 @@ it('keeps the volume backup script inside the Livewire root element', function (
|
||||
expect(strrpos($view, '@endscript'))->toBeLessThan(strrpos($view, '</div>'));
|
||||
});
|
||||
|
||||
it('shows the S3 configuration state in application and service backup tables', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/backup/index.blade.php'),
|
||||
resource_path('views/livewire/project/service/volume-backup/index.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))
|
||||
->toContain('<span>S3</span>')
|
||||
->toContain("'Configured'")
|
||||
->toContain("'Unavailable'")
|
||||
->toContain("'Not set'");
|
||||
}
|
||||
});
|
||||
|
||||
it('targets named volumes and application directory mounts through one backup relation', function () {
|
||||
$team = Team::factory()->create();
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
|
||||
Reference in New Issue
Block a user