From fd5eb3e0cd704ed1b3df0e2b201817509cb8bc4c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:56:52 +0200 Subject: [PATCH] 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. --- .../Controllers/ProjectIconController.php | 20 ++++ app/Jobs/VolumeBackupJob.php | 2 +- app/Livewire/Project/Edit.php | 37 +++++++ app/Livewire/Project/Index.php | 4 + app/Livewire/Project/Shared/Storages/All.php | 33 +++++- app/Livewire/Project/Shared/Storages/Show.php | 1 + app/Models/LocalPersistentVolume.php | 44 ++++++++ app/Services/AvatarStorageService.php | 4 +- app/Services/ProjectIconStorageService.php | 69 ++++++++++++ ...035_add_icon_columns_to_projects_table.php | 31 ++++++ {svgs => public/svgs}/jean.png | Bin resources/css/app.css | 41 ++++--- .../views/components/copy-button.blade.php | 22 ++++ resources/views/layouts/app.blade.php | 2 +- .../application/backup/index.blade.php | 3 +- .../project/database/backup-edit.blade.php | 6 +- .../database/backup-executions.blade.php | 6 ++ .../views/livewire/project/edit.blade.php | 80 ++++++++++++++ .../views/livewire/project/index.blade.php | 16 ++- .../livewire/project/new/select.blade.php | 12 +-- .../livewire/project/resource/index.blade.php | 2 +- .../project/service/configuration.blade.php | 2 +- .../project/shared/storages/all.blade.php | 15 +++ .../views/livewire/project/show.blade.php | 2 +- .../livewire/settings-dropdown.blade.php | 14 +-- .../livewire/settings/advanced.blade.php | 4 +- routes/web.php | 2 + tests/Feature/BackupEditValidationTest.php | 4 + tests/Feature/BackupSearchTest.php | 3 + tests/Feature/ButtonHeightConsistencyTest.php | 11 ++ tests/Feature/CopyButtonComponentTest.php | 16 +++ tests/Feature/DatabaseBackupsLayoutTest.php | 11 ++ tests/Feature/DeploymentLogsLayoutTest.php | 5 +- tests/Feature/EmptyStateComponentTest.php | 4 +- .../Feature/GlobalSearchLoadingStateTest.php | 11 +- .../PersistentStorageVolumesLayoutTest.php | 77 +++++++++++++ tests/Feature/ProjectIconTest.php | 101 ++++++++++++++++++ tests/Feature/ProjectNewSelectAccentTest.php | 9 ++ .../Feature/ProjectViewControlsStyleTest.php | 16 +++ tests/Feature/SettingsDropdownTest.php | 40 ++++++- tests/Feature/VolumeBackupTest.php | 2 +- tests/Fixtures/project-icon.jpg | Bin 0 -> 23201 bytes tests/Unit/JeanServiceTemplateTest.php | 4 + 43 files changed, 728 insertions(+), 60 deletions(-) create mode 100644 app/Http/Controllers/ProjectIconController.php create mode 100644 app/Services/ProjectIconStorageService.php create mode 100644 database/migrations/2026_08_13_140035_add_icon_columns_to_projects_table.php rename {svgs => public/svgs}/jean.png (100%) create mode 100644 resources/views/components/copy-button.blade.php create mode 100644 tests/Feature/ButtonHeightConsistencyTest.php create mode 100644 tests/Feature/CopyButtonComponentTest.php create mode 100644 tests/Feature/ProjectIconTest.php create mode 100644 tests/Feature/ProjectNewSelectAccentTest.php create mode 100644 tests/Feature/ProjectViewControlsStyleTest.php create mode 100644 tests/Fixtures/project-icon.jpg diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php new file mode 100644 index 000000000..fb7ebc886 --- /dev/null +++ b/app/Http/Controllers/ProjectIconController.php @@ -0,0 +1,20 @@ +where('uuid', $project_uuid)->firstOrFail(); + $contents = $iconStorage->projectContents($project); + + abort_if($contents === null, 404); + + return response($contents)->header('Content-Type', 'image/jpeg'); + } +} diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index b2f35d0c8..0b85fd255 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -84,7 +84,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) .' -v '.escapeshellarg($source.':/volume:ro') .' '.escapeshellarg($image) - .' tar -czf - -C /volume . > '.escapeshellarg($backupLocation); + ." tar -I 'gzip -1' -cf - -C /volume . > ".escapeshellarg($backupLocation); if ($this->backup->stop_during_backup) { $containers = $this->containersUsingVolume($source, $server); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 1314c9e4b..91b0444f5 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -3,13 +3,16 @@ namespace App\Livewire\Project; use App\Models\Project; +use App\Services\ProjectIconStorageService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Livewire\WithFileUploads; class Edit extends Component { use AuthorizesRequests; + use WithFileUploads; public Project $project; @@ -17,6 +20,40 @@ class Edit extends Component public ?string $description = null; + public $icon; + + public function uploadIcon(ProjectIconStorageService $iconStorage): bool + { + try { + $this->authorize('update', $this->project); + $this->validate([ + 'icon' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'], + ]); + $iconStorage->storeProject($this->project, $this->icon); + $this->reset('icon'); + $this->project->refresh(); + $this->dispatch('success', 'Project icon updated.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + + public function removeIcon(ProjectIconStorageService $iconStorage): void + { + try { + $this->authorize('update', $this->project); + $iconStorage->deleteProject($this->project); + $this->project->refresh(); + $this->dispatch('success', 'Project icon removed.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 8a67041f0..2b472a1a2 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -53,6 +53,10 @@ class Index extends Component 'uuid' => $project->uuid, 'name' => $project->name, 'description' => $project->description, + 'iconUrl' => $project->icon_path ? route('project.icon', [ + 'project_uuid' => $project->uuid, + 'v' => $project->updated_at->timestamp, + ]) : null, 'href' => $project->navigateTo(), 'environmentCount' => $project->environments->count(), 'resourceCount' => $resourceCount, diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 15a154e67..583c2788a 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -21,7 +21,7 @@ class All extends Component /** * Editable form state keyed by storage id. * - * @var array + * @var array */ public array $forms = []; @@ -42,13 +42,16 @@ class All extends Component public bool $canUpdate = false; + public bool $deleteDockerVolume = false; + protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; public function mount(): void { $this->canUpdate = (bool) auth()->user()?->can('update', $this->resource); $this->supportsPreviewSuffix = $this->resource instanceof Application - && $this->resource->git_based(); + && $this->resource->git_based() + && filled($this->resource->git_repository); $this->showActionsColumn = $this->canUpdate; $this->showBackupAction = $this->resource instanceof Application || $this->resource instanceof ServiceApplication @@ -129,12 +132,35 @@ class All extends Component $storage = $this->findStorageOrFail($storageId); + if ($this->isComposeOrService && $storage->isDeclaredInCompose()) { + $this->dispatch('error', 'This volume is managed by the current Docker Compose file.'); + + return false; + } + if ($storage->scheduledBackups()->exists()) { $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); return false; } + $this->deleteDockerVolume = in_array('deleteDockerVolume', $selectedActions, true); + if ($this->deleteDockerVolume) { + $server = $this->resource instanceof Application + ? $this->resource->destination->server + : $this->resource->service->server; + + try { + instant_remote_process([ + 'docker volume rm -f '.escapeshellarg($storage->name), + ], $server); + } catch (\Throwable $exception) { + $this->dispatch('error', 'Failed to delete the Docker volume: '.$exception->getMessage()); + + return false; + } + } + $storage->delete(); $this->refreshList(); $this->dispatch('refreshStorages'); @@ -169,6 +195,9 @@ class All extends Component 'hostPath' => $storage->host_path, 'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true), 'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate, + 'canDeleteStale' => $this->canUpdate + && ($storage->isServiceResource() || $storage->isDockerComposeResource()) + && ! $storage->isDeclaredInCompose(), ]; } $this->forms = $forms; diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index db155d3e5..7e1e2dec1 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -105,6 +105,7 @@ class Show extends Component // PR deployment volume suffixes only apply to git-based applications. $this->supportsPreviewSuffix = $this->resource instanceof Application && $this->resource->git_based() + && filled($this->resource->git_repository) && ! $this->isService; // Parent All batches badge/url; isolated embeds still hydrate themselves. if (! $this->backupMetaHydrated) { diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 6b4e0fe5b..add857fe2 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Support\Str; use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends BaseModel @@ -136,6 +137,49 @@ class LocalPersistentVolume extends BaseModel return $this->isReadOnlyVolume(); } + public function isDeclaredInCompose(): bool + { + try { + $resource = $this->resource; + if (! $resource) { + return true; + } + + $composeContent = $resource instanceof Application + ? $resource->docker_compose_raw + : data_get($resource, 'service.docker_compose_raw'); + + if (blank($composeContent)) { + return true; + } + + $compose = Yaml::parse($composeContent); + $services = data_get($compose, 'services', []); + + if ($this->isServiceResource()) { + $services = array_intersect_key($services, [$resource->name => true]); + } + + foreach ($services as $service) { + foreach (data_get($service, 'volumes', []) as $volume) { + $parsedVolume = is_array($volume) ? $volume : parseDockerVolumeString($volume); + $source = data_get($parsedVolume, 'source'); + $target = data_get($parsedVolume, 'target'); + $resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid'); + $generatedName = $source ? $resourceUuid.'_'.Str::slug($source, '-') : null; + + if ($generatedName === $this->name && $target && str($target)->start('/')->value() === $this->mount_path) { + return true; + } + } + } + + return false; + } catch (\Throwable) { + return true; + } + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php index 3266d0719..d1446ea37 100644 --- a/app/Services/AvatarStorageService.php +++ b/app/Services/AvatarStorageService.php @@ -68,7 +68,7 @@ class AvatarStorageService ]); } - private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter + protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter { if ($storageType !== 's3') { return Storage::disk('local'); @@ -82,7 +82,7 @@ class AvatarStorageService return $storage->filesystem(); } - private function compress(UploadedFile $upload): string + protected function compress(UploadedFile $upload): string { $imageInfo = getimagesize($upload->getRealPath()); if ($imageInfo && $imageInfo['mime'] === 'image/jpeg' && $imageInfo[0] <= 256 && $imageInfo[1] <= 256) { diff --git a/app/Services/ProjectIconStorageService.php b/app/Services/ProjectIconStorageService.php new file mode 100644 index 000000000..b7a381e10 --- /dev/null +++ b/app/Services/ProjectIconStorageService.php @@ -0,0 +1,69 @@ +avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local'; + $s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null; + $disk = $this->disk($storageType, $s3StorageId); + $path = "project-icons/{$project->uuid}/icon.jpg"; + + if (! $disk->put($path, $this->compress($upload))) { + throw new RuntimeException('Unable to store the project icon.'); + } + + $oldStorageType = $project->icon_storage_type; + $oldS3StorageId = $project->icon_s3_storage_id; + $oldPath = $project->icon_path; + + $project->forceFill([ + 'icon_path' => $path, + 'icon_storage_type' => $storageType, + 'icon_s3_storage_id' => $s3StorageId, + ])->save(); + + if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) { + $this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath); + } + } + + public function projectContents(Project $project): ?string + { + if (! $project->icon_path) { + return null; + } + + try { + $disk = $this->disk($project->icon_storage_type ?? 'local', $project->icon_s3_storage_id); + } catch (RuntimeException) { + return null; + } + + return $disk->exists($project->icon_path) ? $disk->get($project->icon_path) : null; + } + + public function deleteProject(Project $project): void + { + if ($project->icon_path) { + try { + $this->disk($project->icon_storage_type ?? 'local', $project->icon_s3_storage_id) + ->delete($project->icon_path); + } catch (RuntimeException) { + } + } + + $project->forceFill([ + 'icon_path' => null, + 'icon_storage_type' => null, + 'icon_s3_storage_id' => null, + ])->save(); + } +} diff --git a/database/migrations/2026_08_13_140035_add_icon_columns_to_projects_table.php b/database/migrations/2026_08_13_140035_add_icon_columns_to_projects_table.php new file mode 100644 index 000000000..674f39c82 --- /dev/null +++ b/database/migrations/2026_08_13_140035_add_icon_columns_to_projects_table.php @@ -0,0 +1,31 @@ +string('icon_path')->nullable(); + $table->string('icon_storage_type')->nullable(); + $table->foreignId('icon_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropConstrainedForeignId('icon_s3_storage_id'); + $table->dropColumn(['icon_path', 'icon_storage_type']); + }); + } +}; diff --git a/svgs/jean.png b/public/svgs/jean.png similarity index 100% rename from svgs/jean.png rename to public/svgs/jean.png diff --git a/resources/css/app.css b/resources/css/app.css index 397b2e412..e0f5b1302 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1570,17 +1570,6 @@ html[data-theme="custom"] textarea:disabled { line-height: 1.25rem; } -/* Buttons */ -.application-settings-workspace .button, -.application-settings-form .button { - height: 2rem; - min-height: 2rem; - border-radius: 8px; - padding-left: 0.75rem; - padding-right: 0.75rem; - white-space: nowrap; -} - .application-settings-workspace .form-control, .application-settings-form .form-control { border-radius: 8px; @@ -2698,9 +2687,9 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-executions-table-grid { - grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem; + grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem; gap: 0.75rem; - min-width: 49rem; + min-width: 66rem; } .volume-backup-executions-grid { @@ -2760,11 +2749,11 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-executions-table-grid { - grid-template-columns: 7.5rem minmax(9rem, 1fr) 8rem 6rem minmax(9rem, auto); + grid-template-columns: 7.5rem minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) 8rem 6rem minmax(9rem, auto); } - .backup-executions-table-grid > :nth-child(5), - .backup-executions-table-grid > :nth-child(6) { + .backup-executions-table-grid > :nth-child(6), + .backup-executions-table-grid > :nth-child(7) { display: none; } } @@ -2871,11 +2860,14 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-executions-table-grid { - grid-template-columns: 7.5rem minmax(0, 1fr) minmax(7rem, auto); + grid-template-columns: 7.5rem 9rem minmax(14rem, 1fr) minmax(7rem, auto); + min-width: 42rem; } - .backup-executions-table-grid > :nth-child(3), - .backup-executions-table-grid > :nth-child(4) { + .backup-executions-table-grid > :nth-child(4), + .backup-executions-table-grid > :nth-child(5), + .backup-executions-table-grid > :nth-child(6), + .backup-executions-table-grid > :nth-child(7) { display: none; } @@ -3198,7 +3190,14 @@ html[data-theme="custom"] .logs-viewer-timestamp { .logs-viewer-viewport { min-width: 0; - padding: 0.5rem 0.75rem 2rem; + padding: 0.5rem 0.75rem 0; +} + +/* A flex item is reliably included in the scrollable overflow area, unlike + bottom padding on overflow containers in some browser/layout combinations. */ +.logs-viewer-viewport::after { + content: ""; + flex: 0 0 2rem; } .logs-viewer-line { @@ -3292,7 +3291,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { } .logs-viewer-viewport { - padding: 0.5rem 1rem 2rem; + padding: 0.5rem 1rem 0; } .logs-viewer-line { diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php new file mode 100644 index 000000000..dfdceef20 --- /dev/null +++ b/resources/views/components/copy-button.blade.php @@ -0,0 +1,22 @@ +@props([ + 'value', + 'label' => 'Copy to clipboard', +]) + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index a0f40e0b5..02c7fc9a7 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -22,7 +22,7 @@ this.collapsed = !this.collapsed; localStorage.setItem('sidebarCollapsed', this.collapsed); } - }" x-cloak class="dark:text-inherit text-black"> + }" @open-global-search.window="open = false" x-cloak class="dark:text-inherit text-black"> {{-- ============ DESKTOP TOP BAR ============ --}} diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index 888083210..0401b4ede 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -48,7 +48,8 @@ {{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify - +
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 3c2868a8d..31637d7f9 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -1,5 +1,9 @@
- @if ($section === 'retention') + @if ($backup->database_id === 0) + @include('livewire.project.database.backup-edit.general') + @include('livewire.project.database.backup-edit.s3') + @include('livewire.project.database.backup-edit.retention') + @elseif ($section === 'retention') @include('livewire.project.database.backup-edit.retention') @elseif ($section === 's3') @include('livewire.project.database.backup-edit.s3') diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index e6f9d905d..d3aa40f5a 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -30,6 +30,7 @@ class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]"> Status Database + Backup path Finished Duration Size @@ -79,6 +80,11 @@
{{ data_get($execution, 'database_name', 'N/A') }}
+
+ {{ data_get($execution, 'filename', 'N/A') }} + +
@if ($executionStatus === 'running') Running now diff --git a/resources/views/livewire/project/edit.blade.php b/resources/views/livewire/project/edit.blade.php index b56ac5017..6557c7511 100644 --- a/resources/views/livewire/project/edit.blade.php +++ b/resources/views/livewire/project/edit.blade.php @@ -7,6 +7,86 @@
+
+
+
+

Project icon

+

Upload a JPG, PNG, or WebP image. It will appear in the projects list.

+
+
+
+
+ Project icon preview + @if ($project->icon_path) + {{ $project->name }} icon + @else + + @endif +
+
+
+ + + + + @if ($project->icon_path) + Remove + @endif +
+

+ @error('icon')

{{ $message }}

@enderror +
+
+
+
diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index 2a66e19dc..1c91dce3d 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -77,7 +77,7 @@
+ 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]">
@endif
diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 5abb320b2..772bdd835 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -97,7 +97,7 @@
+ 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]"> -
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 607c5c4a2..d15a1b87a 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -135,8 +135,8 @@
- +
diff --git a/routes/web.php b/routes/web.php index 5561ba0ec..ff3c0890c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\OauthController; use App\Http\Controllers\ProfileAvatarController; +use App\Http\Controllers\ProjectIconController; use App\Http\Controllers\UploadController; use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Boarding\Index as BoardingIndex; @@ -246,6 +247,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/projects', ProjectIndex::class)->name('project.index'); + Route::get('/project/{project_uuid}/icon', ProjectIconController::class)->name('project.icon'); Route::prefix('project/{project_uuid}')->group(function () { Route::get('/', ProjectShow::class)->name('project.show'); Route::get('/edit', ProjectEdit::class)->name('project.edit')->middleware('can.update.resource'); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 0e504d79f..42a430a49 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -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() diff --git a/tests/Feature/BackupSearchTest.php b/tests/Feature/BackupSearchTest.php index 6a0d0cca1..7bb1b1411 100644 --- a/tests/Feature/BackupSearchTest.php +++ b/tests/Feature/BackupSearchTest.php @@ -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 () { diff --git a/tests/Feature/ButtonHeightConsistencyTest.php b/tests/Feature/ButtonHeightConsistencyTest.php new file mode 100644 index 000000000..06b45d782 --- /dev/null +++ b/tests/Feature/ButtonHeightConsistencyTest.php @@ -0,0 +1,11 @@ +toContain('px-2.5 h-9 min-h-9') + ->and($applicationStyles) + ->not->toContain('.application-settings-workspace .button') + ->not->toContain('.application-settings-form .button'); +}); diff --git a/tests/Feature/CopyButtonComponentTest.php b/tests/Feature/CopyButtonComponentTest.php new file mode 100644 index 000000000..a9996a062 --- /dev/null +++ b/tests/Feature/CopyButtonComponentTest.php @@ -0,0 +1,16 @@ +blade(''); + + $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(''); +}); diff --git a/tests/Feature/DatabaseBackupsLayoutTest.php b/tests/Feature/DatabaseBackupsLayoutTest.php index 5be04cd4e..b2402a7bc 100644 --- a/tests/Feature/DatabaseBackupsLayoutTest.php +++ b/tests/Feature/DatabaseBackupsLayoutTest.php @@ -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('Backup path') + ->toContain('class="select-all truncate font-mono text-[11px]') + ->not->toContain('backup-executions-table-grid border-t'); +}); diff --git a/tests/Feature/DeploymentLogsLayoutTest.php b/tests/Feature/DeploymentLogsLayoutTest.php index 1733c3f64..0b9a4452c 100644 --- a/tests/Feature/DeploymentLogsLayoutTest.php +++ b/tests/Feature/DeploymentLogsLayoutTest.php @@ -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)'); diff --git a/tests/Feature/EmptyStateComponentTest.php b/tests/Feature/EmptyStateComponentTest.php index da0e4c0c5..541f587ba 100644 --- a/tests/Feature/EmptyStateComponentTest.php +++ b/tests/Feature/EmptyStateComponentTest.php @@ -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('') @@ -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 () { diff --git a/tests/Feature/GlobalSearchLoadingStateTest.php b/tests/Feature/GlobalSearchLoadingStateTest.php index adf83e364..8737dde28 100644 --- a/tests/Feature/GlobalSearchLoadingStateTest.php +++ b/tests/Feature/GlobalSearchLoadingStateTest.php @@ -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')); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 4019606e1..3d609395a 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.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 () { diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php new file mode 100644 index 000000000..db4d74df4 --- /dev/null +++ b/tests/Feature/ProjectIconTest.php @@ -0,0 +1,101 @@ + 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, + ])); +}); diff --git a/tests/Feature/ProjectNewSelectAccentTest.php b/tests/Feature/ProjectNewSelectAccentTest.php new file mode 100644 index 000000000..13da2e5c2 --- /dev/null +++ b/tests/Feature/ProjectNewSelectAccentTest.php @@ -0,0 +1,9 @@ +not->toContain('dark:group-hover:text-warning') + ->and(substr_count($view, 'class="button button-highlighted ml-auto"'))->toBe(4); +}); diff --git a/tests/Feature/ProjectViewControlsStyleTest.php b/tests/Feature/ProjectViewControlsStyleTest.php new file mode 100644 index 000000000..cb6a24b7c --- /dev/null +++ b/tests/Feature/ProjectViewControlsStyleTest.php @@ -0,0 +1,16 @@ +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]"' + ); + } +}); diff --git a/tests/Feature/SettingsDropdownTest.php b/tests/Feature/SettingsDropdownTest.php index 53ef37e0d..a0f7ec90b 100644 --- a/tests/Feature/SettingsDropdownTest.php +++ b/tests/Feature/SettingsDropdownTest.php @@ -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'); }); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 0be0b2a41..752253824 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -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')); diff --git a/tests/Fixtures/project-icon.jpg b/tests/Fixtures/project-icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e760925b472dd42b189b928ef5d8926915be4c4e GIT binary patch literal 23201 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'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(); +});