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 committed 2026-08-13 22:56:52 +02:00
1 parent a02d02d19b
commit fd5eb3e0cd
43 files changed
+728 -60

No files matched your search

+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" />