mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 08:17:02 +00:00
feat(project): add icons and improve persistent volume management
Add project icon storage and serving with local/S3 support, prevent deletion of compose-managed volumes, and optimize volume backups. Refine project and backup UI components with consistent styling and coverage.
This commit is contained in:
1 parent
a02d02d19b
commit
fd5eb3e0cd
43 files changed
+728
-60
No files matched your search
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user