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

@@ -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();
}
}