diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 64f6844757..4a709d2b96 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -242,6 +242,14 @@ class BackupEdit extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + $this->status = $database->status; + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..8c83e33556 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -17,6 +17,13 @@ class BackupNow extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index b4236b215a..96d2ac7aaf 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($database->getMorphClass() === ServiceDatabase::class) { $service = $database->service; - $this->redirectRoute('project.service.database.backup.show', [ + $this->redirectRoute('project.service.volume-backups.index', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 87f24fb1da..6d7fb97f68 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -9,16 +9,21 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; +use Livewire\WithPagination; class BackupExecutions extends Component { use AuthorizesRequests; + use WithPagination; public Service $service; + public int $perPage = 10; + public bool $executionModalOpen = false; public ?array $selectedExecution = null; @@ -40,10 +45,18 @@ class BackupExecutions extends Component $this->authorize('view', $this->service); } + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage('executionsPage'); + } + public function openExecution(string $executionUuid): void { - $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); - abort_unless($this->selectedExecution, 404); + $this->authorize('view', $this->service); + $execution = $this->executionQuery($executionUuid)->first(); + abort_unless($execution, 404); + $this->selectedExecution = $this->formatExecutions(collect([$execution]))->first(); $this->executionModalOpen = true; } @@ -55,70 +68,89 @@ class BackupExecutions extends Component public function render(): View { + $this->authorize('view', $this->service); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + if ($executions->currentPage() > $executions->lastPage()) { + $this->setPage($executions->lastPage(), 'executionsPage'); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + } + $executions->setCollection($this->formatExecutions($executions->getCollection())); + return view('livewire.project.service.backup-executions', [ - 'executions' => $this->executions(), + 'executions' => $executions, ]); } - private function executions(): Collection + private function executionQuery(?string $uuid = null): Builder { $databaseScheduleIds = ScheduledDatabaseBackup::query() ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) - ->pluck('id'); + ->select('id'); + $volumeScheduleIds = ScheduledVolumeBackup::query() + ->forService($this->service) + ->select('id'); $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with('scheduledDatabaseBackup.database') + ->select('id', 'uuid', 'created_at') + ->selectRaw("'database' as type") ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) - ->latest() - ->limit(100) - ->get() - ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ - 'id' => 'database:'.$execution->id, + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'storage' as type") + ->whereIn('scheduled_volume_backup_id', $volumeScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + + return $databaseExecutions->toBase() + ->unionAll($volumeExecutions->toBase()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->orderBy('type'); + } + + private function formatExecutions(Collection $rows): Collection + { + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->whereIn('id', $rows->where('type', 'database')->pluck('id')) + ->get()->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) + ->get()->keyBy('id'); + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $isDatabase = $row->type === 'database'; + $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); + $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; + $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; + if ($storage?->team_id !== currentTeam()->id) { + $storage = null; + } + $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : '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'; + } + + return [ + 'id' => $row->type.':'.$execution->id, 'uuid' => $execution->uuid, - 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, - 'type' => 'Database', - 'schedule' => $execution->scheduledDatabaseBackup->frequency, + '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, 'status' => $execution->status, 'started_at' => $execution->created_at, 'size' => $execution->size, 'message' => $execution->message, 'filename' => $execution->filename, 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.backup', $execution->id) + ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) : null, - ]); - - $volumeSchedules = ScheduledVolumeBackup::query() - ->with('backupable.resource') - ->forService($this->service) - ->get() - ->keyBy('id'); - $volumeExecutions = ScheduledVolumeBackupExecution::query() - ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) - ->latest() - ->limit(100) - ->get() - ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { - $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); - - return [ - 'id' => 'storage:'.$execution->id, - 'uuid' => $execution->uuid, - 'target' => $schedule->targetName(), - 'type' => $schedule->targetType(), - 'schedule' => $schedule->frequency, - 'status' => $execution->status, - 'started_at' => $execution->created_at, - 'size' => $execution->size, - 'message' => $execution->message, - 'filename' => $execution->filename, - 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.volume-backup', $execution->id) - : null, - ]; - }); - - return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + ]; + }); } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index e856d1373a..600b8b9047 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -11,6 +11,7 @@ use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component @@ -23,6 +24,9 @@ class Index extends Component public string $search = ''; + #[Url(as: 'backup_uuid', except: '')] + public string $backupUuid = ''; + public bool $scheduleModalOpen = false; public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; @@ -38,6 +42,7 @@ class Index extends Component return [ 'refreshVolumeBackups' => '$refresh', 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},ServiceChecked" => '$refresh', "echo-private:team.{$teamId},BackupCreated" => '$refresh', ]; } @@ -49,10 +54,14 @@ class Index extends Component $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + if ($this->backupUuid !== '') { + $this->openSchedule($this->backupUuid); + } } public function openSchedule(string $backupUuid): void { + $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); $this->s3s = currentTeam()->s3s; $this->scheduleModalOpen = true; @@ -60,6 +69,7 @@ class Index extends Component public function closeScheduleModal(): void { + $this->backupUuid = ''; $this->scheduleModalOpen = false; $this->selectedDatabaseBackup = null; $this->selectedVolumeBackup = null; @@ -72,6 +82,12 @@ class Index extends Component $this->loadSelectedSchedule($backupUuid); abort_unless($this->selectedDatabaseBackup, 404); $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) { + $this->selectedDatabaseBackup = null; + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); } else { abort_unless($type === 'storage', 404); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 9ea87161d8..5d0d2538bd 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -5,11 +5,29 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Component; +use Livewire\WithPagination; class Resources extends Component { use AuthorizesRequests; + use WithPagination; + + public int $perPage = 10; + + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } public ?Server $server = null; @@ -93,6 +111,10 @@ class Resources extends Component public function loadManagedContainers() { try { + if ($this->activeTab !== 'managed') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'managed'; $this->server->refresh(); } catch (\Throwable $e) { @@ -102,6 +124,10 @@ class Resources extends Component public function loadUnmanagedContainers() { + if ($this->activeTab !== 'unmanaged') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'unmanaged'; try { $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); @@ -125,6 +151,29 @@ class Resources extends Component public function render() { - return view('livewire.server.resources'); + $resources = $this->activeTab === 'managed' + ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) + : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $search = trim($this->search); + if ($search !== '') { + $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; + $resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey)) + ->contains($search, ignoreCase: true)); + } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($resources->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + + return view('livewire.server.resources', [ + 'resources' => new LengthAwarePaginator( + $resources->forPage($page, $this->perPage)->values(), + $resources->count(), + $this->perPage, + $page, + ), + ]); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 1190cd59a1..49739ac6dd 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Sentinel; +use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -29,6 +30,35 @@ class Logs extends Component $this->authorize('viewSentinel', $this->server); } + public function enableSentinel(): void + { + $this->authorize('manageSentinel', $this->server); + + try { + $this->server->refresh(); + if ($this->server->isBuildServer()) { + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } + if ($this->server->isSwarm()) { + $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); + + return; + } + if ($this->server->isSentinelEnabled()) { + return; + } + + StartSentinel::run($this->server, true); + $this->server->refresh(); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'Sentinel has been enabled.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/resources/css/app.css b/resources/css/app.css index 06f0b251af..65bce6327d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2521,7 +2521,7 @@ input[type="search"]::-webkit-search-results-decoration { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 12.5rem; width: 100%; } diff --git a/resources/views/components/shared-variables/editor.blade.php b/resources/views/components/shared-variables/editor.blade.php index 810b7f69cf..c2c85a3e4e 100644 --- a/resources/views/components/shared-variables/editor.blade.php +++ b/resources/views/components/shared-variables/editor.blade.php @@ -5,6 +5,7 @@ 'title', 'view', 'variablesLabel', + 'readOnlyKeys' => [], ]) @php @@ -105,15 +106,32 @@
Name Scope - Comment + {{ count($readOnlyKeys) ? 'Value / comment' : 'Comment' }} Multiline
@foreach ($variables as $env) - + @if (in_array($env->key, $readOnlyKeys)) +
+
+
+
{{ $env->key }}
+
Built-in · Read-only
+
+ {{ str($type)->headline() }} + {{ $env->value }} + - + +
+
+ @else + + @endif @endforeach
@@ -122,6 +140,14 @@
@endif @else + @if ($variables->whereIn('key', $readOnlyKeys)->isNotEmpty()) +
+
Built-in · Read-only
+ @foreach ($variables->whereIn('key', $readOnlyKeys)->sortBy('key') as $env) +
{{ $env->key }}={{ $env->value }}
+ @endforeach +
+ @endif
@endif - @if (str($status)->startsWith('running')) - Back up now - @endif + Back up now diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index 180ff0a169..a06792bb09 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -35,12 +35,12 @@ @endif
- - Back up now diff --git a/resources/views/livewire/project/service/backup-executions.blade.php b/resources/views/livewire/project/service/backup-executions.blade.php index b5c2057cfd..ee992b9a47 100644 --- a/resources/views/livewire/project/service/backup-executions.blade.php +++ b/resources/views/livewire/project/service/backup-executions.blade.php @@ -21,11 +21,17 @@ + @if ($executions->total() > 10) + + + + @endif @if ($executions->isEmpty()) @else -
+
+
TargetTypeScheduleStatusStartedSizeActions
@@ -42,7 +48,13 @@ wire:click="openExecution('{{ $execution['uuid'] }}')" wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0" class="data-table-row grid min-w-[820px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim"> - {{ $execution['target'] }} + + {{ $execution['target'] }} + + + {{ $execution['type'] }}{{ $execution['schedule'] }} {{ $execution['started_at']->diffForHumans() }} @@ -58,6 +70,12 @@
@endforeach + @if ($executions->hasPages()) + + @endif
@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 f69d3f0db2..9ac25ec39d 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -240,7 +240,7 @@ @if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
-
+
Target Type @@ -268,6 +268,8 @@ default => 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; + $databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null; + $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); @endphp
{{ $databaseBackup->frequency }} - + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach @@ -297,6 +304,8 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; + $volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null; + $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -324,17 +333,20 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php index 9fb2ca2fcc..78ed0eb01a 100644 --- a/resources/views/livewire/server/resources.blade.php +++ b/resources/views/livewire/server/resources.blade.php @@ -30,9 +30,27 @@ +
+
+ + + +
+
+ +
diff --git a/resources/views/livewire/server/sentinel/logs.blade.php b/resources/views/livewire/server/sentinel/logs.blade.php index 455bb55bb2..37f8367fd1 100644 --- a/resources/views/livewire/server/sentinel/logs.blade.php +++ b/resources/views/livewire/server/sentinel/logs.blade.php @@ -10,15 +10,27 @@ - - - -
- -
+ @if ($server->isSentinelEnabled()) + + + +
+ +
+ @else + + + Enable Sentinel + + + + @endif
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php index 4bba5eb064..445b6899e9 100644 --- a/resources/views/livewire/shared-variables/server/show.blade.php +++ b/resources/views/livewire/shared-variables/server/show.blade.php @@ -4,7 +4,8 @@
diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 42a430a491..03af1bea4f 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -247,6 +247,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto 'timeout' => 3600, ]); $database = $backup->database; + $database->update(['status' => 'running:healthy']); $parameters = [ 'project_uuid' => $database->project()->uuid, 'environment_uuid' => $database->environment->uuid, @@ -509,7 +510,7 @@ it('subscribes to database status broadcasts so Backup Now can refresh without a ->toHaveKey('databaseUpdated'); }); -it('shows Backup Now after refresh when the database becomes running', function () { +it('enables Back up now after refresh when the database becomes running', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -521,17 +522,21 @@ it('shows Backup Now after refresh when the database becomes running', function 'availableS3Storages' => $this->team->s3s, 'status' => 'exited:unhealthy', ]) - ->assertDontSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'exited:unhealthy'); + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); + $database->update(['status' => 'running:healthy']); $component->call('refreshStatus') ->assertSet('status', 'running:healthy') - ->assertSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->not->toMatch('/]*wire:click="backupNow"/s'); }); -it('hides Backup Now after refresh when the database stops', function () { +it('disables Back up now after refresh when the database stops', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -543,12 +548,33 @@ it('hides Backup Now after refresh when the database stops', function () { 'availableS3Storages' => $this->team->s3s, 'status' => 'running:healthy', ]) - ->assertSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'running:healthy'); $database->update(['status' => 'exited:unhealthy']); $component->call('refreshStatus') ->assertSet('status', 'exited:unhealthy') - ->assertDontSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); +}); + +it('renders S3 backup selectors outside the scrollable modal', function () { + createS3StorageForBackupEditValidationTest($this->team); + $backup = createBackupForEditValidationTest($this->team); + $html = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + 'section' => 's3', + ])->html(); + + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) { + $panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]'); + expect($panels->length)->toBe(1); + expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999'); + } }); diff --git a/tests/Feature/BackupNowAvailabilityTest.php b/tests/Feature/BackupNowAvailabilityTest.php new file mode 100644 index 0000000000..7c103e001f --- /dev/null +++ b/tests/Feature/BackupNowAvailabilityTest.php @@ -0,0 +1,132 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $resourceAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + $this->service = Service::factory()->create(['server_id' => $server->id, ...$resourceAttributes]); + $this->databases = [ + 'standalone' => StandalonePostgresql::create([ + ...$resourceAttributes, + 'name' => 'postgres', + 'postgres_password' => 'password', + ]), + 'service' => ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]), + ]; + $this->backups = collect($this->databases)->map(fn ($database) => ScheduledDatabaseBackup::create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ])); + Queue::fake(); +}); + +dataset('database backup controls', [ + 'standalone settings' => [BackupEdit::class, 'standalone'], + 'service settings' => [BackupEdit::class, 'service'], + 'standalone button' => [BackupNow::class, 'standalone'], + 'service button' => [BackupNow::class, 'service'], + 'service list' => [Index::class, 'service'], +]); + +it('only enables and queues database backups for running targets', function (string $componentClass, string $type, string $status, bool $running) { + $database = $this->databases[$type]; + $database->update(['status' => $status]); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $dom = new DOMDocument; + @$dom->loadHTML($component->html()); + $buttons = (new DOMXPath($dom))->query('//button'); + $backupButtons = []; + foreach ($buttons as $button) { + if (str_starts_with($button->getAttribute('wire:click'), 'backupNow') || str_starts_with($button->getAttribute('wire:click.stop'), 'backupNow')) { + $backupButtons[] = $button; + } + } + expect($backupButtons)->toHaveCount(1); + expect($backupButtons[0]->hasAttribute('disabled'))->toBe(! $running); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])); + if ($running) { + Queue::assertPushed(DatabaseBackupJob::class); + } else { + $component->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); + } +})->with('database backup controls')->with([ + ['running:healthy', true], + ['running:unhealthy', true], + ['exited:unhealthy', false], + ['restarting:unhealthy', false], +]); + +it('checks current database status before queuing from a stale page', function (string $componentClass, string $type) { + $database = $this->databases[$type]; + $database->update(['status' => 'running:healthy']); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $database->update(['status' => 'exited:unhealthy']); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])) + ->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); +})->with('database backup controls'); + +it('refreshes service backup buttons when the service status check completes', function () { + $database = $this->databases['service']; + $database->update(['status' => 'exited:unhealthy']); + $component = Livewire::test(Index::class, ['service' => $this->service]); + $database->update(['status' => 'running:healthy']); + + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->not->toMatch('/]*wire:click.stop="backupNow/s'); + + $database->update(['status' => 'exited:unhealthy']); + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->toMatch('/]*wire:click.stop="backupNow/s'); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 1b18c31b41..a96e776a05 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -82,11 +82,10 @@ it('creates a service database backup without S3 and opens its configuration', f $backup = ScheduledDatabaseBackup::query()->sole(); - $component->assertRedirectToRoute('project.service.database.backup.show', [ + $component->assertRedirectToRoute('project.service.volume-backups.index', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $backup->uuid, ]); @@ -114,14 +113,22 @@ it('selects a service database when creating a backup from the unified backups p 'custom_type' => 'postgresql', ]); - Livewire::test(CreateScheduledBackup::class, ['service' => $service]) + $component = 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(); + $backup = ScheduledDatabaseBackup::query()->sole(); + expect($backup->database->is($analytics))->toBeTrue(); + + $component->assertRedirectToRoute('project.service.volume-backups.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $service->uuid, + 'backup_uuid' => $backup->uuid, + ]); }); it('creates a clickhouse backup for its configured database', function () { diff --git a/tests/Feature/Livewire/SentinelLogsTest.php b/tests/Feature/Livewire/SentinelLogsTest.php new file mode 100644 index 0000000000..99d2fa1051 --- /dev/null +++ b/tests/Feature/Livewire/SentinelLogsTest.php @@ -0,0 +1,127 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + $this->actingAs($user); + $this->server = Server::factory()->create(['team_id' => $team->id]); +}); + +it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSee('Sentinel is disabled') + ->assertSeeHtml('wire:click="enableSentinel"') + ->assertDontSee('Out of sync') + ->assertDontSee('In sync') + ->assertDontSeeLivewire(GetLogs::class); +})->with([false, true]); + +it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update([ + 'is_sentinel_enabled' => ! $metricsOnly, + 'is_metrics_enabled' => $metricsOnly, + 'is_build_server' => false, + ]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertDontSee('Sentinel is disabled') + ->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync') + ->assertSeeLivewire(GetLogs::class); +})->with([false, true])->with([false, true]); + +it('enables sentinel from the logs page', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool { + expect($server->id)->toBe($this->server->id); + expect($restart)->toBeTrue(); + $server->settings->update(['is_sentinel_enabled' => true]); + + return true; + }); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertDontSee('Sentinel is disabled') + ->assertDontSee('Enable Sentinel') + ->assertSeeLivewire(GetLogs::class) + ->assertDispatched('refreshServerShow') + ->assertDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeTrue(); +}); + +it('keeps sentinel disabled when startup fails', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed')); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDontSeeLivewire(GetLogs::class) + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +}); + +it('does not enable sentinel on unsupported servers', function (string $setting) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDispatched('error'); +})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']); + +it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + $user = User::factory()->create(); + if (! $crossTeam) { + $this->server->team->members()->attach($user->id, ['role' => 'member']); + } + $this->actingAs($user); + StartSentinel::shouldRun()->never(); + $component = new Logs; + $component->server = $this->server->fresh(); + + expect(fn () => $component->enableSentinel()) + ->toThrow(AuthorizationException::class); + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +})->with([false, true]); + +it('does not restart sentinel when it is already enabled', function () { + $this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSeeLivewire(GetLogs::class) + ->call('enableSentinel') + ->assertNotDispatched('success'); +}); diff --git a/tests/Feature/ServerResourcesPaginationTest.php b/tests/Feature/ServerResourcesPaginationTest.php new file mode 100644 index 0000000000..0f9efaae40 --- /dev/null +++ b/tests/Feature/ServerResourcesPaginationTest.php @@ -0,0 +1,141 @@ +component = new Resources; + $this->component->server = Mockery::mock(Server::class)->makePartial(); +}); + +it('paginates sorted resources on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(23, 1))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(23) + ->and($page->count())->toBe(10) + ->and($page->pluck($nameKey)->all())->toBe(array_map(fn ($id) => "Resource $id", range(1, 10))); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(2)->and($page->count())->toBe(10) + ->and($page->first()[$nameKey])->toBe('Resource 11'); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->count())->toBe(3)->and($page->first()[$nameKey])->toBe('Resource 21'); + + $this->component->previousPage(); + expect($this->component->render()->getData()['resources']->currentPage())->toBe(2); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('resets pagination and search when switching tabs but preserves them on refresh', function () { + $this->component->server->shouldReceive('refresh')->andReturnSelf(); + $this->component->server->shouldReceive('loadUnmanagedContainers')->andReturn(collect()); + $this->component->setPage(3); + $this->component->search = 'managed resource'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'container'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('container'); + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'application'; + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('application'); +}); + +it('clamps page size and resets the page', function (int $size, int $expected) { + $this->component->setPage(3); + $this->component->perPage = $size; + $this->component->updatedPerPage(); + expect($this->component->perPage)->toBe($expected) + ->and($this->component->getPage())->toBe(1); +})->with([[25, 25], [0, 1], [200, 100]]); + +it('clamps stale pages after resources disappear including an empty list', function (int $count, int $expectedPage) { + $this->component->activeTab = 'unmanaged'; + $this->component->unmanagedContainers = array_fill(0, $count, ['Names' => 'Container']); + $this->component->setPage(9); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe($expectedPage) + ->and($this->component->getPage())->toBe($expectedPage) + ->and($page->total())->toBe($count); +})->with([[12, 2], [0, 1]]); + +it('renders shared pagination for both resource tabs with stable row identities', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + expect($view)->toContain('toContain('toContain('previous-action="previousPage"') + ->toContain('next-action="nextPage"') + ->toContain('wire:key="managed-') + ->toContain('wire:key="unmanaged-') + ->not->toContain('$server->definedResources()'); +}); + +it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $this->component->setPage(3); + $this->component->search = ' RESOURCE 2 '; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(1) + ->and($page->total())->toBe(7) + ->and($page->pluck($nameKey)->all())->toBe([ + 'Resource 2', 'Resource 20', 'Resource 21', 'Resource 22', + 'Resource 23', 'Resource 24', 'Resource 25', + ]); + + $this->component->search = 'missing'; + $this->component->updatedSearch(); + expect($this->component->render()->getData()['resources']->total())->toBe(0); + + $this->component->search = ''; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(25)->and($page->currentPage())->toBe(1); + + $this->component->search = ' '; + expect($this->component->render()->getData()['resources']->total())->toBe(25); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('provides accessible live search and a distinct no-results message', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view)->toContain('wire:model.live.debounce.300ms="search"') + ->toContain('aria-label="Search resources by name"') + ->toContain('aria-label="Clear search"') + ->toContain('No matching resources') + ->toContain('No matching containers'); +}); + +it('shows search feedback and prevents interaction with stale results while searching', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view) + ->toContain('') + ->not->toContain('wire:loading.inline-flex wire:target="search"') + ->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"') + ->toContain('wire:loading.attr="inert" wire:target="search"'); +}); diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php index 9d5ac5a8a8..ede6fb6fc0 100644 --- a/tests/Feature/ServiceResourceRoutingTest.php +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -3,14 +3,17 @@ use App\Jobs\DatabaseBackupJob; use App\Jobs\VolumeBackupJob; use App\Livewire\Project\Database\Import as DatabaseImport; +use App\Livewire\Project\Service\BackupExecutions; use App\Livewire\Project\Service\Heading; use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\Project; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -20,6 +23,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; @@ -234,6 +238,7 @@ test('service database backup schedules open in the Livewire component', functio test('service database backups can be queued from the Livewire component', function () { Queue::fake(); + $this->ownServiceDatabase->update(['status' => 'running:healthy']); $backup = ScheduledDatabaseBackup::create([ 'team_id' => $this->teamA->id, 'frequency' => 'daily', @@ -448,5 +453,294 @@ test('service storage backups page includes schedules from all compose databases ->assertSee('own-db') ->assertSee('analytics-db') ->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false) - ->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false); + ->assertSee("wire:click.stop=\"backupNow('database', '{$backups->first()->uuid}')\"", false); +}); + +test('service backup settings open automatically from the creation redirect', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('selectedDatabaseBackup.uuid', $backup->uuid) + ->assertSee('S3'); +}); + +test('service backup settings reject a backup belonging to another team', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + + $this->expectException(ModelNotFoundException::class); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]); +}); + +test('service backups have explicit settings actions for database and storage schedules', function () { + $databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + ]); + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + $buttons = $xpath->query('//button[contains(., "Settings")]'); + $actions = []; + foreach ($buttons as $button) { + $actions[] = $button->getAttribute('wire:click.stop'); + } + + expect($actions)->toContain("openSchedule('{$databaseBackup->uuid}')", "openSchedule('{$volumeBackup->uuid}')"); + + foreach (['database' => $databaseBackup, 'storage' => $volumeBackup] as $type => $backup) { + $backupAction = "wire:click.stop=\"backupNow('{$type}', '{$backup->uuid}')\""; + $settingsAction = "wire:click.stop=\"openSchedule('{$backup->uuid}')\""; + expect(strpos($html, $backupAction))->toBeLessThan(strpos($html, $settingsAction)); + } + +}); + +test('members cannot open service backup settings', function () { + $this->userA->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertForbidden(); +}); + +test('closing service backup settings clears the backup query parameter', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + $component = Livewire::withQueryParams(['backup_uuid' => $backup->uuid, 'search' => 'own-db']) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('backupUuid', $backup->uuid); + + expect($component->effects['url']['backupUuid']) + ->toMatchArray(['as' => 'backup_uuid', 'use' => 'replace', 'except' => '']); + + $component->dispatch('modalClosed') + ->assertSet('scheduleModalOpen', false) + ->assertSet('selectedDatabaseBackup', null) + ->assertSet('selectedVolumeBackup', null) + ->assertSet('backupUuid', '') + ->assertSet('search', 'own-db') + ->assertNoRedirect(); +}); + +test('service execution history paginates both backup types without truncating older runs', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $databaseExecutions = collect(range(1, 105))->map(fn ($index) => ScheduledDatabaseBackupExecution::forceCreate([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + 'created_at' => now()->subMinutes($index), + ])); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamA->id, 'frequency' => 'daily']); + $volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + + $component = Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', function ($executions) use ($volumeExecution, $databaseExecutions) { + expect($executions)->toBeInstanceOf(LengthAwarePaginator::class) + ->and($executions->total())->toBe(106) + ->and($executions->count())->toBe(10) + ->and($executions->first()['uuid'])->toBe($volumeExecution->uuid) + ->and($executions->last()['uuid'])->toBe($databaseExecutions[8]->uuid); + + return true; + }) + ->assertSeeHtml('aria-label="Next page"'); + + $component->call('openExecution', $volumeExecution->uuid) + ->assertSet('selectedExecution.uuid', $volumeExecution->uuid) + ->call('closeExecutionModal'); + $component->call('nextPage', 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 2 && $executions->first()['uuid'] === $databaseExecutions[9]->uuid); + $component->call('setPage', 11, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->count() === 6 && $executions->last()['uuid'] === $databaseExecutions->last()->uuid) + ->call('openExecution', $databaseExecutions->last()->uuid) + ->assertSet('executionModalOpen', true) + ->assertSet('selectedExecution.uuid', $databaseExecutions->last()->uuid); + $component->set('perPage', 25) + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 1 && $executions->count() === 25); + $component->call('setPage', 999, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 5 && $executions->count() === 6); + $component->set('perPage', 1000)->assertSet('perPage', 100); + $component->set('perPage', 0)->assertSet('perPage', 1); +}); + +test('service execution pagination excludes other teams and denies opening their runs', function (string $type) { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + ]); + + if ($type === 'storage') { + $volume = LocalPersistentVolume::create([ + 'name' => 'other-service-data', + 'mount_path' => '/data', + 'resource_id' => $this->otherServiceDatabase->id, + 'resource_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamB->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', fn ($executions) => $executions->isEmpty()) + ->assertDontSeeHtml('aria-label="Next page"') + ->call('openExecution', $execution->uuid) + ->assertNotFound(); +})->with(['database', 'storage']); + +test('execution page size remains adjustable when all runs fit on one page', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + foreach (range(1, 11) as $index) { + $schedule->executions()->create(['status' => 'success']); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->set('perPage', 25) + ->assertSeeHtml('aria-label="Items per page"') + ->assertDontSeeHtml('aria-label="Next page"') + ->set('perPage', 10) + ->assertSeeHtml('aria-label="Next page"'); +}); + +test('service backup lists identify the configured S3 storage without extra columns', function () { + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data-'.str($name)->slug(), + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $storage = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => $name, 'bucket' => 'backups']); + ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + } + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + expect(substr_count($html, 'data-tooltip="S3 storage: '.$name.' (bucket: backups)"'))->toBe(2); + } +}); + +test('execution tooltips distinguish current database storage from the recorded storage destination', 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([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id]); + + 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)"'); + + $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"'); }); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php index 34767cf06d..9b434ef994 100644 --- a/tests/Feature/SharedVariableDevViewTest.php +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -168,3 +168,38 @@ test('server shared variable dev view updates existing variable', function () { expect($var->value)->toBe('new_value') ->and($var->comment)->toBe('updated comment'); }); + +test('server shared variables display built-ins as read-only rows', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSee('Built-in · Read-only') + ->assertDontSee('Add a variable to make it available to resources in this scope.') + ->assertDontSee('data-env-settings-trigger', false) + ->assertSet('variables', '') + ->call('switch') + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSet('variables', '') + ->set('variables', "COOLIFY_SERVER_UUID=changed\nCOOLIFY_SERVER_NAME=changed\nCUSTOM=value") + ->call('submit'); + + expect($server->environment_variables()->pluck('value', 'key')->all()) + ->toMatchArray(['COOLIFY_SERVER_UUID' => $server->uuid, 'COOLIFY_SERVER_NAME' => $server->name, 'CUSTOM' => 'value']); +}); + +test('server built-ins are visible to team members but not other teams', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertDontSee('Add variable'); + + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $otherServer->uuid]) + ->assertRedirect(route('dashboard')) + ->assertDontSee($otherServer->uuid); +});