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 000000000..e760925b4 Binary files /dev/null and b/tests/Fixtures/project-icon.jpg differ diff --git a/tests/Unit/JeanServiceTemplateTest.php b/tests/Unit/JeanServiceTemplateTest.php index 32de62ed3..d340f67a6 100644 --- a/tests/Unit/JeanServiceTemplateTest.php +++ b/tests/Unit/JeanServiceTemplateTest.php @@ -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(); +});