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:
Andras Bacsai
2026-08-13 22:56:52 +02:00
parent a02d02d19b
commit fd5eb3e0cd
43 changed files with 728 additions and 60 deletions
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Services\ProjectIconStorageService;
use Illuminate\Http\Response;
class ProjectIconController extends Controller
{
public function __invoke(string $project_uuid, ProjectIconStorageService $iconStorage): Response
{
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$contents = $iconStorage->projectContents($project);
abort_if($contents === null, 404);
return response($contents)->header('Content-Type', 'image/jpeg');
}
}
+1 -1
View File
@@ -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);
+37
View File
@@ -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 [
+4
View File
@@ -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,
+31 -2
View File
@@ -21,7 +21,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool}>
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
*/
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;
@@ -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) {
+44
View File
@@ -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
{
+2 -2
View File
@@ -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) {
@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\Project;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class ProjectIconStorageService extends AvatarStorageService
{
public function storeProject(Project $project, UploadedFile $upload): void
{
$settings = instanceSettings();
$storageType = $settings->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();
}
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->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']);
});
}
};

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+20 -21
View File
@@ -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 {
@@ -0,0 +1,22 @@
@props([
'value',
'label' => 'Copy to clipboard',
])
<button type="button"
x-data="{ copied: false }"
x-on:click.prevent.stop="await window.copyToClipboard({{ Js::from($value) }}); copied = true; setTimeout(() => copied = false, 1000)"
{{ $attributes->class('inline-flex size-6 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-40 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white') }}
title="{{ $label }}" aria-label="{{ $label }}" @disabled(blank($value))>
<svg x-show="!copied" class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true">
<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16"
stroke-width="1.5" stroke-linecap="round" />
<rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke-width="1.5" />
</svg>
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" viewBox="0 0 24 24" fill="none"
stroke="currentColor" aria-hidden="true">
<path d="m6.75 12.25 3.5 3.5 7-7" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
+1 -1
View File
@@ -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">
<livewire:deployments-indicator />
{{-- ============ DESKTOP TOP BAR ============ --}}
@@ -48,7 +48,8 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-backup-index" />
<livewire:project.application.heading :application="$application"
wire:key="application-heading-backup-index-{{ $application->id }}" />
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
@@ -1,5 +1,9 @@
<div>
@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')
@@ -30,6 +30,7 @@
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
<span>Status</span>
<span>Database</span>
<span>Backup path</span>
<span>Finished</span>
<span>Duration</span>
<span>Size</span>
@@ -79,6 +80,11 @@
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
{{ data_get($execution, 'database_name', 'N/A') }}
</div>
<div class="flex min-w-0 items-center gap-1">
<code class="select-all truncate font-mono text-[11px] text-neutral-600 dark:text-fg-dim"
title="Backup path: {{ data_get($execution, 'filename', 'N/A') }}">{{ data_get($execution, 'filename', 'N/A') }}</code>
<x-copy-button :value="data_get($execution, 'filename', '')" label="Copy backup path" />
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
@if ($executionStatus === 'running')
Running now
@@ -7,6 +7,86 @@
</header>
<div class="flex flex-col gap-6">
<section class="application-settings-section" x-data="{
preview: null,
processing: false,
uploadError: null,
async prepareIcon(event) {
const file = event.target.files?.[0];
if (!file) return;
this.processing = true;
this.uploadError = null;
try {
const image = await new Promise((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = reject;
element.src = URL.createObjectURL(file);
});
const cropSize = Math.min(image.naturalWidth, image.naturalHeight);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 256, 256);
context.drawImage(image, (image.naturalWidth - cropSize) / 2, (image.naturalHeight - cropSize) / 2, cropSize, cropSize, 0, 0, 256, 256);
const blob = await new Promise((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8));
const previewUrl = URL.createObjectURL(blob);
const compressed = new File([blob], 'project-icon.jpg', { type: 'image/jpeg' });
this.$wire.upload('icon', compressed, async () => {
const uploaded = await this.$wire.uploadIcon();
if (uploaded) {
if (this.preview) URL.revokeObjectURL(this.preview);
this.preview = previewUrl;
} else {
URL.revokeObjectURL(previewUrl);
}
this.processing = false;
}, () => {
URL.revokeObjectURL(previewUrl);
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
} catch (error) {
this.processing = false;
this.uploadError = 'The image could not be processed in this browser.';
}
},
}">
<div class="application-settings-section-header">
<div>
<h2>Project icon</h2>
<p>Upload a JPG, PNG, or WebP image. It will appear in the projects list.</p>
</div>
</div>
<div class="application-settings-section-body flex items-center gap-4">
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<img x-cloak x-show="preview" :src="preview" alt="Project icon preview" class="h-full w-full object-cover">
@if ($project->icon_path)
<img x-show="!preview" src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
alt="{{ $project->name }} icon" class="h-full w-full object-cover">
@else
<x-reicon x-show="!preview" name="projects" class="size-6" />
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<div class="flex flex-wrap gap-2">
<input x-ref="iconInput" type="file" x-on:change="prepareIcon($event)" accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.iconInput.click()" x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if ($project->icon_path)
<x-forms.button type="button" wire:click="removeIcon" x-bind:disabled="processing" isError>Remove</x-forms.button>
@endif
</div>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('icon') <p class="text-xs text-red-500">{{ $message }}</p> @enderror
</div>
</div>
</section>
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
@@ -77,7 +77,7 @@
</div>
<div
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.035]">
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]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -110,7 +110,12 @@
<div class="flex items-start gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="projects" class="size-4" />
<template x-if="project.iconUrl">
<img :src="project.iconUrl" :alt="`${project.name} icon`" class="h-full w-full rounded-lg object-cover">
</template>
<template x-if="!project.iconUrl">
<x-reicon name="projects" class="size-4" />
</template>
</div>
<div class="min-w-0 flex-1">
<h2
@@ -191,7 +196,12 @@
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
<x-reicon name="projects" class="size-4" />
<template x-if="project.iconUrl">
<img :src="project.iconUrl" :alt="`${project.name} icon`" class="h-full w-full rounded-lg object-cover">
</template>
<template x-if="!project.iconUrl">
<x-reicon name="projects" class="size-4" />
</template>
</div>
<a :href="project.href" {{ wireNavigate() }}
class="truncate text-[13px] font-semibold text-black hover:underline dark:text-fg"
@@ -151,8 +151,7 @@
rel="noopener noreferrer" @click.stop>
Docs
</a>
<span
class="ml-auto inline-flex items-center gap-1 text-[12px] font-medium text-neutral-400 transition-colors group-hover:text-coollabs dark:text-fg-faint dark:group-hover:text-coollabs-100">
<span class="button button-highlighted ml-auto">
Deploy
<x-reicon name="arrow-right" class="size-3.5" />
</span>
@@ -188,8 +187,7 @@
rel="noopener noreferrer" @click.stop>
Docs
</a>
<span
class="ml-auto inline-flex items-center gap-1 text-[12px] font-medium text-neutral-400 transition-colors group-hover:text-coollabs dark:text-fg-faint dark:group-hover:text-coollabs-100">
<span class="button button-highlighted ml-auto">
Deploy
<x-reicon name="arrow-right" class="size-3.5" />
</span>
@@ -254,8 +252,7 @@
class="button" @click.stop>
Website
</a>
<span
class="ml-auto inline-flex items-center gap-1 text-[12px] font-medium text-neutral-400 transition-colors group-hover:text-coollabs dark:text-fg-faint dark:group-hover:text-coollabs-100">
<span class="button button-highlighted ml-auto">
Deploy
<x-reicon name="arrow-right" class="size-3.5" />
</span>
@@ -334,8 +331,7 @@
target="_blank" rel="noopener noreferrer" class="button" @click.stop>
Website
</a>
<span
class="ml-auto inline-flex items-center gap-1 text-[12px] font-medium text-neutral-400 transition-colors group-hover:text-coollabs dark:text-fg-faint dark:group-hover:text-coollabs-100">
<span class="button button-highlighted ml-auto">
Deploy
<x-reicon name="arrow-right" class="size-3.5" />
</span>
@@ -143,7 +143,7 @@
</div>
<div
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.035]">
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]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -116,7 +116,7 @@
</div>
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<div
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.035]">
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]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -121,6 +121,21 @@
@else
<span class="text-neutral-400 dark:text-fg-faint"></span>
@endif
@if ($form['canDeleteStale'])
<x-modal-confirmation title="Remove stale volume entry?" isErrorButton
buttonTitle="Delete stale volume entry" submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Also permanently delete the Docker volume and all its data.',
'default_warning' => 'The Docker volume and its data will not be deleted.',
]]"
:actions="[
'This removes only the stale volume entry from Coolify.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endif
</div>
@endif
</div>
@@ -97,7 +97,7 @@
</div>
<div
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.035]">
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]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
@@ -82,7 +82,7 @@
@endif
</button>
@elseif ($trigger === 'account-menu')
<button wire:click="openWhatsNewModal" @click="open = false" type="button"
<button wire:click="openWhatsNewModal" type="button"
class="listbox-option relative w-full text-left">
<span class="flex items-center gap-2">
<svg class="size-4 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -112,8 +112,8 @@
<section
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-3rem)] !w-full max-w-5xl flex-col overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<div class="flex min-w-0 items-center gap-3">
<header class="relative !pr-11">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.05] dark:text-fg-dim">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -134,8 +134,8 @@
Product updates, fixes, and improvements.
</p>
</div>
</div>
<div class="flex items-center gap-2">
</div>
<div class="flex items-center gap-2">
@if (isDev())
<x-forms.button wire:click="manualFetchChangelog" class="gap-1.5">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -151,12 +151,12 @@
Mark all read
</x-forms.button>
@endif
</div>
<button wire:click="closeWhatsNewModal"
class="flex size-7 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 hover:bg-neutral-100 hover:text-black focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="absolute right-2 top-2 flex size-7 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 hover:bg-neutral-100 hover:text-black focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Close What's new">
<x-reicon name="x" class="size-4" />
</button>
</div>
</header>
<div class="application-settings-section-body flex min-h-0 flex-1 flex-col !p-0">
@@ -135,8 +135,8 @@
</div>
</x-application.settings-section>
<x-application.settings-section id="avatar-storage-section" title="Profile picture storage"
helper="Choose where compressed user profile pictures are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
<x-application.settings-section id="avatar-storage-section" title="Image storage"
helper="Choose where compressed profile pictures and project icons are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
<div class="max-w-md">
<x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave"
:options="$avatar_storage_options" />
+2
View File
@@ -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');
@@ -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()
+3
View File
@@ -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');
});
+16
View File
@@ -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');
});
+3 -2
View File
@@ -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)');
+2 -2
View File
@@ -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 () {
+101
View File
@@ -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]"'
);
}
});
+39 -1
View File
@@ -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');
});
+1 -1
View File
@@ -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

+4
View File
@@ -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();
});