mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 04:15:55 +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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user