feat: add backup controls, resource search, and Sentinel enablement

Unify service backup history with pagination, schedule settings actions, S3 destination details, and team-safe access checks. Display server built-ins as read-only variables and add loading and empty states for searchable resources.
This commit is contained in:
Andras Bacsai
2026-09-07 16:46:37 +02:00
parent 183e28682c
commit b123356acd
24 changed files with 1110 additions and 116 deletions
@@ -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.');
@@ -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) {
@@ -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 {
@@ -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();
];
});
}
}
@@ -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);
+50 -1
View File
@@ -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,
),
]);
}
}
+30
View File
@@ -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');
+1 -1
View File
@@ -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%;
}
@@ -5,6 +5,7 @@
'title',
'view',
'variablesLabel',
'readOnlyKeys' => [],
])
@php
@@ -105,15 +106,32 @@
<div class="data-table-header env-table-grid-shared order-[-1]">
<span>Name</span>
<span>Scope</span>
<span>Comment</span>
<span>{{ count($readOnlyKeys) ? 'Value / comment' : 'Comment' }}</span>
<span class="text-center">Multiline</span>
<span></span>
</div>
@foreach ($variables as $env)
<livewire:project.shared.environment-variable.show
wire:key="shared-variable-{{ $type }}-{{ $env->id }}" :env="$env"
:type="$type" :tableAlphabeticalOrder="$alphabeticalPositions[$env->id]"
:tableCreationOrder="$loop->index" />
@if (in_array($env->key, $readOnlyKeys))
<div wire:key="shared-variable-{{ $type }}-{{ $env->id }}" class="env-table-item"
:style="`order: ${sharedSort === 'alphabetical' ? {{ $alphabeticalPositions[$env->id] }} : {{ $loop->index }}}`"
x-show="@js(mb_strtolower($env->key . ' ' . ($env->comment ?? '') . ' ' . $type)).includes(sharedSearch.trim().toLowerCase())">
<div class="data-table-row env-table-grid-shared">
<div class="min-w-0">
<div class="env-key-label truncate font-mono text-[13px]" title="{{ $env->key }}">{{ $env->key }}</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">Built-in · Read-only</div>
</div>
<span class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ str($type)->headline() }}</span>
<span class="min-w-0 truncate font-mono text-[13px]" title="{{ $env->value }}">{{ $env->value }}</span>
<span class="data-table-cell-dash">-</span>
<span class="justify-self-end text-neutral-400 dark:text-fg-faint" title="Built-in variable, managed by Coolify"><x-reicon name="lock" class="size-3.5" /></span>
</div>
</div>
@else
<livewire:project.shared.environment-variable.show
wire:key="shared-variable-{{ $type }}-{{ $env->id }}" :env="$env"
:type="$type" :tableAlphabeticalOrder="$alphabeticalPositions[$env->id]"
:tableCreationOrder="$loop->index" />
@endif
@endforeach
<div
class="order-[9999] flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
@@ -122,6 +140,14 @@
</div>
@endif
@else
@if ($variables->whereIn('key', $readOnlyKeys)->isNotEmpty())
<div class="border-b border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="mb-2 text-[12px] text-neutral-500 dark:text-fg-dim">Built-in · Read-only</div>
@foreach ($variables->whereIn('key', $readOnlyKeys)->sortBy('key') as $env)
<div class="break-all font-mono text-[13px]">{{ $env->key }}={{ $env->value }}</div>
@endforeach
</div>
@endif
<form wire:submit="submit" class="p-4">
<x-unsaved-bar action="submit" />
<x-forms.textarea canGate="update" :canResource="$resource" rows="20"
@@ -19,9 +19,9 @@
Disable backup
</x-forms.button>
@endif
@if (str($status)->startsWith('running'))
<x-forms.button type="button" wire:click="backupNow">Back up now</x-forms.button>
@endif
<x-forms.button type="button" wire:click="backupNow"
:disabled="! str($status)->startsWith('running')"
:tooltip="! str($status)->startsWith('running') ? 'The database must be running to start a backup.' : null">Back up now</x-forms.button>
</div>
</div>
@@ -35,12 +35,12 @@
@endif
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageId" label="S3 storage" :required="$saveS3"
<x-forms.listbox id="s3StorageId" label="S3 storage" portal :required="$saveS3"
:options="$availableS3Storages->map(fn ($s3) => [
'value' => $s3->id,
'label' => $s3->name,
])->values()->all()" />
<x-forms.listbox id="disableLocalBackup" label="Local copy" :disabled="! $saveS3"
<x-forms.listbox id="disableLocalBackup" label="Local copy" portal :disabled="! $saveS3"
:options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
@@ -1 +1,3 @@
<x-forms.button wire:click='backupNow'>Backup Now</x-forms.button>
<x-forms.button wire:click="backupNow"
:disabled="! str($backup->database->status)->startsWith('running')"
:tooltip="! str($backup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null">Back up now</x-forms.button>
@@ -21,11 +21,17 @@
<x-application.settings-section title="Executions"
helper="Review backup runs across every database and storage target in this service." flush>
@if ($executions->total() > 10)
<x-slot:actions>
<x-page-size-select model="perPage" livewire />
</x-slot:actions>
@endif
@if ($executions->isEmpty())
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after a backup schedule runs." icon-name="browser-terminal" />
@else
<div class="data-table w-full overflow-x-auto">
<div class="data-table relative w-full overflow-x-auto">
<x-table.loading target="previousPage,nextPage,setPage,perPage" text="Loading executions..." />
<div class="data-table-header grid min-w-[820px] grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px]">
<span>Target</span><span>Type</span><span>Schedule</span><span>Status</span><span>Started</span><span>Size</span><span class="text-right">Actions</span>
</div>
@@ -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">
<span class="truncate font-medium text-neutral-950 dark:text-fg" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
<span class="flex min-w-0 items-center gap-2 font-medium text-neutral-950 dark:text-fg">
<span class="truncate" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
<span tabindex="0" data-tooltip="{{ $execution['s3_tooltip'] }}"
aria-label="{{ $execution['s3_tooltip'] }}" class="shrink-0 text-neutral-500 dark:text-fg-dim">
<x-reicon name="cloud" class="size-3.5" aria-hidden="true" />
</span>
</span>
<span>{{ $execution['type'] }}</span><span>{{ $execution['schedule'] }}</span>
<span><x-status-badge :status="str($execution['status'])->headline()" :type="$statusType" /></span>
<span>{{ $execution['started_at']->diffForHumans() }}</span>
@@ -58,6 +70,12 @@
</span>
</div>
@endforeach
@if ($executions->hasPages())
<x-table-pagination :from="$executions->firstItem()" :to="$executions->lastItem()"
:total="$executions->total()" :current-page="$executions->currentPage()" :last-page="$executions->lastPage()"
wire-target="previousPage,nextPage,setPage,perPage"
previous-action="previousPage('executionsPage')" next-action="nextPage('executionsPage')" />
@endif
</div>
@endif
</x-application.settings-section>
@@ -240,7 +240,7 @@
@if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="min-w-[59rem]">
<div class="min-w-[64rem]">
<div class="data-table-header backup-table-grid service-backup-table-grid">
<span>Target</span>
<span>Type</span>
@@ -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
<div wire:key="database-backup-{{ $databaseBackup->uuid }}"
x-show="isVisible(@js($databaseBackupId))"
@@ -282,14 +284,19 @@
<span>{{ $databaseBackup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
<x-status-badge :status="$databaseBackup->save_s3 ? ($databaseBackup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$databaseBackup->save_s3 ? ($databaseBackup->s3 ? 'success' : 'error') : 'neutral'" />
<x-status-badge :status="$databaseBackup->save_s3 ? ($databaseS3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$databaseBackup->save_s3 ? ($databaseS3 ? 'success' : 'error') : 'neutral'"
:data-tooltip="$databaseS3Tooltip" :aria-label="$databaseS3Tooltip" tabindex="0" />
</span>
<span>{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}</span>
<span class="flex justify-end">
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
:disabled="! str($databaseBackup->database->status)->startsWith('running')"
:tooltip="! str($databaseBackup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null"
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">Settings</x-forms.button>
</span>
</div>
@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 @@
<span>{{ $backup->targetType() }}</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
<span>
<x-status-badge :status="$backup->save_s3 ? ($volumeS3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$backup->save_s3 ? ($volumeS3 ? 'success' : 'error') : 'neutral'"
:data-tooltip="$volumeS3Tooltip" :aria-label="$volumeS3Tooltip" tabindex="0" />
</span>
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
<span class="flex justify-end">
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">Settings</x-forms.button>
</span>
</div>
@endforeach
@@ -30,9 +30,27 @@
</x-forms.button>
</x-slot:actions>
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative w-full max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input wire:model.live.debounce.300ms="search" type="search" placeholder="Search resources by name"
aria-label="Search resources by name"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
<button type="button" wire:click="$set('search', '')" @class([
'absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg',
'hidden' => blank($search),
]) aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
</div>
<div class="relative">
<div class="transition-all" wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:loading.attr="inert" wire:target="search">
@if ($activeTab === 'managed')
@php($managedResources = $server->definedResources()->sortBy('name', SORT_NATURAL))
@if ($managedResources->count() > 0)
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table-header server-resources-managed-table-grid">
<span>Name</span>
@@ -41,9 +59,9 @@
<span>Type</span>
<span>Status</span>
</div>
@foreach ($managedResources as $resource)
@foreach ($resources as $resource)
@php($resourceStatus = (string) data_get($resource, 'status', 'unknown'))
<div
<div wire:key="managed-{{ $resource->type() }}-{{ $resource->uuid }}"
class="data-table-row server-resources-managed-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<div class="min-w-0">
<a class="block max-w-full truncate text-[12px] font-medium text-neutral-950 hover:underline dark:text-fg"
@@ -72,22 +90,16 @@
</div>
</div>
@endforeach
<div
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
{{ $managedResources->count() }}
{{ Str::plural('managed resource', $managedResources->count()) }}
</div>
</div>
@else
<div class="p-6">
<x-empty size="sm" title="No managed resources"
description="Resources assigned to this server will appear here."
<x-empty size="sm" :title="trim($search) !== '' ? 'No matching resources' : 'No managed resources'"
:description="trim($search) !== '' ? 'Try another name or clear the search.' : 'Resources assigned to this server will appear here.'"
icon-name="projects" />
</div>
@endif
@else
@if (count($unmanagedContainers) > 0)
@php($sortedUnmanagedContainers = collect($unmanagedContainers)->sortBy('name', SORT_NATURAL))
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table-header server-resources-unmanaged-table-grid">
<span>Name</span>
@@ -95,9 +107,9 @@
<span>Status</span>
<span>Actions</span>
</div>
@foreach ($sortedUnmanagedContainers as $resource)
@foreach ($resources as $resource)
@php($containerState = (string) data_get($resource, 'State', 'unknown'))
<div
<div wire:key="unmanaged-{{ data_get($resource, 'ID') }}"
class="data-table-row server-resources-unmanaged-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<div class="min-w-0 truncate text-[12px] font-medium text-neutral-950 dark:text-fg">
{{ data_get($resource, 'Names') }}
@@ -139,20 +151,28 @@
</div>
</div>
@endforeach
<div
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
{{ $sortedUnmanagedContainers->count() }}
{{ Str::plural('unmanaged container', $sortedUnmanagedContainers->count()) }}
</div>
</div>
@else
<div class="p-6">
<x-empty size="sm" title="No unmanaged containers"
description="All detected Docker containers are managed by Coolify."
<x-empty size="sm" :title="trim($search) !== '' ? 'No matching containers' : 'No unmanaged containers'"
:description="trim($search) !== '' ? 'Try another name or clear the search.' : 'All detected Docker containers are managed by Coolify.'"
icon-name="servers" />
</div>
@endif
@endif
@if ($resources->total() > 0)
<x-table-pagination :from="$resources->firstItem()" :to="$resources->lastItem()"
:total="$resources->total()" :current-page="$resources->currentPage()"
:last-page="$resources->lastPage()" wire-target="previousPage,nextPage,perPage"
previous-action="previousPage" next-action="nextPage">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.server-resources" />
</x-slot:pageSize>
</x-table-pagination>
@endif
</div>
<x-table.loading target="search" text="Searching resources..." />
</div>
</x-application.settings-section>
</div>
</div>
@@ -10,15 +10,27 @@
<x-application.settings-section title="Sentinel logs"
helper="Search, filter, follow, copy, or download recent output from the Sentinel container."
flush class="logs-settings-section">
<x-slot:actions>
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'warning'"
class="logs-section-status-badge" />
</x-slot:actions>
<div class="settings-log-panel">
<livewire:project.shared.get-logs :server="$server" container="coolify-sentinel"
displayName="Sentinel" :collapsible="false" />
</div>
@if ($server->isSentinelEnabled())
<x-slot:actions>
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'warning'"
class="logs-section-status-badge" />
</x-slot:actions>
<div class="settings-log-panel">
<livewire:project.shared.get-logs :server="$server" container="coolify-sentinel"
displayName="Sentinel" :collapsible="false" />
</div>
@else
<x-slot:actions>
<x-forms.button canGate="manageSentinel" :canResource="$server" isHighlighted
wire:click="enableSentinel">
Enable Sentinel
</x-forms.button>
</x-slot:actions>
<x-empty size="sm" title="Sentinel is disabled"
description="Enable Sentinel to view its logs."
icon-name="dashboard" />
@endif
</x-application.settings-section>
</div>
</div>
@@ -4,7 +4,8 @@
</x-slot>
<x-shared-variables.editor :resource="$server"
:variables="$server->environment_variables->whereNotIn('key', ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])"
:variables="$server->environment_variables"
:readOnlyKeys="['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME']"
type="server" title="{{ $server->name }}"
:view="$view" variablesLabel="Server shared variables" />
</div>
+32 -6
View File
@@ -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('/<button\s+disabled[^>]*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('/<button\s+disabled[^>]*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('/<button\s+disabled[^>]*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');
}
});
+132
View File
@@ -0,0 +1,132 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Livewire\Project\Database\BackupEdit;
use App\Livewire\Project\Database\BackupNow;
use App\Livewire\Project\Service\VolumeBackup\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
$database->update(['status' => 'exited:unhealthy']);
$component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked');
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click.stop="backupNow/s');
});
@@ -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 () {
+127
View File
@@ -0,0 +1,127 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Livewire\Project\Shared\GetLogs;
use App\Livewire\Server\Sentinel\Logs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 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');
});
@@ -0,0 +1,141 @@
<?php
use App\Livewire\Server\Resources;
use App\Models\Server;
beforeEach(function () {
$this->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('<x-table-pagination')
->toContain('<x-page-size-select')
->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('<x-table.loading target="search" text="Searching resources..." />')
->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"');
});
+295 -1
View File
@@ -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"');
});
@@ -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);
});