mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 14:15:58 +00:00
feat(project): add icons and improve persistent volume management
Add project icon storage and serving with local/S3 support, prevent deletion of compose-managed volumes, and optimize volume backups. Refine project and backup UI components with consistent styling and coverage.
This commit is contained in:
1 parent
a02d02d19b
commit
fd5eb3e0cd
43 files changed
+728
-60
No files matched your search
@@ -318,6 +318,10 @@ it('queues instance database backup without redirecting when project context is
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => collect(),
|
||||
])
|
||||
->assertSee('Retention')
|
||||
->assertSee('S3 storage')
|
||||
->assertSee('Local backups')
|
||||
->assertSee('S3 backups')
|
||||
->call('backupNow')
|
||||
->assertDispatched('success', 'Backup queued. It will be available in a few minutes.')
|
||||
->assertNoRedirect()
|
||||
|
||||
@@ -63,6 +63,9 @@ it('renders frontend-only application backup search data for volume names and fr
|
||||
->assertDontSee('class="font-mono text-xs">daily', false)
|
||||
->assertDontSee('backup-type-filter-trigger', false)
|
||||
->assertDontSee('backup-sort-trigger', false);
|
||||
|
||||
expect(file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')))
|
||||
->toContain('wire:key="application-heading-backup-index-{{ $application->id }}"');
|
||||
});
|
||||
|
||||
it('renders frontend-only database backup search data for database names and frequencies', function () {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('uses the standard button height inside application forms and workspaces', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$applicationStyles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($utilities)->toContain('px-2.5 h-9 min-h-9')
|
||||
->and($applicationStyles)
|
||||
->not->toContain('.application-settings-workspace .button')
|
||||
->not->toContain('.application-settings-form .button');
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('renders a reusable compact copy button', function () {
|
||||
$html = $this->blade('<x-copy-button value="backup/path.sql" label="Copy backup path" />');
|
||||
|
||||
$html->assertSee('Copy backup path')
|
||||
->assertSee('backup\/path.sql', false)
|
||||
->assertSee('window.copyToClipboard', false)
|
||||
->assertSee('size-6', false);
|
||||
});
|
||||
|
||||
it('uses the reusable copy button for database backup paths', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($view)->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path" />');
|
||||
});
|
||||
@@ -32,3 +32,14 @@ it('returns from database backup settings to the database', function () {
|
||||
->toContain("'Back to database'")
|
||||
->toContain("except('backup_uuid')");
|
||||
});
|
||||
|
||||
it('shows the backup path directly on every execution', function () {
|
||||
$executions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($executions)
|
||||
->toContain('Backup path:')
|
||||
->toContain("data_get(\$execution, 'filename', 'N/A')")
|
||||
->toContain('<span>Backup path</span>')
|
||||
->toContain('class="select-all truncate font-mono text-[11px]')
|
||||
->not->toContain('backup-executions-table-grid border-t');
|
||||
});
|
||||
@@ -117,8 +117,9 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
->toContain('.logs-viewer-actions')
|
||||
->toContain('.logs-viewer-deployment-actions')
|
||||
->toContain('.logs-settings-section')
|
||||
->toContain('padding: 0.5rem 0.75rem 2rem;')
|
||||
->toContain('padding: 0.5rem 1rem 2rem;')
|
||||
->toContain('padding: 0.5rem 0.75rem 0;')
|
||||
->toContain('padding: 0.5rem 1rem 0;')
|
||||
->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;")
|
||||
->toContain('flex-direction: column')
|
||||
->toContain('@media (min-width: 640px)');
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ it('keeps backup execution actions compact', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($css)
|
||||
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
|
||||
->toContain('grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
|
||||
->and($view)
|
||||
->toContain('title="Download backup" aria-label="Download backup"')
|
||||
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
|
||||
@@ -71,7 +71,7 @@ it('uses the compact resource table styling for backup executions', function ()
|
||||
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
|
||||
->toContain('flex min-h-11 items-center justify-between border-t')
|
||||
->and($css)
|
||||
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
|
||||
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*66rem;/');
|
||||
});
|
||||
|
||||
it('renders compact size without the full-page min height class', function () {
|
||||
|
||||
@@ -38,8 +38,8 @@ it('preselects the first result in every resource selection step', function () {
|
||||
|
||||
expect($view)
|
||||
->toContain('preselectFirstResult()')
|
||||
->toContain("this.selectedIndex = 0;")
|
||||
->toContain("results[0].focus();")
|
||||
->toContain('this.selectedIndex = 0;')
|
||||
->toContain('results[0].focus();')
|
||||
->and(substr_count($view, 'x-init="preselectFirstResult()"'))->toBe(4);
|
||||
});
|
||||
|
||||
@@ -73,6 +73,13 @@ it('closes the client-side command palette without a Livewire request', function
|
||||
->not->toContain('closeTimer');
|
||||
});
|
||||
|
||||
it('closes the mobile sidebar when the command palette opens', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('@open-global-search.window="open = false"');
|
||||
});
|
||||
|
||||
it('keeps palette content intact during the close animation to prevent flicker', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -257,6 +258,82 @@ it('shows PR deployment suffix only for git-based applications', function () {
|
||||
->assertSet('supportsPreviewSuffix', false)
|
||||
->assertDontSee('Add suffix')
|
||||
->assertDontSee('PR deployment suffix');
|
||||
|
||||
[$nonGitComposeApp] = createApplicationWithVolume([
|
||||
'build_pack' => 'dockercompose',
|
||||
'git_repository' => '',
|
||||
'git_branch' => '',
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $nonGitComposeApp])
|
||||
->assertSet('supportsPreviewSuffix', false)
|
||||
->assertDontSee('Add suffix');
|
||||
});
|
||||
|
||||
it('allows stale compose volume metadata to be deleted', function () {
|
||||
[$application, $volume] = createApplicationWithVolume([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
->assertSee('Delete stale volume entry')
|
||||
->call('delete', $volume->id, 'password');
|
||||
|
||||
expect($volume->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('deletes the Docker volume only when explicitly selected', function () {
|
||||
Process::fake();
|
||||
DB::table('private_keys')->where('id', $this->server->private_key_id)->update([
|
||||
'private_key' => encrypt('test-key'),
|
||||
]);
|
||||
|
||||
[$application, $volume] = createApplicationWithVolume([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML,
|
||||
]);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
->assertSet('deleteDockerVolume', false)
|
||||
->call('delete', $volume->id, 'password', ['deleteDockerVolume'])
|
||||
->assertSet('deleteDockerVolume', true);
|
||||
|
||||
Process::assertRan(fn () => true);
|
||||
expect($volume->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not allow compose volume metadata that is still declared to be deleted', function () {
|
||||
[$application, $volume] = createApplicationWithVolume([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
volumes:
|
||||
- data:/data
|
||||
volumes:
|
||||
data:
|
||||
YAML,
|
||||
]);
|
||||
|
||||
$volume->name = $application->uuid.'_data';
|
||||
$volume->save();
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
->assertDontSee('Delete stale volume entry')
|
||||
->call('delete', $volume->id, 'password')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($volume->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('hides PR deployment suffix for databases', function () {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Edit;
|
||||
use App\Livewire\Project\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'avatar_storage_type' => 'local',
|
||||
]));
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
it('stores a project icon using the instance image storage setting', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$upload = UploadedFile::fake()->createWithContent('project.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg')));
|
||||
|
||||
Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid])
|
||||
->set('icon', $upload)
|
||||
->call('uploadIcon')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->project->refresh();
|
||||
|
||||
expect($this->project->icon_path)
|
||||
->toBe("project-icons/{$this->project->uuid}/icon.jpg")
|
||||
->and($this->project->icon_storage_type)->toBe('local')
|
||||
->and($this->project->icon_s3_storage_id)->toBeNull();
|
||||
|
||||
Storage::disk('local')->assertExists($this->project->icon_path);
|
||||
});
|
||||
|
||||
it('serves a project icon only to a member of its team', function () {
|
||||
Storage::fake('local');
|
||||
$this->project->forceFill([
|
||||
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
|
||||
'icon_storage_type' => 'local',
|
||||
])->save();
|
||||
Storage::disk('local')->put($this->project->icon_path, 'icon-content');
|
||||
|
||||
$this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'image/jpeg');
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
$this->actingAs($otherUser);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
$this->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('removes a project icon', function () {
|
||||
Storage::fake('local');
|
||||
$path = "project-icons/{$this->project->uuid}/icon.jpg";
|
||||
$this->project->forceFill([
|
||||
'icon_path' => $path,
|
||||
'icon_storage_type' => 'local',
|
||||
])->save();
|
||||
Storage::disk('local')->put($path, 'icon-content');
|
||||
|
||||
Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid])
|
||||
->call('removeIcon')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($this->project->refresh()->icon_path)->toBeNull();
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
});
|
||||
|
||||
it('exposes the icon URL on the projects index', function () {
|
||||
$this->project->forceFill([
|
||||
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
|
||||
'icon_storage_type' => 'local',
|
||||
])->save();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertViewHas('projectsJs', fn (array $projects): bool => $projects[0]['iconUrl'] === route('project.icon', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'v' => $this->project->updated_at->timestamp,
|
||||
]));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
it('uses the Coollabs gradient button treatment for deploy actions', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/select.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('dark:group-hover:text-warning')
|
||||
->and(substr_count($view, 'class="button button-highlighted ml-auto"'))->toBe(4);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('matches project view control borders and rounding to standard buttons', function () {
|
||||
$templates = [
|
||||
resource_path('views/livewire/project/index.blade.php'),
|
||||
resource_path('views/livewire/project/show.blade.php'),
|
||||
resource_path('views/livewire/project/resource/index.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
expect(file_get_contents($template))->toContain(
|
||||
'class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]"'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -38,6 +38,7 @@ it('renders the changelog modal above the desktop sidebar toggle', function () {
|
||||
|
||||
$component = Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
|
||||
->call('openWhatsNewModal')
|
||||
->assertNotDispatched('whats-new-opened')
|
||||
->assertSee("What's new", false)
|
||||
->assertSee('Test Release')
|
||||
->assertSee('Mark read')
|
||||
@@ -47,5 +48,42 @@ it('renders the changelog modal above the desktop sidebar toggle', function () {
|
||||
// Single-release layout: no nested card chrome / unread left accent bar
|
||||
expect($component->html())
|
||||
->not->toContain('dark:bg-raised')
|
||||
->not->toContain('w-0.5 bg-accent');
|
||||
->not->toContain('w-0.5 bg-accent')
|
||||
->toContain('absolute right-2 top-2');
|
||||
});
|
||||
|
||||
it('keeps the account menu mounted while the changelog modal opens', function () {
|
||||
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
|
||||
$accountMenuView = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
|
||||
expect($dropdownView)
|
||||
->not->toContain('wire:click="openWhatsNewModal" @click="open = false"')
|
||||
->and($accountMenuView)
|
||||
->not->toContain('@whats-new-opened.window="open = false"');
|
||||
});
|
||||
|
||||
it('opens the changelog modal when there are no entries', function () {
|
||||
$user = new User(['email' => 'test@example.com']);
|
||||
$user->id = 1;
|
||||
|
||||
Auth::setUser($user);
|
||||
|
||||
app()->instance(ChangelogService::class, new class extends ChangelogService
|
||||
{
|
||||
public function getEntriesForUser(User $user): Collection
|
||||
{
|
||||
return collect();
|
||||
}
|
||||
|
||||
public function getUnreadCountForUser(User $user): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
Livewire::test(SettingsDropdown::class, ['trigger' => 'account-menu'])
|
||||
->call('openWhatsNewModal')
|
||||
->assertSet('showWhatsNewModal', true)
|
||||
->assertSee("What's new", false)
|
||||
->assertSee('No updates found');
|
||||
});
|
||||
@@ -1599,7 +1599,7 @@ it('archives a named volume on its server', function () {
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect')
|
||||
&& str_contains($process->command, 'docker run --rm --name ')
|
||||
&& str_contains($process->command, 'app-data:/volume:ro')
|
||||
&& str_contains($process->command, 'tar -czf -')
|
||||
&& str_contains($process->command, "tar -I 'gzip -1' -cf -")
|
||||
&& str_contains($process->command, '> ')
|
||||
&& str_contains($process->command, '.tar.gz')
|
||||
&& ! str_contains($process->command, ':/backup'));
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -37,3 +37,7 @@ it('includes a Jean Server one-click service template with all deployment enviro
|
||||
->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}');
|
||||
}
|
||||
});
|
||||
|
||||
it('ships the Jean service icon from the public path used by the service picker', function () {
|
||||
expect(__DIR__.'/../../public/svgs/jean.png')->toBeFile();
|
||||
});
|
||||
Reference in new issue
Block a user