feat(ui): add volume backup storage controls and grouped navigation

This commit is contained in:
Andras Bacsai
2026-08-10 10:35:15 +02:00
parent 4ba6aa3500
commit 62b2fe2330
20 changed files with 373 additions and 112 deletions
+72
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -15,6 +16,8 @@ class Resources extends Component
public array $selectedStorages = [];
public array $selectedVolumeStorages = [];
public function mount(): void
{
$this->authorize('view', $this->storage);
@@ -26,6 +29,13 @@ class Resources extends Component
foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id;
}
ScheduledVolumeBackup::query()
->where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->each(function (ScheduledVolumeBackup $backup): void {
$this->selectedVolumeStorages[$backup->id] = $this->storage->id;
});
}
public function disableS3(int $backupId): void
@@ -80,6 +90,61 @@ class Resources extends Component
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function disableVolumeS3(int $backupId): void
{
$this->authorize('update', $this->storage);
$backup = ScheduledVolumeBackup::query()
->where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
$backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
unset($this->selectedVolumeStorages[$backupId]);
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
}
public function moveVolumeBackup(int $backupId): void
{
$this->authorize('update', $this->storage);
$backup = ScheduledVolumeBackup::query()
->where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
$newStorageId = $this->selectedVolumeStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
return;
}
$newStorage = S3Storage::query()
->where('id', $newStorageId)
->where('team_id', $this->storage->team_id)
->first();
if (! $newStorage) {
$this->dispatch('error', 'Storage not found.');
return;
}
$this->authorize('update', $newStorage);
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedVolumeStorages[$backupId]);
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
@@ -92,8 +157,15 @@ class Resources extends Component
->orderBy('name')
->get(['id', 'name', 'is_usable']);
$volumeBackups = ScheduledVolumeBackup::query()
->where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->with('backupable.resource')
->get();
return view('livewire.storage.resources', [
'groupedBackups' => $backups,
'volumeBackups' => $volumeBackups,
'allStorages' => $allStorages,
]);
}
+2 -2
View File
@@ -131,11 +131,11 @@
}
@utility button-highlighted {
@apply border-2 text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;
@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;
}
@utility control-selected {
@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;
@apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white;
}
@utility loading-indicator {
@@ -52,12 +52,12 @@
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Deployment',
'label' => 'Deployment Logs',
'route' => 'project.application.deployment.index',
'active' => str($currentRoute)->startsWith('project.application.deployment'),
],
[
'label' => 'Runtime',
'label' => 'Runtime Logs',
'route' => 'project.application.logs',
'active' => $currentRoute === 'project.application.logs',
],
@@ -142,8 +142,8 @@
'Persistent Storage' => 'storages',
'Backups' => 'database',
'Terminal' => 'browser-terminal',
'Deployment' => 'time-back',
'Runtime' => 'unordered-list',
'Deployment Logs' => 'time-back',
'Runtime Logs' => 'unordered-list',
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
@@ -160,14 +160,17 @@
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'],
'Logs' => ['Deployment', 'Runtime'],
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
->map(fn (array $labels) => collect($labels)
->map(fn (string $label) => collect($configurationMenuItems)->firstWhere('label', $label))
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page
@@ -14,7 +14,7 @@
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
@@ -32,14 +32,18 @@
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics'],
'Deploy' => ['Servers'],
'Automation' => ['Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->map(fn (array $labels) => collect($labels)
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
$pageSections = $database->type() === 'standalone-postgresql'
@@ -13,10 +13,12 @@
'value' => null, // initial value when wire=false
'disabled' => false,
'tooltip' => true,
'portal' => false,
])
@php
$triggerId = ($htmlId ?? $id).'-trigger';
$panelId = ($htmlId ?? $id).'-panel';
@endphp
<div class="w-full min-w-0">
@@ -41,6 +43,7 @@
@endif
<div class="relative min-w-0" x-data="{
open: false,
positioned: false,
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
get current() {
@@ -53,11 +56,42 @@
if (String(option.value) === String(this.value)) return;
this.value = option.value;
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
},
toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open && @js($portal)) {
this.$nextTick(() => requestAnimationFrame(() => this.positionPanel()));
}
},
positionPanel(panel = null) {
const trigger = this.$refs.trigger;
panel ??= document.getElementById(@js($panelId));
if (!trigger || !panel) return;
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = Math.max(triggerRect.width, panel.offsetWidth);
const panelHeight = Math.min(panel.scrollHeight, 256);
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const left = Math.min(
Math.max(edge, triggerRect.left),
window.innerWidth - panelWidth - edge,
);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.minWidth = `${triggerRect.width}px`;
this.positioned = true;
}
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open"
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
<span class="listbox-trigger-label" x-text="current"></span>
@@ -66,23 +100,48 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
<div class="listbox-panel" x-show="open" x-cloak role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel" style="position: fixed; z-index: 9999" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
</template>
</div>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
@endif
</div>
</div>
@@ -13,7 +13,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
@@ -31,14 +31,17 @@
]);
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->map(fn (array $labels) => collect($labels)
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
@endphp
@@ -14,15 +14,15 @@
</div>
<div
class="flex h-8 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="List view" title="List view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
@@ -63,7 +63,7 @@
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
@@ -77,9 +77,9 @@
</div>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
?
'control-selected' :
@@ -88,7 +88,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
?
'control-selected' :
@@ -133,12 +133,12 @@
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="project.addResourceHref" :href="project.addResourceHref"
{{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Add resource" :aria-label="`Add resource to ${project.name}`">
<x-reicon name="plus" class="size-3" />
</a>
<a x-show="project.settingsHref" :href="project.settingsHref" {{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Project settings" :aria-label="`Open settings for ${project.name}`">
<x-reicon name="settings" class="size-3" />
</a>
@@ -129,7 +129,7 @@
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
@@ -143,9 +143,9 @@
</div>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
?
'control-selected' :
@@ -154,7 +154,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
?
'control-selected' :
@@ -18,7 +18,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
@@ -33,14 +33,17 @@
]);
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->map(fn (array $labels) => collect($labels)
->map(fn (string $label) => $configurationItems->firstWhere('label', $label))
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
$storageSections = $applications
@@ -113,9 +116,9 @@
</div>
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
@@ -123,7 +126,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
@@ -85,7 +85,7 @@
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
@@ -99,9 +99,9 @@
</div>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
?
'control-selected' :
@@ -110,7 +110,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
?
'control-selected' :
@@ -152,13 +152,13 @@
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="environment.addResourceHref" :href="environment.addResourceHref"
{{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Add resource" :aria-label="`Add resource to ${environment.name}`">
<x-reicon name="plus" class="size-3" />
</a>
<a x-show="environment.settingsHref" :href="environment.settingsHref"
{{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Environment settings"
:aria-label="`Open settings for ${environment.name}`">
<x-reicon name="settings" class="size-3" />
@@ -94,9 +94,9 @@
<span x-text="filteredServers.length === 1 ? 'server' : 'servers'"></span>
</span>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
@@ -104,7 +104,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
@@ -6,9 +6,9 @@
</div>
<div class="flex items-center gap-3">
<span class="text-[11px] text-neutral-500 dark:text-fg-faint"><span x-text="filteredItems.length"></span> <span x-text="filteredItems.length === 1 ? '{{ $singular }}' : '{{ $plural }}'"></span></span>
<div class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
<button type="button" x-on:click="setViewMode('grid')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
<div class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
<button type="button" x-on:click="setViewMode('grid')" class="flex size-7.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<div x-data="{ search: '' }" class="application-settings-form">
<x-application.settings-section title="Backup schedules"
description="Schedules currently writing backup data to this storage." flush>
@if ($groupedBackups->count() === 0)
@if ($groupedBackups->count() === 0 && $volumeBackups->count() === 0)
<x-empty title="No backup schedules use this storage"
description="Select this storage from a database or volume backup schedule to see it here."
icon-name="storages" size="sm" />
@@ -18,7 +18,7 @@
<div class="overflow-x-auto">
<div
class="grid min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div>Database</div>
<div>Backup target</div>
<div>Frequency</div>
<div>Status</div>
<div>Storage</div>
@@ -93,10 +93,13 @@
<span class="text-neutral-500 dark:text-fg-dim">{{ $backup->frequency }}</span>
@endif
</div>
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
:type="$backup->enabled ? 'success' : 'warning'" />
<div class="flex items-center">
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
:type="$backup->enabled ? 'success' : 'warning'" />
</div>
<div class="flex items-end gap-2">
<x-forms.listbox id="selectedStorages.{{ $backup->id }}" :options="$storageOptions" />
<x-forms.listbox id="selectedStorages.{{ $backup->id }}" :options="$storageOptions"
portal />
<button type="button" class="button shrink-0"
wire:click="moveBackup({{ $backup->id }})">Move</button>
<button type="button" class="button shrink-0 text-error"
@@ -108,6 +111,45 @@
</div>
@endforeach
@endforeach
@foreach ($volumeBackups as $backup)
@php
$targetName = $backup->targetName();
$targetType = $backup->targetType();
$resource = $backup->targetResource();
$resourceName = $resource?->human_name ?? $resource?->name;
$storageOptions = $allStorages->map(fn ($s3) => [
'value' => $s3->id,
'label' => $s3->name.($s3->is_usable ? '' : ' (unusable)'),
'disabled' => ! $s3->is_usable,
])->values()->all();
@endphp
<div
class="grid min-h-14 min-w-[780px] grid-cols-[minmax(12rem,1fr)_9rem_7rem_minmax(15rem,1.2fr)] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]"
x-show="search === '' || '{{ strtolower(addslashes($targetName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($targetType)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($resourceName ?? '')) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<div class="min-w-0">
<div class="truncate font-medium text-black dark:text-fg">{{ $targetName }}</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">
{{ $targetType }}@if ($resourceName) · {{ $resourceName }} @endif
</div>
</div>
<div class="text-neutral-500 dark:text-fg-dim">{{ $backup->frequency }}</div>
<div class="flex items-center">
<x-status-badge :status="$backup->enabled ? 'Enabled' : 'Disabled'"
:type="$backup->enabled ? 'success' : 'warning'" />
</div>
<div class="flex items-end gap-2">
<x-forms.listbox id="selectedVolumeStorages.{{ $backup->id }}" :options="$storageOptions"
portal />
<button type="button" class="button shrink-0"
wire:click="moveVolumeBackup({{ $backup->id }})">Move</button>
<button type="button" class="button shrink-0 text-error"
wire:click="disableVolumeS3({{ $backup->id }})"
wire:confirm="Are you sure you want to disable S3 for this backup schedule?">
Disable
</button>
</div>
</div>
@endforeach
</div>
@endif
</x-application.settings-section>
+4 -4
View File
@@ -56,7 +56,7 @@
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
@@ -70,9 +70,9 @@
</div>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
?
'control-selected' :
@@ -81,7 +81,7 @@
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
?
'control-selected' :
@@ -10,7 +10,7 @@ test('highlighted buttons use the shared coollabs style in every color scheme',
expect($utilities)
->toContain('@utility button-highlighted')
->toContain('@apply border-2 text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;')
->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;')
->and($appStyles)
->toContain('button[isHighlighted]:not(:disabled)')
->toContain('@apply button-highlighted;')
@@ -177,19 +177,20 @@ it('moves application terminal and logs from the top tabs into the settings side
->not->toContain("'label' => 'Runtime'")
->and($sidebar)
->toContain("'label' => 'Terminal'")
->toContain("'label' => 'Deployment'")
->toContain("'label' => 'Runtime'")
->toContain("'Logs' => ['Deployment', 'Runtime']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
->toContain("'label' => 'Deployment Logs'")
->toContain("'label' => 'Runtime Logs'")
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
});
it('groups application automation pages separately from build and deploy', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)
->toContain("'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments']")
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
});
it('centers the rollback image loading state across the card', function () {
@@ -291,7 +292,7 @@ it('removes desktop top spacing from the deployment log viewer', function () {
it('uses a distinct runtime log icon in the sidebar', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)->toContain("'Runtime' => 'unordered-list'");
expect($sidebar)->toContain("'Runtime Logs' => 'unordered-list'");
});
it('shows deployment history above the selected deployment logs', function () {
@@ -8,17 +8,21 @@ test('view switchers use the shared coollabs selected state on every page', func
resource_path('views/livewire/project/resource/index.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'),
resource_path('views/livewire/tags/show.blade.php'),
resource_path('views/livewire/shared/list-search-controls.blade.php'),
resource_path('views/components/shared-variables/view-controls.blade.php'),
])->map(fn (string $path): string => file_get_contents($path));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($views->every(fn (string $view): bool => str_contains($view, "viewMode === 'table'")
expect($views->every(fn (string $view): bool => (str_contains($view, "viewMode === 'table'") || str_contains($view, "viewMode === 'list'"))
&& str_contains($view, "viewMode === 'grid'")
&& str_contains($view, 'control-selected')))
->toBeTrue()
->and($views->every(fn (string $view): bool => str_contains($view, 'flex h-9')
&& str_contains($view, 'size-7.5')))->toBeTrue()
->and($views->implode("\n"))->not->toContain('dark:bg-warning/15 dark:text-warning')
->and($utilities)
->toContain('@utility control-selected')
->toContain('@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;');
->toContain('@apply bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white;');
});
test('server index does not expose server IP addresses', function () {
@@ -10,11 +10,11 @@ it('moves service and database page navigation into their sidebars', function ()
->and($databaseHeading)->not->toContain('<x-resource-heading-tabs')
->and($serviceConfiguration)
->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'")
->toContain("['label' => 'Runtime Logs'")
->toContain("['label' => 'Terminal'")
->and($databaseSidebar)
->toContain("['label' => 'Backups'")
->toContain("['label' => 'Runtime'")
->toContain("['label' => 'Runtime Logs'")
->toContain("['label' => 'Terminal'");
});
@@ -41,21 +41,38 @@ it('matches application action bar behavior for services and databases', functio
->and($database)->toContain('id="database-desktop-actions"');
});
it('keeps database and service sidebar sections in the application sequence', function () {
it('groups database and service navigation by user workflow', function () {
$database = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$serviceSidebars = [
file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php')),
file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')),
];
expect($database)
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']")
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Healthcheck']")
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Metrics']")
->toContain("'Deploy' => ['Servers']")
->toContain("'Automation' => ['Webhooks', 'Backups', 'Import Backup']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone']");
expect($service)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
foreach ($serviceSidebars as $serviceSidebar) {
expect($serviceSidebar)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage']")
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
}
});
it('groups application navigation by user workflow', function () {
$application = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($application)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone']");
});
it('shows the database sidebar on backup pages', function () {
@@ -155,6 +172,6 @@ it('shows the service sidebar on runtime logs and terminal pages', function () {
->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
->and($sidebar)
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
});
@@ -2,8 +2,10 @@
use App\Livewire\Storage\Resources as StorageResources;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -75,6 +77,57 @@ beforeEach(function () {
});
describe('Storage/Resources team-scoped backup access', function () {
test('lists and manages volume backup schedules using this storage', function () {
$volume = LocalPersistentVolume::create([
'name' => 'minio-volume-data',
'mount_path' => '/data',
'resource_id' => 999,
'resource_type' => 'App\\Models\\Application',
]);
$backup = ScheduledVolumeBackup::create([
'uuid' => fake()->uuid(),
'backupable_type' => $volume->getMorphClass(),
'backupable_id' => $volume->id,
'team_id' => $this->teamA->id,
's3_storage_id' => $this->storageA->id,
'frequency' => 'daily',
'enabled' => true,
'save_s3' => true,
]);
$destination = S3Storage::unguarded(fn () => S3Storage::create([
'uuid' => fake()->uuid(),
'name' => 'volume-backup-destination',
'region' => 'us-east-1',
'key' => 'key-c',
'secret' => 'secret-c',
'bucket' => 'bucket-c',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->teamA->id,
'is_usable' => true,
]));
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->assertSee('minio-volume-data')
->assertSee('Volume')
->assertSeeHtml('class="listbox-trigger"')
->assertSeeHtml('x-teleport="body"')
->assertSeeHtml('requestAnimationFrame')
->assertSeeHtml('-panel"')
->assertSeeHtml("visibility: positioned ? 'visible' : 'hidden'")
->assertSeeHtml('positionPanel($el)')
->assertSeeHtml('class="flex items-center"')
->set("selectedVolumeStorages.{$backup->id}", $destination->id)
->call('moveVolumeBackup', $backup->id);
expect($backup->refresh()->s3_storage_id)->toBe($destination->id);
Livewire::test(StorageResources::class, ['storage' => $destination])
->call('disableVolumeS3', $backup->id);
expect($backup->refresh()->save_s3)->toBeFalse()
->and($backup->s3_storage_id)->toBeNull();
});
test('disableS3 on other team backup throws and leaves row unchanged', function () {
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->call('disableS3', $this->backupB->id))