Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai
2026-08-17 16:23:56 +02:00
20 changed files with 202 additions and 161 deletions
+1
View File
@@ -179,6 +179,7 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin
- Run `vendor/bin/pint --dirty --format agent` before finalizing changes
- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below)
- Check sibling files for conventions before creating new files
- When adding remote shell commands, account for servers using non-root SSH users: commands pass through `parseCommandsByLineForSudo()`, so test pipelines, redirects, substitutions, and `sh -c`/`bash -c` scripts with the non-root sudo parser.
## Git Workflow
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
@@ -35,12 +36,20 @@ class Domains extends Component
public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?string $newDomainService = null;
public ?int $editingIndex = null;
public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?string $editingService = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
@@ -662,6 +671,12 @@ class Domains extends Component
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainService(): void
{
$this->resetAddDomainDnsGate();
@@ -677,6 +692,8 @@ class Domains extends Component
public function resetAddDomainForm(): void
{
$this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->resetAddDomainDnsGate();
$this->resetErrorBag('newDomain');
}
@@ -743,6 +760,9 @@ class Domains extends Component
return;
}
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain);
@@ -893,6 +913,12 @@ class Domains extends Component
$this->resetEditDomainDnsGate();
}
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->resetEditDomainDnsGate();
}
public function resetEditDomainDnsGate(): void
{
$this->editDomainDnsFailed = false;
@@ -908,10 +934,13 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service'];
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
}
public function addSuggestedDomain(int $index): void
@@ -990,6 +1019,8 @@ class Domains extends Component
$this->showEditDomainModal = false;
$this->editingIndex = null;
$this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingService = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
@@ -1021,6 +1052,9 @@ class Domains extends Component
return;
}
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
+35
View File
@@ -43,10 +43,18 @@ class Domains extends Component
public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?int $editingIndex = null;
public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?int $editingServiceApplicationId = null;
public bool $showEditDomainModal = false;
@@ -515,6 +523,12 @@ class Domains extends Component
$this->forceSaveDns = false;
}
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function updatedEditingDomain(): void
{
$this->editDomainDnsFailed = false;
@@ -522,6 +536,12 @@ class Domains extends Component
$this->forceSaveEditDns = false;
}
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->updatedEditingDomain();
}
public function confirmAddDomainDespiteDns(): void
{
$this->forceSaveDns = true;
@@ -842,6 +862,9 @@ class Domains extends Component
{
try {
$this->authorize('update', $this->service);
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain');
$app = $this->findServiceApp($this->newServiceApplicationId);
@@ -893,6 +916,8 @@ class Domains extends Component
}
$this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->addDomainDnsFailed = false;
$this->addDomainDnsMessage = '';
$this->forceSaveDns = false;
@@ -916,12 +941,15 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
}
public function cancelEdit(): void
@@ -929,6 +957,8 @@ class Domains extends Component
$this->showEditDomainModal = false;
$this->editingIndex = null;
$this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
@@ -945,6 +975,9 @@ class Domains extends Component
return;
}
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$app = $this->findServiceApp($this->editingServiceApplicationId);
@@ -1130,6 +1163,8 @@ class Domains extends Component
}
$this->newDomain = $domain;
$this->newDomainParts = DomainUrlParts::split($domain);
$this->newDomainPartsChanged = true;
$this->updatedNewDomain();
} catch (\Throwable $e) {
handleError($e, $this);
+13
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@@ -28,6 +29,10 @@ class Create extends Component
public string $endpoint = '';
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public S3Storage $storage;
protected function rules(): array
@@ -76,6 +81,9 @@ class Create extends Component
try {
$this->authorize('create', S3Storage::class);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate();
$this->storage = new S3Storage;
@@ -101,6 +109,11 @@ class Create extends Component
}
}
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
private function connectionErrorDescription(\Throwable $exception): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
+18
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -24,6 +25,10 @@ class Form extends Component
public string $endpoint;
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public string $bucket;
public string $region;
@@ -101,6 +106,8 @@ class Form extends Component
$this->name = $this->storage->name;
$this->description = $this->storage->description;
$this->endpoint = $this->storage->endpoint;
$this->endpointParts = DomainUrlParts::split($this->endpoint);
$this->endpointPartsChanged = false;
$this->bucket = $this->storage->bucket;
$this->region = $this->storage->region;
$this->key = $this->storage->key;
@@ -126,6 +133,9 @@ class Form extends Component
try {
$this->authorize('validateConnection', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$testedStorage = new S3Storage;
$testedStorage->uuid = $this->storage->uuid;
$testedStorage->team_id = $this->storage->team_id;
@@ -166,6 +176,9 @@ class Form extends Component
{
try {
$this->authorize('update', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
DB::transaction(function () {
$this->validate();
@@ -195,4 +208,9 @@ class Form extends Component
return handleError($e, $this);
}
}
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
}
+1
View File
@@ -95,6 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$isComplexPipeCommand = (
$line->contains(' | sh') ||
$line->contains(' | bash') ||
$line->contains(' sh -c ') ||
($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
);
@@ -1,68 +1,27 @@
@props([
'id',
'wire' => true,
'value' => '',
'errorId' => null,
'hostLabel' => 'Domain',
'hostPlaceholder' => 'app.example.com',
])
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]" x-data="{
value: @if ($wire) @entangle($id) @else @js($value) @endif,
scheme: 'https',
host: '',
port: '',
path: '',
syncing: false,
init() {
this.read(this.value);
this.$watch('value', value => {
if (!this.syncing) this.read(value);
});
['scheme', 'host', 'port', 'path'].forEach(part => this.$watch(part, () => this.write()));
},
read(value) {
if (!value) return;
try {
const url = new URL(value);
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:\[[^\]]+\]|[^\/:?#]+)(?::(\d+))?/i);
this.syncing = true;
this.scheme = url.protocol.replace(':', '') === 'http' ? 'http' : 'https';
this.host = url.hostname;
this.port = authority?.[1] || url.port;
this.path = `${url.pathname === '/' ? '' : url.pathname}${url.search}${url.hash}`;
this.$nextTick(() => this.syncing = false);
} catch (_) {}
},
write() {
if (this.syncing) return;
const path = this.path.trim();
const normalizedPath = path && !['/', '?', '#'].includes(path[0]) ? `/${path}` : path;
const next = `${this.scheme}://${this.host.trim()}${this.port ? `:${this.port}` : ''}${normalizedPath}`;
if (this.value !== next) {
this.syncing = true;
this.value = next;
this.$nextTick(() => this.syncing = false);
}
},
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}>
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]">
<div class="min-w-0">
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https"
x-model="scheme" portal :options="[
['value' => 'https', 'label' => 'https'],
['value' => 'http', 'label' => 'http'],
]" />
<x-forms.listbox id="{{ $id }}.scheme" htmlId="{{ $id }}-protocol" label="Protocol" portal :options="[
['value' => 'https', 'label' => 'https'],
['value' => 'http', 'label' => 'http'],
]" />
</div>
<div class="min-w-0">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}" class="mb-0! flex items-center gap-1.5 leading-4">
<label for="{{ $id }}-host" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $hostLabel }} <x-highlighted text="*" />
</label>
</div>
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="{{ $hostPlaceholder }}"
autocomplete="off" required />
@error($errorId ?? $id)
<input id="{{ $id }}-host" type="text" class="input" wire:model="{{ $id }}.host"
placeholder="{{ $hostPlaceholder }}" autocomplete="off" required />
@error($errorId ?? "{$id}.host")
@php
preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches);
$validationLink = $validationLinkMatches[1] ?? null;
@@ -82,16 +41,16 @@
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-port" class="mb-0! flex items-center gap-1.5 leading-4">Port</label>
</div>
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000"
min="1" max="65535" inputmode="numeric" />
<input id="{{ $id }}-port" type="number" class="input" wire:model="{{ $id }}.port"
placeholder="3000" min="1" max="65535" inputmode="numeric" />
</div>
<div class="min-w-0 sm:col-span-3">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-path" class="mb-0! flex items-center gap-1.5 leading-4">Path</label>
</div>
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3"
autocomplete="off" />
<input id="{{ $id }}-path" type="text" class="input" wire:model="{{ $id }}.path"
placeholder="/api/v3" autocomplete="off" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Optional path, query, or fragment appended after the domain and port.
</p>
@@ -90,6 +90,9 @@
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
panel.style.width = 'max-content';
panel.style.minWidth = `${triggerRect.width}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
const panelWidth = Math.min(
Math.max(triggerRect.width, panel.offsetWidth),
window.innerWidth - (edge * 2),
@@ -107,8 +110,6 @@
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.minWidth = `${triggerRect.width}px`;
this.positioned = true;
},
}" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
@@ -15,30 +15,14 @@
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingService: @js($editingService),
openEditDomain(index, url, service) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
openEditDomain() {
this.editingServiceLabel = $wire.editingService || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingService = null;
},
prepareEditSubmit() {
// Sync Alpine → Livewire only when the user actually saves (one request).
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
@@ -47,7 +31,7 @@
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
@open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="domains-section" title="Domains">
@can('update', $application)
@@ -128,7 +112,7 @@
:disabled="! auth()->user()->can('update', $application)" />
@endif
<x-forms.domain-input id="newDomain" />
<x-forms.domain-input id="newDomainParts" errorId="newDomain" />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -320,7 +304,7 @@
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label>
@@ -328,8 +312,7 @@
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false"
x-model="localEditingDomain" />
<x-forms.domain-input id="editingDomainParts" errorId="editingDomain" />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -345,7 +328,7 @@
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
wire:click="confirmUpdateDomainDespiteDns">
Continue
</x-forms.button>
@else
@@ -178,12 +178,7 @@
</x-forms.button>
@endif
@else
<button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
})"
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
@@ -19,29 +19,14 @@
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: '',
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingServiceApplicationId: @js($editingServiceApplicationId),
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingServiceApplicationId = serviceApplicationId;
this.editingServiceLabel = serviceLabel || '';
openEditDomain() {
this.editingServiceLabel = $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingServiceApplicationId = null;
},
prepareEditSubmit() {
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
@@ -50,7 +35,7 @@
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)"
@open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="service-domains-section" title="Domains">
@can('update', $service)
@@ -116,7 +101,7 @@
])->values()->all()"
:disabled="! auth()->user()->can('update', $service)" />
<x-forms.domain-input id="newDomain" />
<x-forms.domain-input id="newDomainParts" errorId="newDomain" />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -232,7 +217,7 @@
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service application</label>
@@ -243,8 +228,7 @@
</p>
</div>
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false"
x-model="localEditingDomain" />
<x-forms.domain-input id="editingDomainParts" errorId="editingDomain" />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -260,7 +244,7 @@
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
wire:click="confirmUpdateDomainDespiteDns">
Continue
</x-forms.button>
@else
@@ -201,13 +201,7 @@
</x-forms.button>
@endif
@else
<button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
serviceLabel: @js($serviceLabel),
})"
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -7,7 +7,7 @@
<x-forms.input required label="Name" id="name" />
<x-forms.input label="Description" id="description" />
</div>
<x-forms.domain-input id="endpoint" host-label="Host"
<x-forms.domain-input id="endpointParts" errorId="endpoint" host-label="Host"
host-placeholder="minio.internal or 192.168.1.50" />
<div class="flex gap-2">
<x-forms.input required label="Bucket" id="bucket" />
@@ -17,7 +17,7 @@
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />
<div class="lg:col-span-2">
@can('update', $storage)
<x-forms.domain-input id="endpoint" host-label="Host"
<x-forms.domain-input id="endpointParts" errorId="endpoint" host-label="Host"
host-placeholder="minio.internal or 192.168.1.50" />
@else
<x-forms.input label="Endpoint" :value="$endpoint" disabled />
+20 -4
View File
@@ -236,6 +236,22 @@ it('adds a domain to the application', function () {
->toBe(['https://app.example.com', 'https://www.app.example.com']);
});
it('composes the complete port on the server without duplicating an existing www domain', function () {
$this->application->update(['fqdn' => 'https://www.example.com:3000']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '3000')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([
'https://www.example.com:3000',
'https://example.com:3000',
]);
});
it('adds multiple domains without replacing existing ones', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
@@ -1222,16 +1238,16 @@ it('uses segmented fields when adding and editing application domains', function
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
expect($view)
->toContain('<x-forms.domain-input id="newDomain"')
->toContain('<x-forms.domain-input id="editingDomainLocal"')
->toContain('<x-forms.domain-input id="newDomainParts"')
->toContain('<x-forms.domain-input id="editingDomainParts"')
->not->toContain('placeholder="https://app.example.com"')
->and($component)
->toContain('Protocol')
->toContain('Domain')
->toContain('Port')
->toContain('Path')
->toContain("scheme: 'https'")
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
->toContain('wire:model="{{ $id }}.host"')
->toContain('<x-forms.listbox id="{{ $id }}.scheme"')
->not->toContain('<select id="{{ $id }}-protocol"')
->toContain("['value' => 'https', 'label' => 'https']")
->toContain("['value' => 'http', 'label' => 'http']")
@@ -60,6 +60,15 @@ test('mobile listbox panels stay anchored to their trigger', function () {
->not->toContain('transform: translate(-50%, -50%) !important;');
});
test('portaled listboxes measure content without inheriting the viewport width', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
expect($listbox)
->toContain("panel.style.width = 'max-content';")
->toContain('panel.style.minWidth = `${triggerRect.width}px`;')
->toContain('Math.max(triggerRect.width, panel.offsetWidth)');
});
test('searchable listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="tz" label="Timezone"
+2 -2
View File
@@ -141,8 +141,8 @@ it('uses segmented fields when adding and editing service domains', function ()
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)
->toContain('<x-forms.domain-input id="newDomain"')
->toContain('<x-forms.domain-input id="editingDomainLocal"')
->toContain('<x-forms.domain-input id="newDomainParts"')
->toContain('<x-forms.domain-input id="editingDomainParts"')
->not->toContain('placeholder="https://app.example.com"');
});
+15 -35
View File
@@ -1,51 +1,31 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
it('renders configurable host copy for S3 endpoints', function () {
View::share('errors', new ViewErrorBag);
$html = Blade::render(<<<'BLADE'
<x-forms.domain-input id="endpoint" :wire="false" value="http://192.168.1.50:9000/s3"
host-label="Host" host-placeholder="minio.internal or 192.168.1.50" />
BLADE);
$html = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
expect($html)
->toContain('Host')
->toContain('minio.internal or 192.168.1.50')
->toContain("scheme: 'https'")
->toContain("host: ''")
->toContain("port: ''")
->toContain("path: ''");
->toContain('{{ $hostLabel }}')
->toContain('{{ $hostPlaceholder }}')
->toContain('wire:model="{{ $id }}.host"')
->toContain('wire:model="{{ $id }}.port"')
->toContain('wire:model="{{ $id }}.path"');
});
it('does not reparse partial numeric hosts while typing', function () {
it('binds URL parts directly to Livewire without Alpine synchronization', function () {
$view = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
$writeMethod = str($view)->between('write() {', "\n },\n}")->value();
expect($writeMethod)
->toContain('this.syncing = true;')
->toContain('this.value = next;')
->toContain('this.$nextTick(() => this.syncing = false);')
->and(strpos($writeMethod, 'this.syncing = true;'))
->toBeLessThan(strpos($writeMethod, 'this.value = next;'));
expect($view)
->toContain('wire:model="{{ $id }}.host"')
->toContain('wire:model="{{ $id }}.port"')
->not->toContain('x-data=')
->not->toContain('$watch');
});
it('keeps validation errors attached to the composed endpoint', function () {
$settingsUrl = route('settings.advanced').'#endpoint-section';
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag([
'endpoint' => "The endpoint is invalid. Configure allowed internal targets: {$settingsUrl}",
]));
View::share('errors', $errors);
$html = Blade::render('<x-forms.domain-input id="endpoint" :wire="false" />');
$html = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
expect($html)
->toContain('The endpoint is invalid.')
->toContain('href="'.$settingsUrl.'"')
->toContain('@error($errorId ?? "{$id}.host")')
->toContain('href="{{ $validationLink }}"')
->toContain('Set them here.');
});
@@ -24,6 +24,24 @@ test('wraps complex Docker install command with pipes in bash -c', function () {
expect($result[0])->toBe("sudo bash -c 'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh'");
});
test('preserves command substitutions inside database and volume backup scripts', function () {
$script = 'compressor=$(if command -v pigz; then printf pigz; else printf gzip; fi); exec $compressor';
$command = 'docker exec database pg_dumpall | docker run --rm -i helper sh -c '.escapeshellarg($script);
$volumeCommand = 'docker run --rm helper sh -c '.escapeshellarg($script).' > /data/coolify/backups/volume.tar.gz';
$result = parseCommandsByLineForSudo(collect([$command, $volumeCommand]), $this->server);
expect($result[0])
->toStartWith("sudo bash -c '")
->toContain('compressor=$(if command -v pigz; then')
->not->toContain('$(sudo if')
->not->toContain('| sudo docker run')
->and($result[1])
->toStartWith("sudo bash -c '")
->toContain('compressor=$(if command -v pigz; then')
->not->toContain('$(sudo if');
});
test('wraps complex Docker install command with multiple fallbacks', function () {
$commands = collect([
'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3',
@@ -11,11 +11,11 @@ it('uses the shared split URL input without a Livewire blur request', function (
expect($createView)
->not->toContain('wire:model.blur="endpoint"')
->toContain('<x-forms.domain-input id="endpoint"')
->toContain('<x-forms.domain-input id="endpointParts"')
->toContain('host-label="Host"')
->toContain('host-placeholder="minio.internal or 192.168.1.50"')
->and($editView)
->toContain('<x-forms.domain-input id="endpoint"')
->toContain('<x-forms.domain-input id="endpointParts"')
->toContain('@can(\'update\', $storage)');
});