mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-28 02:06:37 -04: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:
@@ -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