mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-18 14:23:15 +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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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]"'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user