mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user