feat(domains): keep domain drafts and move preview settings

Preserve in-progress domain edits and redirects across Livewire
refreshes, copy www port overrides for service redirect pairs, and
relocate preview deployment toggles from Advanced to Previews.
Unsaved bars can stay dirty via Alpine while a modal is closed, and
service domain tables stack at narrow widths.
This commit is contained in:
Andras Bacsai 2026-09-07 18:29:58 +02:00
parent b123356acd
commit d75881fa96
18 changed files with 1204 additions and 426 deletions

View file

@ -520,7 +520,10 @@ ### Unsaved changes
Deferred fields in one Livewire component use one floating unsaved bar and one
submit action. Do not add a separate “Save configuration” button to every
card. Selectors that are safe to persist independently should use the existing
instant-save pattern.
instant-save pattern. When those requests share a component with a modal draft,
pass the unsaved bar a `dirty` Alpine expression comparing that draft with its
initial values, so unrelated saves do not hide pending changes. Mount modal save
bars only while the modal is open to avoid inactive keyboard shortcuts.
---

View file

@ -27,12 +27,6 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $isGitShallowCloneEnabled = false;
#[Validate(['boolean'])]
public bool $isPreviewDeploymentsEnabled = false;
#[Validate(['boolean'])]
public bool $isPrDeploymentsPublicEnabled = false;
#[Validate(['boolean'])]
public bool $isAutoDeployEnabled = true;
@ -107,8 +101,6 @@ private function syncData(bool $toModel = false): void
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->settings->is_gpu_enabled = $this->isGpuEnabled;
@ -136,8 +128,6 @@ private function syncData(bool $toModel = false): void
$this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled;
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
$this->gpuDriver = $this->application->settings->gpu_driver;

View file

@ -150,7 +150,15 @@ public function mount(): void
public function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->loadDomainState();
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& ($row['service'] ?? null) === ($editingRow['service'] ?? null));
$this->editingIndex = $index === false ? null : (int) $index;
}
}
public function pollDnsChecks(): void
@ -227,7 +235,9 @@ public function loadDomainState(): void
$this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
$this->redirect = $this->application->redirect ?? 'both';
if ($this->pendingAction !== 'redirect' || $this->isCompose) {
$this->redirect = $this->application->redirect ?? 'both';
}
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings();
@ -254,6 +264,9 @@ public function loadDomainState(): void
}
$this->composeServices = [];
$pendingRedirect = $this->pendingRedirectService !== null
? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null)
: null;
$this->serviceRedirects = [];
if ($this->isCompose) {
try {
@ -290,7 +303,9 @@ public function loadDomainState(): void
$serviceEntry = $domains[$serviceName] ?? null;
$storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null;
$this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect(
is_string($storedRedirect) ? $storedRedirect : null
$this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService
? $pendingRedirect
: (is_string($storedRedirect) ? $storedRedirect : null)
);
}
}
@ -973,7 +988,13 @@ public function updatedShowDomainConflictModal(bool $value): void
return;
}
$this->authorize('update', $this->application);
$wasRedirect = $this->pendingAction === 'redirect';
$this->pendingAction = null;
$this->pendingRedirectService = null;
if ($wasRedirect) {
$this->refreshDomains();
}
}
public function addDomain(): void

View file

@ -19,6 +19,10 @@ class Previews extends Component
public Application $application;
public bool $isPreviewDeploymentsEnabled = false;
public bool $isPrDeploymentsPublicEnabled = false;
public string $deployment_uuid;
public array $parameters;
@ -41,11 +45,29 @@ class Previews extends Component
public function mount()
{
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->pull_requests = collect();
$this->parameters = get_route_parameters();
$this->syncDockerTags();
}
public function savePreviewSettings(): void
{
$this->authorize('update', $this->application);
$this->validate([
'isPreviewDeploymentsEnabled' => 'boolean',
'isPrDeploymentsPublicEnabled' => 'boolean',
]);
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->dispatch('configurationChanged');
}
private function syncDockerTags(): void
{
$this->previewDockerTags = [];

View file

@ -129,9 +129,17 @@ public function mount(): void
public function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->service->refresh();
$this->service->load(['applications', 'server']);
$this->loadDomainState();
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& (int) $row['service_application_id'] === (int) $editingRow['service_application_id']);
$this->editingIndex = $index === false ? null : (int) $index;
}
}
public function pollDnsChecks(): void
@ -239,9 +247,14 @@ public function loadDomainState(): void
])
->all();
$pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null;
$this->serviceRedirects = [];
foreach ($this->service->applications as $app) {
$this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null);
$this->serviceRedirects[$app->id] = $this->normalizeRedirect(
$this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId
? $pendingRedirect
: $app->redirect
);
}
if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) {
@ -924,6 +937,7 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
}
$toAdd = collect();
$portOverrides = $app->domain_port_overrides ?? [];
foreach ($current as $url) {
$counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true);
if ($counterpart === null) {
@ -940,6 +954,11 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
continue;
}
$port = $this->effectiveDomainInternalPort($url, $app);
if ($port['has_port_override']) {
$portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port'];
}
$knownHosts[$hostKey] = true;
$toAdd->push($counterpart);
}
@ -948,12 +967,13 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
return true;
}
$app->domain_port_overrides = $portOverrides ?: null;
$merged = $current->merge($toAdd)->unique()->values();
$this->pendingAction = 'redirect';
$this->pendingRedirectServiceApplicationId = $app->id;
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainListForApp($app, $merged)) {
// Counterparts inherit an existing port, so only domain conflicts need confirmation.
if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) {
return false;
}
@ -980,11 +1000,24 @@ public function confirmRemovePort(): void
return;
}
if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) {
$this->setServiceRedirect($this->pendingRedirectServiceApplicationId);
return;
}
$this->addDomain();
}
public function cancelRemovePort(): void
{
$this->authorize('update', $this->service);
if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) {
$app = $this->findServiceApp($this->pendingRedirectServiceApplicationId);
$this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect);
}
$this->pendingRedirectServiceApplicationId = null;
$this->showPortWarningModal = false;
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
@ -1421,6 +1454,7 @@ protected function saveDomainListForApp(
ServiceApplication $app,
Collection $domains,
bool $checkConflicts = true,
bool $checkPorts = true,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@ -1447,7 +1481,7 @@ protected function saveDomainListForApp(
}
}
if (! $this->forceRemovePort) {
if ($checkPorts && ! $this->forceRemovePort) {
$requiredPort = $app->getRequiredPort();
if ($requiredPort !== null && $domainString) {
$previousFqdn = $app->getOriginal('fqdn');

View file

@ -4396,3 +4396,63 @@ .command-palette-arch-badge {
.dark .command-palette-arch-badge {
color: #fcd34d;
}
/* Service domains prioritize public addresses; configuration lives in settings. */
#service-domains-section,
.domains-overview-container {
container: service-domains / inline-size;
}
.service-domains-overview-grid {
grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem;
column-gap: 0.75rem;
}
.data-table-row.service-domains-overview-grid {
padding-block: 0.5rem;
}
.service-domain-detail {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
font-size: 12px;
}
.service-domains-overview-grid > span:not(:first-child):not(:last-child) {
text-align: center;
}
.service-domain-detail-label {
display: none;
}
.service-domains-https .listbox-trigger {
min-width: 7rem;
}
@container service-domains (max-width: 980px) {
.data-table-header.service-domains-overview-grid {
display: none;
}
.data-table-row.service-domains-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.data-table-row.service-domains-overview-grid > :first-child {
grid-column: 1 / -1;
}
.service-domain-detail {
justify-content: space-between;
gap: 0.5rem;
}
.service-domain-detail-label {
display: inline;
color: var(--coollabs-fg-dim);
}
}

View file

@ -5,6 +5,8 @@
// appears when those fields differ from the last server snapshot — not on
// incidental component state (e.g. $wire.set from x-init, display-only props).
'targets' => null,
// Optional Alpine expression for drafts that survive unrelated server requests.
'dirty' => null,
])
{{-- Floating "unsaved changes" pill (bottom center). Reveals itself via
@ -40,7 +42,8 @@
window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset);
window.removeEventListener('resize', this.updateKeyboardInset);
},
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty"
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`"
@if ($dirty) x-bind:class="{ 'is-dirty': {{ $dirty }} }" @else wire:dirty.class="is-dirty" @endif
wire:loading.class="is-saving"
@keydown.enter.window="
if ($el.classList.contains('is-dirty') &&

View file

@ -55,7 +55,7 @@
@if ($application->git_based())
<x-application.settings-section id="advanced-deployment-section" title="Deployment"
helper="Automatic deployments and pull request previews.">
helper="Automatic deployments from Git webhooks.">
<div class="grid w-full gap-4 sm:grid-cols-2">
<x-forms.listbox id="isAutoDeployEnabled" label="Auto deploy" onChange="instantSave"
helper="Automatically deploy new commits based on Git webhooks."
@ -63,18 +63,6 @@
['value' => true, 'label' => 'Deploy on push (webhooks)'],
['value' => false, 'label' => 'Manual deployments only'],
]" :disabled="! $canUpdate" />
<x-forms.listbox id="isPreviewDeploymentsEnabled" label="Preview deployments" onChange="instantSave"
helper="Automatically deploy Preview Deployments for all opened PRs.<br><br>Closing a PR deletes its Preview Deployment."
:options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Deploy opened pull requests'],
]" :disabled="! $canUpdate" />
<x-forms.listbox id="isPrDeploymentsPublicEnabled" label="PR deployment access" onChange="instantSave"
helper="When public, anyone can trigger PR deployments. Otherwise fork PRs are blocked and only repository owners, members, and collaborators can trigger them."
:options="[
['value' => false, 'label' => 'Repository members only'],
['value' => true, 'label' => 'Public (fork PRs allowed)'],
]" :disabled="! $canUpdate || ! $isPreviewDeploymentsEnabled" />
</div>
</x-application.settings-section>

View file

@ -6,26 +6,31 @@
$composeDomainGroups = collect($domainRows)
->groupBy(fn ($row) => $row['service'] ?? '__unknown')
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
$helperText = $isCompose
? 'Manage domains for every service in this Docker Compose application.'
: 'Manage domains for this application.';
$hasHttpsDomains = collect($domainRows)->contains(
fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://')
);
@endphp
<div class="flex flex-col gap-4"
<div id="application-domains-section" class="domains-overview-container flex flex-col gap-4"
x-data="{
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
editingDomainBaseline: null,
get hasAddressChanges() {
return this.modalOpen && this.editingDomainBaseline !== null
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal && !$wire.showDomainConflictModal;
},
openEditDomain() {
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editingServiceLabel = $wire.editingService || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingDomainBaseline = null;
this.editingServiceLabel = '';
},
matchesDomainSearch(value) {
@ -40,56 +45,29 @@
@if ($hasDnsChecksInProgress)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
<x-application.settings-section id="domains-section" title="Domains">
@can('update', $application)
<x-slot:actions>
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Recheck DNS
</x-forms.button>
</x-slot:actions>
@endcan
@if ($labelsAreWritable)
<x-callout type="warning" title="Domains managed via labels" class="mb-4">
Container label readonly mode is disabled. Domains must be set in the Labels section on the General page.
</x-callout>
@endif
@if ($labelsAreWritable)
<x-callout type="warning" title="Domains managed via labels" class="mb-4">
Container label readonly mode is disabled. Domains must be set in the Labels section on the General page.
</x-callout>
@endif
@if ($isCompose && count($composeServices) === 0)
<x-callout type="info" title="No services">
No non-database services found in the Docker Compose file. Domains can only be assigned to application
services.
</x-callout>
@endif
@if ($isCompose && count($composeServices) === 0)
<x-callout type="info" title="No services">
No non-database services found in the Docker Compose file. Domains can only be assigned to application
services.
</x-callout>
@endif
@cannot('update', $application)
<x-callout type="danger" title="Insufficient permissions">
You don't have permission to manage domains. Contact your team administrator for access.
</x-callout>
@endcannot
<p class="text-sm text-neutral-500 dark:text-fg-dim">
{{ $helperText }}
</p>
@if ($hasHttpsDomains && ! $labelsAreWritable)
<div class="mt-4 max-w-md">
<x-forms.listbox canGate="update" :canResource="$application" id="isForceHttpsEnabled" label="Redirect HTTP to HTTPS"
onChange="updateForceHttps"
helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL."
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" :disabled="! auth()->user()->can('update', $application)" />
</div>
@endif
</x-application.settings-section>
@cannot('update', $application)
<x-callout type="danger" title="Insufficient permissions">
You don't have permission to manage domains. Contact your team administrator for access.
</x-callout>
@endcannot
{{-- Toolbar --}}
<div class="mt-2 flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<h2 id="domains-section">Domains</h2>
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
@ -98,7 +76,7 @@
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
@if ($isCompose && $composeDomainGroups->isNotEmpty())
@if ($hasRows)
<div class="relative w-full sm:w-64">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
@ -107,6 +85,10 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
</div>
@endif
@can('update', $application)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
<div class="relative shrink-0">
@include('livewire.project.shared.cloudflare-autoconfigure')
</div>
@ -118,7 +100,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
<button type="button"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Add
Add domain
</button>
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
@ -168,20 +150,46 @@ class="button button-highlighted">
</div>
</div>
@if ($hasHttpsDomains && ! $labelsAreWritable)
<div class="flex flex-wrap items-center justify-end gap-2 service-domains-https">
<label for="isForceHttpsEnabled-trigger" class="mb-0! text-[12px]!">Redirect HTTP to HTTPS</label>
<x-helper helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL." />
<div class="w-28 shrink-0">
<x-forms.listbox canGate="update" :canResource="$application" id="isForceHttpsEnabled"
onChange="updateForceHttps" portal
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" :disabled="! auth()->user()->can('update', $application)" />
</div>
</div>
@endif
{{-- Table / empty --}}
<div id="domains-table-section"
class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flush' : '' }} w-full">
@if ($hasRows)
<div class="data-table-header service-domains-overview-grid">
<span>Domain</span>
<span>Protocol redirect</span>
<span>Domain redirect</span>
<span>Internal port</span>
<span>Search indexing</span>
<span>DNS status</span>
<span class="text-right">Actions</span>
</div>
@endif
@if ($isCompose && count($composeServices) === 0 && ! $hasRows)
<x-empty size="sm" title="No services available"
description="No non-database services found in the Docker Compose file."
icon-name="globe" />
@elseif ($isCompose && $composeDomainGroups->isEmpty())
<x-empty size="sm" title="No domains configured"
description="Add your first domain with the + Add button above. Choose which service receives it."
description="Add your first domain with the Add domain button above. Choose which service receives it."
icon-name="globe" />
@elseif (! $hasRows)
<x-empty size="sm" title="No domains configured"
description="Add your first domain with the + Add button above, or generate one with the server wildcard domain."
description="Add your first domain with the Add domain button above, or generate one with the server wildcard domain."
icon-name="globe" />
@elseif ($isCompose)
@php
@ -213,36 +221,10 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">
{{ $serviceName }}
</span>
<div class="flex shrink-0 items-center gap-2">
<span class="hidden text-xs text-neutral-500 sm:inline dark:text-fg-dim">Direction</span>
@if (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
<x-forms.listbox id="domain-direction-service-{{ $redirectWireKey }}" :wire="false"
:value="$serviceRedirects[$redirectWireKey] ?? 'both'" preserveValue
onChange="updateServiceRedirect" :onChangeArgs="[$serviceName]" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
@else
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ match ($serviceRedirects[$redirectWireKey] ?? 'both') {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
} }}
</span>
@endif
</div>
</div>
<div wire:key="application-compose-domain-rows-{{ $redirectWireKey }}-{{ md5(serialize($rows->all())) }}"
<div wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"
class="data-table w-full">
<div class="data-table-header domains-table-grid-service">
<span>Domain</span>
<span>DNS Check</span>
<span class="whitespace-nowrap">Search engine indexing</span>
<span></span>
</div>
@foreach ($rows as $row)
@php
$index = collect($domainRows)->search(
@ -256,9 +238,7 @@ class="data-table w-full">
'row' => $row,
'application' => $application,
'labelsAreWritable' => $labelsAreWritable,
'isCompose' => false,
'showDirectionControl' => false,
'domainGridClass' => 'domains-table-grid-service',
'isCompose' => true,
])
@endforeach
</div>
@ -273,13 +253,6 @@ class="px-4 py-8">
</div>
@else
<div class="data-table w-full">
<div class="data-table-header domains-table-grid">
<span>Domain</span>
<span>DNS Check</span>
<span class="whitespace-nowrap">Search engine indexing</span>
<span>Direction</span>
<span></span>
</div>
@foreach ($domainRows as $index => $row)
@include('livewire.project.application.partials.domain-row', [
'index' => $index,
@ -290,10 +263,15 @@ class="px-4 py-8">
])
@endforeach
</div>
<div x-cloak x-show="domainSearch.trim() && !hasDomainSearchResults(@js(collect($domainRows)->pluck('url')->values()))"
class="px-4 py-8">
<x-empty size="sm" title="No domains found"
description="No domain matches your search." icon-name="search" />
</div>
@endif
</div>
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
{{-- One dialog for address edits and automatically saved domain settings. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
@ -315,7 +293,7 @@ class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<h3 class="min-w-0 flex-1 truncate">Domain settings</h3>
<button type="button" @click="closeEditDomain()"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
@ -324,6 +302,10 @@ class="icon-button shrink-0" aria-label="Close">
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="modalOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
targets="updateDomain,confirmUpdateDomainDespiteDns" />
</template>
<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>
@ -344,19 +326,47 @@ class="icon-button shrink-0" aria-label="Close">
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
wire:click="confirmUpdateDomainDespiteDns">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>
Save
</x-forms.button>
@endif
</div>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">Continue</x-forms.button>
@endif
</form>
@php
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
@endphp
@if ($editingRow && ! $labelsAreWritable)
@can('update', $application)
@php
$editingKey = hash('sha256', $editingRow['url'].'|'.($editingRow['service'] ?? ''));
$editingRedirectKey = $isCompose ? $this->serviceRedirectWireKey($editingRow['service']) : null;
$editingRedirectProperty = $isCompose ? 'serviceRedirects.'.$editingRedirectKey : 'redirect';
@endphp
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
<x-forms.listbox id="application-domain-indexing-{{ $editingKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$application->isDomainNoindexed($editingRow['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain" :onChangeArgs="[$editingRow['url']]" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="application-domain-direction-{{ $editingKey }}"
label="www redirect" :wire="false" preserveValue
:value="$isCompose ? ($serviceRedirects[$editingRedirectKey] ?? 'both') : $redirect"
:x-effect="'value = $wire.get('.json_encode($editingRedirectProperty).')'"
:helper="$isCompose ? 'Applies to all domains for this Compose service.' : 'Applies to all domains for this application.'"
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
:onChangeArgs="$isCompose ? [$editingRow['service']] : []" portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@endcan
@endif
</div>
</div>
</div>

View file

@ -7,49 +7,35 @@
default => 'neutral',
};
$dnsLabel = match ($row['dns_status']) {
'ok' => 'DNS OK',
'ok' => 'DNS matches',
'failed' => 'DNS mismatch',
'skipped' => 'DNS skipped',
'checking' => 'Checking DNS...',
'pending' => 'DNS pending',
'pending' => 'Not checked',
default => 'DNS unknown',
};
$gridClass = $domainGridClass ?? (($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid');
$domainParts = $isSuggested ? null : parse_url($row['url']);
$gridClass = 'service-domains-overview-grid';
$publicUrl = getFqdnWithoutPort($row['url']);
$domainParts = $isSuggested ? null : parse_url($publicUrl);
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
? $domainParts['scheme'].'://'.$domainParts['host'].'/favicon.ico'
: null;
$redirectPairKey = function (string $url): string {
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['host'])) {
return $url;
}
$host = preg_replace('/^www\./i', '', $parts['host']);
return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? ''));
};
$pairKey = $redirectPairKey($row['url']);
$firstPairRowIndex = collect($domainRows)
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
->filter(fn ($item) => ($item['service'] ?? null) === ($row['service'] ?? null))
->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey)
->keys()
->first();
$showDirection = ($showDirectionControl ?? true) && ! $isSuggested && $firstPairRowIndex === $index;
$rowDirection = $isCompose
? ($serviceRedirects[$this->serviceRedirectWireKey($row['service'])] ?? 'both')
: $redirect;
$isNoindexed = $application->isDomainNoindexed($row['url']);
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
@endphp
<div wire:key="domain-row-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
class="env-table-item">
x-show="matchesDomainSearch(@js(($row['service'] ?? '').' '.$row['url']))" class="env-table-item">
<div @class([
'data-table-row',
$gridClass,
'domains-row-suggested' => $isSuggested,
'domains-row-without-direction' => ! $showDirection,
])>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<div class="flex min-w-0 items-center gap-2">
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
@ -69,29 +55,15 @@ class="domain-favicon-fallback size-4 text-neutral-400 dark:text-fg-faint" />
class="invisible absolute inset-0 size-4 rounded-sm" />
</span>
@endif
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
<a href="{{ $publicUrl }}" target="_blank" rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $publicUrl }}">
{{ $publicUrl }}
</a>
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
<span class="table-badge shrink-0"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
Internal port {{ $row['internal_port'] }}
</span>
@else
<span class="table-badge table-badge-danger shrink-0"
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
No internal port
</span>
@endif
@endif
@if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif
@if ($isCompose ?? false)
<span class="domains-service-mobile table-badge shrink-0">{{ $row['service'] ?? '-' }}</span>
@endif
</div>
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
@ -101,12 +73,32 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
@endif
</div>
@if ($isCompose ?? false)
<div class="domains-service-desktop min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
title="{{ $row['service'] ?? '' }}">
{{ $row['service'] ?? '-' }}
</div>
@endif
<div class="service-domain-detail" title="Protocol redirect">
<span class="service-domain-detail-label">Protocol redirect</span>
<span>{{ str_starts_with($row['url'], 'https://') && $isForceHttpsEnabled ? 'HTTP → HTTPS' : 'Disabled' }}</span>
</div>
<div class="service-domain-detail" title="Domain redirect">
<span class="service-domain-detail-label">Domain redirect</span>
<span>{{ match ($rowDirection) { 'www' => 'non-www → www', 'non-www' => 'www → non-www', default => 'Disabled' } }}</span>
</div>
<div class="service-domain-detail"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the application or Compose service port' }}">
<span class="service-domain-detail-label">Internal port</span>
@if (filled($row['internal_port'] ?? null))
<span aria-label="Internal port {{ $row['internal_port'] }}">{{ $row['internal_port'] }}</span>
@else
<span role="img" aria-label="No internal port" title="No internal port. Set Ports Exposes or a per-domain internal port so the proxy can route this domain." class="text-red-500 dark:text-red-400">
<x-reicon name="alert-triangle" class="size-4" />
</span>
@endif
</div>
<div class="service-domain-detail">
<span class="service-domain-detail-label">Search indexing</span>
<span role="img" aria-label="{{ $isNoindexed ? 'Search indexing blocked' : 'Search indexing allowed' }}"
title="{{ $isNoindexed ? 'Search indexing blocked' : 'Search indexing allowed' }}">
<x-reicon :name="$isNoindexed ? 'x' : 'check'" class="size-4" />
</span>
</div>
<div class="flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
@ -118,55 +110,6 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
@endif
</div>
<div class="min-w-0" title="Search engine indexing">
@unless ($isSuggested)
<span class="domains-mobile-label">Search engine indexing</span>
@endunless
@if ($isSuggested)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
@elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
<x-forms.listbox id="domain-indexing-{{ $index }}" :wire="false"
preserveValue
:value="$application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain" :onChangeArgs="[$row['url']]" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
@else
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $application->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }}
</span>
@endif
</div>
@if ($showDirectionControl ?? true)
<div class="min-w-0" title="Direction">
@php
$rowDirection = $domainDirection ?? $redirect;
$directionLabel = match ($rowDirection) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
@if ($showDirection)
<span class="domains-mobile-label">Direction</span>
@endif
@if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable)
<x-forms.listbox id="domain-direction-{{ $index }}" :wire="false" :value="$rowDirection"
preserveValue
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
:onChangeArgs="$isCompose ? [$row['service']] : []" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
@elseif ($showDirection)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
@endif
</div>
@endif
<div class="flex items-center justify-end gap-1">
@can('update', $application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
@ -193,7 +136,7 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
@else
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain">
title="Domain settings" aria-label="Settings for {{ $publicUrl }}">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?" buttonTitle="Remove"

View file

@ -1,4 +1,27 @@
<div class="flex flex-col gap-6">
@if ($application->git_based())
@php
$canUpdate = auth()->user()->can('update', $application);
@endphp
<x-application.settings-section id="preview-settings-section" title="Preview settings"
helper="Automatic pull request deployments and who can trigger them.">
<div class="grid w-full gap-4 sm:grid-cols-2">
<x-forms.listbox id="isPreviewDeploymentsEnabled" label="Preview deployments" onChange="savePreviewSettings"
helper="Automatically deploy Preview Deployments for all opened PRs.<br><br>Closing a PR deletes its Preview Deployment."
:options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Deploy opened pull requests'],
]" :disabled="! $canUpdate" />
<x-forms.listbox id="isPrDeploymentsPublicEnabled" label="PR deployment access" onChange="savePreviewSettings"
helper="When public, anyone can trigger PR deployments. Otherwise fork PRs are blocked and only repository owners, members, and collaborators can trigger them."
:options="[
['value' => false, 'label' => 'Repository members only'],
['value' => true, 'label' => 'Public (fork PRs allowed)'],
]" :disabled="! $canUpdate || ! $isPreviewDeploymentsEnabled" />
</div>
</x-application.settings-section>
@endif
<livewire:project.application.preview.form :application="$application" />
@if (count($application->additional_servers) > 0)

View file

@ -15,18 +15,26 @@
})->values();
@endphp
<div class="flex flex-col gap-4"
<div id="service-domains-section" class="flex flex-col gap-4"
x-data="{
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: '',
editingDomainBaseline: null,
get hasAddressChanges() {
return this.modalOpen && this.editingDomainBaseline !== null
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal && !$wire.showDomainConflictModal;
},
openEditDomain() {
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editingServiceLabel = $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingDomainBaseline = null;
this.editingServiceLabel = '';
},
matchesDomainSearch(value) {
@ -41,37 +49,23 @@
@if ($hasDnsChecksInProgress)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
<x-application.settings-section id="service-domains-section" title="Domains">
@can('update', $service)
<x-slot:actions>
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Recheck DNS
</x-forms.button>
</x-slot:actions>
@endcan
@cannot('update', $service)
<x-callout type="danger" title="Insufficient permissions">
You don't have permission to manage domains. Contact your team administrator for access.
</x-callout>
@endcannot
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Manage domains and www/non-www redirects for applications in this stack.
</p>
</x-application.settings-section>
@cannot('update', $service)
<x-callout type="danger" title="Insufficient permissions">
You don't have permission to manage domains. Contact your team administrator for access.
</x-callout>
@endcannot
{{-- Toolbar --}}
<div class="mt-2 flex flex-wrap items-center gap-2">
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} not added
@endif
</p>
<div class="min-w-0 flex-1">
<h3>Domains</h3>
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} across {{ $domainGroups->count() }} service{{ $domainGroups->count() === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} not added
@endif
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
@if ($domainGroups->isNotEmpty())
<div class="relative w-full sm:w-64">
@ -82,6 +76,13 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
</div>
@endif
@can('update', $service)
@if ($configuredCount > 0)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
@endif
@if ($serviceAppCount > 0)
<div class="relative shrink-0">
@include('livewire.project.shared.cloudflare-autoconfigure')
@ -92,12 +93,12 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
<button type="button"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Add
Add domain
</button>
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
{{-- Always show which service receives the domain --}}
<x-forms.listbox canGate="update" :canResource="$service" label="Service application" id="newServiceApplicationId" required
<x-forms.listbox canGate="update" :canResource="$service" label="Service application" id="newServiceApplicationId" required portal
helper="Domain will be assigned to this compose service application."
:options="collect($serviceApps)->map(fn ($app) => [
'value' => $app['id'],
@ -153,12 +154,21 @@ class="button button-highlighted">
@elseif (! $hasRows)
<div class="application-settings-section-body mt-1 w-full scroll-mt-28">
<x-empty size="sm" title="No domains configured"
description="Add your first domain with the + Add button above. Choose which service application receives it."
description="Add your first domain with the Add domain button above. Choose which service application receives it."
icon-name="globe" />
</div>
@else
<div wire:key="service-domains-list"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible">
<div class="data-table-header service-domains-overview-grid">
<span>Domain</span>
<span>Protocol redirect</span>
<span>Domain redirect</span>
<span>Internal port</span>
<span>Search indexing</span>
<span>DNS status</span>
<span class="text-right">Actions</span>
</div>
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
@ -173,12 +183,13 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
<div class="flex w-full flex-wrap items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
@if ($hasHttpsDomains)
<div class="w-full sm:w-72">
<div class="flex w-full items-center gap-2 sm:w-auto service-domains-https">
<label for="service-force-https-{{ $appId }}-trigger" class="mb-0! whitespace-nowrap text-[12px]!">Redirect HTTP to HTTPS</label>
<x-helper helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL." />
<x-forms.listbox canGate="update" :canResource="$service" id="forceHttpsRedirects.{{ $appId }}"
htmlId="service-force-https-{{ $appId }}"
label="Redirect HTTP to HTTPS" onChange="updateForceHttps"
htmlId="service-force-https-{{ $appId }}" preserveValue
onChange="updateForceHttps"
:onChangeArgs="[(int) $appId]"
helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL."
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
@ -187,13 +198,13 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
@endif
</div>
<div wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}">
<div wire:key="service-domain-rows-{{ $appId }}">
@include('livewire.project.service.partials.domain-table', [
'rows' => $rows,
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => false,
'showHeader' => true,
'showHeader' => false,
])
</div>
</section>
@ -207,7 +218,7 @@ class="px-4 py-8">
</div>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
{{-- One dialog for the address and domain settings. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
@ -229,7 +240,7 @@ class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<h3 class="min-w-0 flex-1 truncate">Domain settings</h3>
<button type="button" @click="closeEditDomain()" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
@ -238,6 +249,10 @@ class="application-settings-form application-settings-section relative flex max-
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="modalOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
targets="updateDomain,confirmUpdateDomainDespiteDns" />
</template>
<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>
@ -261,19 +276,50 @@ class="application-settings-form application-settings-section relative flex max-
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
wire:click="confirmUpdateDomainDespiteDns">
@if ($editDomainDnsFailed)
<div class="flex justify-end">
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>
Save
</x-forms.button>
@endif
</div>
</div>
@endif
</form>
@php
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
@endphp
@if ($editingRow)
@can('update', $service)
@php
$editingAppId = (int) $editingRow['service_application_id'];
$editingDomainKey = hash('sha256', $editingRow['url'].'|'.$editingAppId);
$editingNoindex = $service->applications->firstWhere('id', $editingAppId)?->isDomainNoindexed($editingRow['url']);
@endphp
<div wire:key="editing-domain-settings-{{ $editingDomainKey }}"
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
<x-forms.listbox id="service-domain-indexing-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$editingNoindex ? 'noindex' : 'index'"
onChange="toggleNoindexDomain"
:onChangeArgs="[$editingAppId, $editingRow['url']]" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="service-domain-direction-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="www redirect" :wire="false" :value="$serviceRedirects[$editingAppId] ?? 'both'" preserveValue
x-effect="value = $wire.serviceRedirects[{{ $editingAppId }}] ?? 'both'"
helper="Applies to all domains for this service application."
onChange="updateServiceRedirect" :onChangeArgs="[$editingAppId]" portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@endcan
@endif
</div>
</div>
</div>
@ -311,7 +357,8 @@ class="icon-button" aria-label="Close">
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button type="button"
@click="modalOpen = false; $wire.call('cancelRemovePort')">
wire:click="cancelRemovePort"
@click="modalOpen = false">
Keep required port
</x-forms.button>
<x-forms.button type="button" wire:click="confirmRemovePort"

View file

@ -1,7 +1,7 @@
@php
$showServiceColumn = $showServiceColumn ?? false;
$showHeader = $showHeader ?? true;
$gridClass = $showServiceColumn ? 'domains-table-grid-compose' : 'domains-table-grid';
$gridClass = 'service-domains-overview-grid';
@endphp
<div class="data-table w-full">
@ -11,10 +11,12 @@
@if ($showServiceColumn)
<span>Service</span>
@endif
<span>DNS Check</span>
<span class="whitespace-nowrap">Search engine indexing</span>
<span>Direction</span>
<span></span>
<span>Protocol redirect</span>
<span>Domain redirect</span>
<span>Internal port</span>
<span>Search indexing</span>
<span>DNS status</span>
<span class="text-right">Actions</span>
</div>
@endif
@foreach ($rows as $row)
@ -32,35 +34,28 @@
default => 'neutral',
};
$dnsLabel = match ($row['dns_status']) {
'ok' => 'DNS OK',
'ok' => 'DNS matches',
'failed' => 'DNS mismatch',
'skipped' => 'DNS skipped',
'checking' => 'Checking DNS...',
'pending' => 'DNS pending',
'pending' => 'Not checked',
default => 'DNS unknown',
};
$serviceLabel = filled($row['service_name'] ?? null)
? \Illuminate\Support\Str::headline($row['service_name'])
: '-';
$domainParts = $isSuggested ? null : parse_url($row['url']);
$publicUrl = getFqdnWithoutPort($row['url']);
$domainParts = $isSuggested ? null : parse_url($publicUrl);
$isNoindexed = $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']);
$rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both';
$directionLabel = match ($rowDirection) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Both www and non-www',
};
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
: null;
$redirectPairKey = function (string $url): string {
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['host'])) {
return $url;
}
$host = preg_replace('/^www\./i', '', $parts['host']);
return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? ''));
};
$pairKey = $redirectPairKey($row['url']);
$firstPairRowUrl = collect($rows)
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
->first(fn ($item) => $redirectPairKey($item['url']) === $pairKey)['url'] ?? null;
$showDirection = ! $isSuggested && $firstPairRowUrl === $row['url'];
$domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? ''));
@endphp
@ -70,10 +65,9 @@ class="env-table-item">
'data-table-row',
$gridClass,
'domains-row-suggested' => $isSuggested,
'domains-row-without-direction' => ! $showDirection,
])>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<div class="flex min-w-0 items-center gap-2">
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
@ -93,17 +87,11 @@ class="domain-favicon-fallback size-4 text-neutral-400 dark:text-fg-faint" />
class="invisible absolute inset-0 size-4 rounded-sm" />
</span>
@endif
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
<a href="{{ $publicUrl }}" target="_blank" rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs [overflow-wrap:anywhere] dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $publicUrl }}">
{{ $publicUrl }}
</a>
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
<span class="table-badge shrink-0"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the Coolify service port' }}">
Internal port {{ $row['internal_port'] }}
</span>
@endif
@endif
@if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@ -124,6 +112,27 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
</div>
@endif
<div class="service-domain-detail" title="Protocol redirect">
<span class="service-domain-detail-label">Protocol redirect</span>
<span>{{ str_starts_with($row['url'], 'https://') && ($forceHttpsRedirects[$row['service_application_id']] ?? true) ? 'HTTP → HTTPS' : 'Disabled' }}</span>
</div>
<div class="service-domain-detail" title="{{ $directionLabel }}">
<span class="service-domain-detail-label">Domain redirect</span>
<span>{{ match ($rowDirection) { 'www' => 'non-www → www', 'non-www' => 'www → non-www', default => 'Disabled' } }}</span>
</div>
<div class="service-domain-detail"
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the Coolify service port' }}">
<span class="service-domain-detail-label">Internal port</span>
<span @if (filled($row['internal_port'] ?? null)) aria-label="Internal port {{ $row['internal_port'] }}" @endif>{{ $row['internal_port'] ?? '—' }}</span>
</div>
<div class="service-domain-detail">
<span class="service-domain-detail-label">Search indexing</span>
<span role="img" aria-label="{{ $isNoindexed ? 'Search indexing blocked' : 'Search indexing allowed' }}"
title="{{ $isNoindexed ? 'Search indexing blocked' : 'Search indexing allowed' }}">
<x-reicon :name="$isNoindexed ? 'x' : 'check'" class="size-4" />
</span>
</div>
<div class="flex min-w-0 items-center">
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
@ -134,54 +143,6 @@ class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 un
@endif
</div>
<div class="min-w-0">
@unless ($isSuggested)
<span class="domains-mobile-label">Search engine indexing</span>
@endunless
@if ($isSuggested)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
@elseif (auth()->user()?->can('update', $service))
<x-forms.listbox id="service-domain-indexing-{{ $row['service_application_id'] }}-{{ $index }}"
:wire="false"
preserveValue
:value="$service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain"
:onChangeArgs="[(int) $row['service_application_id'], $row['url']]" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
@else
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }}
</span>
@endif
</div>
<div class="min-w-0">
@php
$rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both';
$directionLabel = match ($rowDirection) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
@if ($showDirection)
<span class="domains-mobile-label">Direction</span>
@endif
@if ($showDirection && auth()->user()?->can('update', $service))
<x-forms.listbox id="service-domain-direction-{{ $row['service_application_id'] }}-{{ $index }}"
:wire="false" :value="$rowDirection" preserveValue onChange="updateServiceRedirect"
:onChangeArgs="[(int) $row['service_application_id']]" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
@elseif ($showDirection)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
@endif
</div>
<div class="flex items-center justify-end gap-1">
@can('update', $service)
<button type="button" wire:click="checkDomainDns({{ $index }})"
@ -209,8 +170,8 @@ class="h-7! shrink-0 px-2.5! text-[12px]!">
</x-forms.button>
@endif
@else
<button type="button" wire:click="startEdit({{ $index }})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<button type="button" class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ $publicUrl }}"
wire:click="startEdit({{ $index }})" wire:loading.attr="disabled" wire:target="startEdit">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"

View file

@ -618,10 +618,10 @@
->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false)
->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false)
->assertSee('x-on:error="$el.remove()"', false)
->assertSee('class="min-w-0 flex-1 text-[13px]', false)
->assertSee('class="min-w-0 flex-1 truncate text-[13px]', false)
->html();
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0);
});
it('shows the HTTP redirect control for HTTPS domains and persists changes', function () {
@ -645,7 +645,7 @@
->assertDontSee('Redirect HTTP to HTTPS');
});
it('shows one redirect direction control in each compose service header', function () {
it('shows the compose service redirect control in domain settings', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n",
@ -660,11 +660,13 @@
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSuccessful()
->assertSee('api')
->call('startEdit', 0)
->assertSee('www redirect')
->html();
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0);
->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0);
});
it('shows dns entries control next to Add', function () {
@ -805,7 +807,7 @@
it('adds a domain to the application', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('+ Add')
->assertSee('Add domain')
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
@ -909,7 +911,7 @@
->call('startEdit', 0)
->assertSet('showEditDomainModal', true)
->assertSet('editingDomain', 'https://old.example.com')
->assertSee('Direction')
->assertSee('www redirect')
->assertSee('Search engine indexing')
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'new.example.com')
@ -1287,7 +1289,7 @@
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.dns_status', 'ok')
->assertSee('DNS OK')
->assertSee('DNS matches')
->assertDontSee('DNS points to 203.0.113.10')
->assertDontSee('Last checked');
});
@ -1886,33 +1888,33 @@
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
->toContain('class="data-table-header domains-table-grid-service"')
->toContain('<span>Direction</span>')
->toContain('<span class="whitespace-nowrap">Search engine indexing</span>')
->toContain('class="data-table-header service-domains-overview-grid"')
->toContain('<span>Domain redirect</span>')
->toContain('<span>Search indexing</span>')
->not->toContain('<span>Last checked</span>')
->not->toContain('id="edit-domain-direction"')
->toContain('id="domain-direction-service-{{ $redirectWireKey }}"')
->toContain('onChange="updateServiceRedirect"')
->toContain("'showDirectionControl' => false")
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
->toContain('id="application-domain-direction-{{ $editingKey }}"')
->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
->not->toContain('title="No domains for this service"');
});
it('keeps search engine indexing table headers on one line', function () {
it('uses concise search indexing headers in application and service domain tables', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect(substr_count($applicationView, '<span class="whitespace-nowrap">Search engine indexing</span>'))
->toBe(2)
expect(substr_count($applicationView, '<span>Search indexing</span>'))
->toBe(1)
->and($serviceView)
->toContain('<span class="whitespace-nowrap">Search engine indexing</span>');
->toContain('<span>Search indexing</span>');
});
it('shows domain guidance in the application domains section', function () {
it('shows save guidance in the application domain settings', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->toContain('<p class="text-sm text-neutral-500 dark:text-fg-dim">')
->toContain('{{ $helperText }}');
->toContain('Indexing and redirect changes save automatically.')
->toContain('<x-unsaved-bar action="updateDomain"');
});
it('does not render a last checked column in the domains table', function () {
@ -1928,13 +1930,13 @@
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($styles)
->toContain('@media (max-width: 768px)')
->toContain('.domains-mobile-label')
->toContain('.domains-table-grid .listbox-trigger')
->toContain('@container service-domains (max-width: 980px)')
->toContain('.service-domain-detail-label')
->toContain('.service-domains-overview-grid')
->and($row)
->toContain('domains-mobile-label')
->toContain('Search engine indexing')
->toContain('Direction');
->toContain('service-domain-detail-label')
->toContain('Search indexing')
->toContain('Domain redirect');
});
it('uses segmented fields when adding and editing application domains', function () {
@ -2132,10 +2134,11 @@
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('Search engine indexing')
->assertSee('Direction')
->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateRedirect', false)
->assertSee('wire:ignore', false)
@ -2376,7 +2379,7 @@
->not->toContain('Inherited from Ports Exposes');
});
it('shows an error badge when a domain has no internal port and ports exposes is empty', function () {
it('shows a warning when a domain has no internal port and ports exposes is empty', function () {
$this->application->update([
'ports_exposes' => null,
'fqdn' => 'https://example.com',
@ -2387,7 +2390,8 @@
->assertSet('domainRows.0.internal_port', null)
->assertSee('No internal port')
->assertDontSee('Internal port ')
->assertSee('table-badge-danger', false);
->assertSee('aria-label="No internal port"', false)
->assertSee('Set Ports Exposes or a per-domain internal port', false);
});
it('keeps the internal port badge when a domain override exists without ports exposes', function () {
@ -2413,7 +2417,7 @@
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 3000')
->assertSee('Inherited from Ports Exposes', false)
->assertSee('Inherited from the application or Compose service port', false)
->assertDontSee('Custom internal port for this domain', false);
});
@ -2887,3 +2891,141 @@ function applicationDomainPortOverrideApiToken(User $user, Team $team): string
expect($this->application->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070);
});
it('keeps the selected application domain when a refresh reorders dns rows', function (bool $compose) {
$first = 'https://first.example.com';
$second = 'https://second.example.com';
$this->application->update($compose ? [
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [80]\n",
'docker_compose_domains' => json_encode(['web' => ['domain' => "$first,$second"]]),
] : ['fqdn' => "$first,$second"]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingDomainParts.host', 'renamed.example.com');
$this->application->update(['domain_dns_statuses' => [
($compose ? 'web|' : '').$second => ['status' => 'failed', 'message' => 'Mismatch'],
]]);
$component->call('refreshDomains')
->assertSet('editingIndex', 1)
->assertSet('editingDomainParts.host', 'renamed.example.com')
->call('toggleNoindexDomain', $first, 'noindex')
->call('updateDomain')
->assertHasNoErrors();
$this->application->refresh();
$domains = $compose ? json_decode($this->application->docker_compose_domains, true)['web']['domain'] : $this->application->fqdn;
expect(explode(',', $domains))->toBe(['https://renamed.example.com', $second]);
})->with([false, true]);
it('inherits application counterpart ports without changing configured counterparts', function (bool $compose, ?int $override, string $redirect) {
$host = $redirect === 'www' ? 'app.example.com' : 'www.app.example.com';
$counterpart = $redirect === 'www' ? 'www.app.example.com' : 'app.example.com';
$url = "https://$host/blog";
$pairedUrl = "https://$counterpart/blog";
$existing = 'http://existing.example.com,https://www.existing.example.com';
$this->application->update(array_merge([
'ports_exposes' => '80',
'domain_port_overrides' => array_filter([
$url => $override,
'https://www.existing.example.com' => 9090,
], fn ($port) => $port !== null),
], $compose ? [
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [80]\n",
'docker_compose_domains' => json_encode(['web' => ['domain' => "$url,$existing"]]),
] : ['fqdn' => "$url,$existing"]));
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
if ($compose) {
$component->call('updateServiceRedirect', 'web', $redirect);
} else {
$component->call('updateRedirect', $redirect);
}
$component->assertHasNoErrors()->assertSet('showPortWarningModal', false);
$this->application->refresh();
$domains = $compose ? json_decode($this->application->docker_compose_domains, true)['web']['domain'] : $this->application->fqdn;
expect($domains)->toContain($pairedUrl)->toContain($existing)
->and($this->application->domain_port_overrides[$pairedUrl] ?? 80)->toBe($override ?? 80)
->and($this->application->domain_port_overrides[$url] ?? null)->toBe($override)
->and($this->application->domain_port_overrides['https://www.existing.example.com'])->toBe(9090);
})->with([false, true])->with([null, 8080])->with(['www', 'non-www']);
it('preserves pending application redirect through refresh and resolves its domain conflict', function (bool $compose, bool $cancel) {
Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://www.pending.example.com',
'build_pack' => 'nixpacks',
]);
$url = 'https://pending.example.com';
$this->application->update(array_merge([
'domain_port_overrides' => [$url => 8080],
], $compose ? [
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web.app:\n image: nginx:alpine\n expose: [80]\n",
'docker_compose_domains' => json_encode([
'web.app' => ['domain' => $url, 'redirect' => 'both'],
'occupied' => ['domain' => 'https://www.pending.example.com'],
]),
] : ['fqdn' => $url]));
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
$property = $compose ? 'serviceRedirects.'.str_replace('.', '__dot__', 'web.app') : 'redirect';
if ($compose) {
$component->call('updateServiceRedirect', 'web.app', 'www');
} else {
$component->call('updateRedirect', 'www');
}
$component->assertSet('showDomainConflictModal', true)
->call('refreshDomains')
->assertSet($property, 'www');
if ($cancel) {
$component->set('showDomainConflictModal', false)
->assertSet($property, 'both')
->assertSet('pendingAction', null)
->assertSet('pendingRedirectService', null);
} else {
$component->call('confirmDomainUsage')
->assertHasNoErrors()
->assertSet('pendingAction', null)
->assertSet('pendingRedirectService', null)
->assertSet($property, 'www');
}
$this->application->refresh();
$storedRedirect = $compose ? json_decode($this->application->docker_compose_domains, true)['web.app']['redirect'] : $this->application->redirect;
expect($storedRedirect)->toBe($cancel ? 'both' : 'www');
if (! $cancel) {
expect($this->application->domain_port_overrides['https://www.pending.example.com'] ?? null)->toBe(8080);
}
})->with([false, true])->with([false, true]);
it('keeps the selected application domain when removing an earlier row', function () {
$this->application->update(['fqdn' => 'https://first.example.com,https://second.example.com']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 1)
->call('removeDomain', 0)
->assertSet('editingIndex', 0)
->set('editingDomainParts.host', 'renamed.example.com')
->call('updateDomain')
->assertHasNoErrors();
expect($this->application->fresh()->fqdn)->toBe('https://renamed.example.com');
});
it('prevents members from cancelling protected application redirect conflict state', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->actingAs($this->user->fresh());
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('showDomainConflictModal', true)
->set('showDomainConflictModal', false)
->assertForbidden();
});

View file

@ -0,0 +1,139 @@
<?php
use App\Livewire\Project\Application\Previews;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
[
'id' => 0,
'is_dns_validation_enabled' => false,
]
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$key = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]));
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $key->id,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Preview App',
'preview_url_template' => '{{pr_id}}.{{domain}}',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'redirect' => 'both',
'build_pack' => 'nixpacks',
]);
$this->application->settings()->update([
'is_container_label_readonly_enabled' => true,
]);
});
it('shows preview settings and persists changes independently of preview inputs', function () {
Livewire::test(Previews::class, ['application' => $this->application])
->assertSee('PR deployment access')
->assertSet('isPreviewDeploymentsEnabled', false)
->set('manualPullRequestId', -1)
->set('isPreviewDeploymentsEnabled', true)
->set('isPrDeploymentsPublicEnabled', true)
->call('savePreviewSettings')
->assertHasNoErrors()
->assertDispatched('success');
expect($this->application->fresh()->settings)
->is_preview_deployments_enabled->toBeTrue()
->is_pr_deployments_public_enabled->toBeTrue();
Livewire::test(Previews::class, ['application' => $this->application->fresh()])
->assertSet('isPreviewDeploymentsEnabled', true)
->assertSet('isPrDeploymentsPublicEnabled', true)
->set('isPreviewDeploymentsEnabled', false)
->call('savePreviewSettings');
expect($this->application->fresh()->settings->is_preview_deployments_enabled)->toBeFalse();
});
it('does not show git preview settings for non-git applications', function (string $buildPack, ?string $dockerfile) {
$this->application->update(['build_pack' => $buildPack, 'dockerfile' => $dockerfile]);
Livewire::test(Previews::class, ['application' => $this->application->fresh()])
->assertDontSee('PR deployment access');
})->with([['dockerimage', null], ['dockerfile', 'FROM nginx']]);
it('denies preview setting changes without application update permission', function (string $role, bool $otherTeam) {
$user = User::factory()->create();
$team = $otherTeam ? Team::factory()->create() : $this->team;
$team->members()->attach($user->id, ['role' => $role]);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test(Previews::class, ['application' => $this->application])
->set('isPreviewDeploymentsEnabled', true)
->set('isPrDeploymentsPublicEnabled', true)
->call('savePreviewSettings')
->assertForbidden();
expect($this->application->fresh()->settings)
->is_preview_deployments_enabled->toBeFalse()
->is_pr_deployments_public_enabled->toBeFalse();
})->with([['member', false], ['owner', true]]);
it('removes preview settings from Advanced including its persistence path', function () {
expect(file_get_contents(resource_path('views/livewire/project/application/advanced.blade.php')))
->not->toContain('isPreviewDeploymentsEnabled', 'isPrDeploymentsPublicEnabled');
expect(file_get_contents(app_path('Livewire/Project/Application/Advanced.php')))
->not->toContain('is_preview_deployments_enabled', 'is_pr_deployments_public_enabled');
});

View file

@ -12,6 +12,7 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
@ -20,6 +21,7 @@
beforeEach(function () {
$this->withoutVite();
config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
@ -140,24 +142,22 @@
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"")
->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"")
->toContain('src="https://api.example.com/favicon.ico"')
->toContain('class="relative size-4 shrink-0"')
->toContain('domain-favicon-fallback')
->toContain('class="invisible absolute inset-0 size-4 rounded-sm"')
->toContain('$el.previousElementSibling.classList.add(\'hidden\')')
->toContain('x-on:error="$el.remove()"')
->toContain('class="min-w-0 flex-1 text-[13px]')
->toContain('class="min-w-0 flex-1 truncate text-[13px]')
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->toContain('dark:bg-white/[0.04]')
->toContain('<span>Domain</span>')
->toContain('<span>DNS Check</span>')
->toContain('<span>DNS status</span>')
->not->toContain('<span>Last checked</span>')
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
->and(substr_count($html, '<span>Domain</span>'))->toBe(1)
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
@ -194,16 +194,25 @@
->assertDontSee('Redirect HTTP to HTTPS');
});
it('shows one redirect control for each www and non-www pair', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com,https://www.api.example.com,https://admin.example.com,https://www.admin.example.com',
]);
it('opens address fields and service-wide redirects in the same settings dialog for every domain', function () {
$domains = ['https://api.example.com', 'https://www.api.example.com', 'https://admin.example.com', 'https://www.admin.example.com'];
$this->apiApp->update(['fqdn' => implode(',', $domains)]);
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSuccessful()
->html();
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]);
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(2);
foreach ($domains as $index => $domain) {
$html = $component->call('startEdit', $index)
->assertSet('editingDomain', $domain)
->assertSee('Domain settings')
->assertSee('Save changes')
->assertDontSee('Save address')
->assertSee('Search engine indexing')
->assertSee('www redirect')
->assertDontSee('Edit address and port')
->html();
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1);
}
});
it('uses segmented fields when adding and editing service domains', function () {
@ -246,12 +255,12 @@
->assertSee('Manual records');
});
it('rotates the dns entries chevron while its dropdown is open', function () {
it('exposes the dns entries dropdown expanded state', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php'));
expect($view)
->toContain('class="inline-flex transition-transform"')
->toContain(':class="dnsEntriesOpen && \'rotate-180\'"');
->toContain('x-bind:aria-expanded="dnsEntriesOpen"')
->toContain('x-show="dnsEntriesOpen"');
});
it('lists dns entries for service hosts that still need dns', function () {
@ -418,7 +427,7 @@
expect($view)
->toContain('wire:key="service-domains-list"')
->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"')
->toContain('wire:key="service-domain-rows-{{ $appId }}"')
->not->toContain('md5(serialize($domainRows))');
});
@ -502,9 +511,9 @@
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->assertSee('Direction')
->assertSee('www redirect')
->assertSee('Search engine indexing')
->set('editingDomain', 'https://renamed.example.com')
->set('editingDomainParts.host', 'renamed.example.com')
->call('updateDomain')
->assertHasNoErrors()
->assertDispatched('edit-domain-saved')
@ -757,7 +766,8 @@
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.url', 'https://broken.example.com')
->assertSet('domainRows.0.dns_status', 'failed')
->assertSet('domainRows.2.url', 'https://healthy.example.com');
->assertCount('domainRows', 2)
->assertSet('domainRows.1.url', 'https://healthy.example.com');
});
it('hides dns message text when service domain dns status is ok', function () {
@ -773,7 +783,7 @@
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('DNS OK')
->assertSee('DNS matches')
->assertDontSee('DNS points to 203.0.113.10');
});
@ -827,7 +837,8 @@
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertDontSee('Recheck DNS')
->assertDontSee('Check all DNS')
->assertDontSee('aria-label="Settings for', false)
->assertDontSee('Check DNS');
});
@ -846,10 +857,11 @@
it('updates search engine indexing from the service domains view', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->assertSee('Noindex')
->assertSee('Indexable')
->assertSee('Search engine indexing')
->assertSee('Direction')
->assertSee('www redirect')
->assertSee('toggleNoindexDomain', false)
->assertSee('updateServiceRedirect', false)
->assertSee('wire:ignore', false)
@ -931,3 +943,169 @@
->assertDontSee('Internal port ')
->assertDontSee('table-badge-danger', false);
});
it('prioritizes public addresses and moves domain configuration behind settings', function () {
$this->apiApp->update(['fqdn' => 'https://api.example.com:8080']);
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('Check all DNS')
->assertSee('Add domain')
->assertSee('Domain settings')
->call('startEdit', 0)
->assertSee('Indexing and redirect changes save automatically.')
->assertSee('Internal port 8080')
->assertSee('Both www and non-www')
->assertSee('Search indexing allowed')
->assertDontSee('Manage domains and www/non-www redirects')
->html();
expect($html)->toContain('title="https://api.example.com"')
->not->toContain('title="https://api.example.com:8080"');
});
it('distinguishes unchecked domains from dns checks in progress', function () {
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => true]);
Cache::forget('instance_settings');
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('Not checked')
->assertDontSee('DNS pending');
});
it('keeps the edited domain selected when settings refresh and reorder rows', function () {
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0);
$this->webApp->update([
'fqdn' => 'https://broken.example.com',
'domain_dns_statuses' => [
'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'],
],
]);
$component->call('refreshDomains')
->assertSet('editingIndex', 1)
->set('editingDomainParts.host', 'renamed.example.com')
->call('updateDomain')
->assertHasNoErrors();
expect($this->apiApp->fresh()->fqdn)->toBe('https://renamed.example.com')
->and($this->webApp->fresh()->fqdn)->toBe('https://broken.example.com');
});
it('renders compact icon-only domain actions with accessible labels', function () {
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])->html();
$document = new DOMDocument;
@$document->loadHTML($html);
$xpath = new DOMXPath($document);
foreach (['Check DNS', 'Settings for https://api.example.com', 'Remove domain'] as $label) {
$buttons = $xpath->query('//button[@aria-label="'.$label.'"]');
expect($buttons->length)->toBe(1);
$button = $buttons->item(0);
expect(trim($button->textContent))->toBe('')
->and($button->getAttribute('class'))->toContain('icon-button')
->and($button->getAttribute('title'))->not->toBe('');
}
expect($html)->not->toContain('aria-label="More actions for');
});
it('reuses the floating save bar for pending domain address edits', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('<x-unsaved-bar action="updateDomain"')
->toContain('dirty="hasAddressChanges"')
->toContain('<template x-if="modalOpen">')
->not->toContain('Save address');
});
it('inherits the counterpart internal port when enabling redirects without a port warning', function (?int $override, string $redirect) {
$this->service->update([
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_URL_WEB_80\n api:\n image: node:alpine\n",
]);
$host = $redirect === 'www' ? 'web.example.com' : 'www.web.example.com';
$counterpart = $redirect === 'www' ? 'www.web.example.com' : 'web.example.com';
$url = "https://{$host}/blog";
$pairedUrl = "https://{$counterpart}/blog";
$this->webApp->update([
'fqdn' => $url,
'redirect' => 'both',
'domain_port_overrides' => $override === null ? null : [$url => $override],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('updateServiceRedirect', $this->webApp->id, $redirect)
->assertHasNoErrors()
->assertSet('showPortWarningModal', false)
->assertSet('pendingAction', null)
->assertDispatched('success', 'Redirect updated.')
->call('refreshDomains')
->assertSet("serviceRedirects.{$this->webApp->id}", $redirect);
$this->webApp->refresh();
expect($this->webApp->redirect)->toBe($redirect)
->and($this->webApp->fqdn)->toContain($pairedUrl)
->and($this->webApp->domain_port_overrides[$pairedUrl] ?? $this->webApp->getRequiredPort())->toBe($override ?? 80)
->and($this->webApp->domain_port_overrides[$url] ?? null)->toBe($override);
})->with([null, 80, 8080])->with(['www', 'non-www']);
it('still warns and allows cancellation when manually adding a different port', function () {
$this->service->update([
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB_8000\n api:\n image: node:alpine\n",
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomain', 'https://web.example.com:3000')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->call('cancelRemovePort')
->assertSet('showPortWarningModal', false)
->assertSet('pendingAction', null)
->call('addDomain')
->assertSet('showPortWarningModal', true);
expect($this->webApp->fresh()->fqdn)->toBeNull();
});
it('still checks domain conflicts when inheriting a redirect counterpart port', function () {
$this->webApp->update([
'fqdn' => 'https://example.com',
'redirect' => 'both',
'domain_port_overrides' => ['https://example.com' => 8080],
]);
$this->apiApp->update(['fqdn' => 'https://www.example.com']);
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('updateServiceRedirect', $this->webApp->id, 'www')
->assertSet('showDomainConflictModal', true)
->assertSet('showPortWarningModal', false);
expect($this->webApp->fresh()->redirect)->toBe('both');
$component->call('refreshDomains')
->call('confirmDomainUsage')
->assertSet('showDomainConflictModal', false)
->assertSet('showPortWarningModal', false)
->assertDispatched('success', 'Redirect updated.');
expect($this->webApp->fresh()->redirect)->toBe('www')
->and($this->webApp->fresh()->domain_port_overrides['https://www.example.com'])->toBe(8080);
});
it('renders domain settings in compact columns instead of a second summary line', function () {
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])->html();
foreach (['Protocol redirect', 'Domain redirect', 'Internal port', 'Search indexing'] as $heading) {
expect($html)->toContain('<span>'.$heading.'</span>');
}
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect($view)->toContain('service-domain-detail')
->not->toContain('gap-x-3 gap-y-1');
});
it('lays out the domain settings dropdowns in responsive columns', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')
->toContain('class="sm:col-span-2 text-[12px]');
});

View file

@ -1,7 +1,9 @@
<?php
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
@ -209,3 +211,86 @@
->assertSee('Config App')
->screenshot(filename: 'application-danger-zone');
});
it('uses compact application domains with unified settings and a floating save bar', function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
Cache::forget('instance_settings');
$this->application->update(['fqdn' => 'https://first.example.com,https://second.example.com', 'redirect' => 'both']);
loginAndSkipBoarding();
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/domains';
$page = visit($url);
$page->click('Accept and close')
->assertSee('Check all DNS')
->assertSee('Protocol redirect')
->assertSee('Search indexing')
->assertDontSee('Search engine indexing')
->fill('[aria-label="Search services or domains"]', 'missing.example.com')
->assertSee('No domains found')
->fill('[aria-label="Search services or domains"]', '')
->click('[aria-label="Settings for https://first.example.com"]')
->assertSee('Domain settings')
->assertValue('#editingDomainParts-host', 'first.example.com')
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
->fill('#editingDomainParts-path', '/blog')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->click('[id^="application-domain-indexing-"][id$="-trigger"]')
->click('Noindex')
->assertSee('Search engine indexing updated.')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'application-domain-unified-settings')
->click('[wire\\:click="updateDomain"]')
->assertDontSee('Domain settings')
->assertSee('https://first.example.com/blog')
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-domains-compact');
$page->click('[aria-label="Settings for https://second.example.com"]')
->fill('#editingDomainParts-path', '/discard')
->click('Reset')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-path', '')
->click('[aria-label="Close"]:visible')
->click('[wire\\:key="domain-row-'.md5('https://first.example.com/blog|').'"] [aria-label="Remove domain"]')
->assertSee('Remove domain?')
->click('button:has([x-text="step2ButtonText"]):visible')
->assertDontSee('https://first.example.com/blog')
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-host', 'second.example.com')
->click('[aria-label="Close"]:visible')
->resize(390, 844)
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-domains-mobile');
expect($page->script('document.documentElement.scrollWidth <= window.innerWidth'))->toBeTrue();
});
it('edits Compose application domain redirects in the unified settings dialog', function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
Cache::forget('instance_settings');
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web.api:\n image: nginx:alpine\n expose:\n - '8080'\n",
'docker_compose_domains' => json_encode(['web.api' => ['domain' => 'https://web.example.com', 'redirect' => 'both']]),
]);
loginAndSkipBoarding();
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/domains';
$page = visit($url);
$page->click('Accept and close')
->assertSee('web.api')
->assertSee('Domain redirect')
->click('[aria-label="Settings for https://web.example.com"]')
->assertSee('Domain settings')
->click('[id^="application-domain-direction-"][id$="-trigger"]')
->click('Redirect to www')
->assertDontSee('Use a different port?')
->assertSee('Redirect updated for web.api.')
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-compose-domain-settings');
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
$page->click('[aria-label="Close"]:visible')
->assertSee('https://www.web.example.com')
->screenshot(filename: 'application-compose-domain-overview');
});

View file

@ -1,6 +1,8 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
@ -189,3 +191,130 @@
->assertSee('Config Service')
->screenshot(filename: 'service-danger-zone');
});
it('keeps domain settings out of the overview and supports editing and removal', function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
Cache::forget('instance_settings');
$this->serviceApplication->update([
'fqdn' => 'https://long-public-domain-for-the-service.example.com,https://second.example.com',
'domain_port_overrides' => ['https://long-public-domain-for-the-service.example.com' => 8080],
]);
loginAndSkipBoarding();
$url = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->service).'/domains';
$page = visit($url);
$page->click('Accept and close')
->assertSee('Check all DNS')
->assertVisible('[aria-label="Internal port 8080"]')
->assertDontSee('Search engine indexing')
->assertDontSee('Remove domain')
->screenshot(filename: 'service-domains-overview');
expect($page->script("document.querySelector('.data-table-row.service-domains-overview-grid').getBoundingClientRect().height"))->toBeLessThanOrEqual(48);
expect($page->script("getComputedStyle(document.querySelector('.data-table-row.service-domains-overview-grid')).gridTemplateColumns.split(' ').length"))->toBe(7);
$page->click('[aria-label="Settings for https://long-public-domain-for-the-service.example.com"]')
->assertSee('Domain settings')
->assertValue('#editingDomainParts-host', 'long-public-domain-for-the-service.example.com')
->assertValue('#editingDomainParts-port', '8080')
->assertDontSee('Edit address and port')
->assertDontSee('Save address')
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
->assertSee('Search engine indexing')
->screenshot(filename: 'service-domain-settings');
expect($page->script(<<<'JS'
(() => {
const indexing = document.querySelector('[id^="service-domain-indexing-"][id$="-trigger"]').getBoundingClientRect();
const redirect = document.querySelector('[id^="service-domain-direction-"][id$="-trigger"]').getBoundingClientRect();
return Math.abs(indexing.top - redirect.top) < 2 && redirect.left > indexing.right;
})()
JS))->toBeTrue();
$page->fill('#editingDomainParts-port', '80')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'service-domain-unsaved-changes');
$domainKey = hash('sha256', 'https://long-public-domain-for-the-service.example.com|'.$this->serviceApplication->id);
$page->click('#service-domain-indexing-'.$this->serviceApplication->id.'-'.$domainKey.'-trigger')
->click('Noindex')
->screenshot(filename: 'service-domain-indexing-saved')
->assertSee('Domain settings')
->assertVisible('[aria-label="Search indexing blocked"]')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->assertNoJavaScriptErrors();
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
$page->click('[wire\\:click="updateDomain"]')
->assertDontSee('Domain settings')
->assertVisible('[aria-label="Internal port 80"] >> nth=0');
$page->click('[wire\\:key="svc-domain-'.$this->serviceApplication->id.'-'.md5('https://long-public-domain-for-the-service.example.com').'"] [aria-label="Remove domain"]')
->assertSee('Remove domain?')
->assertNoJavaScriptErrors()
->screenshot(filename: 'service-domain-removal-confirmation')
->click('button:has([x-text="step2ButtonText"]):visible')
->assertDontSee('https://long-public-domain-for-the-service.example.com')
->assertSee('1 domain across 1 service')
->click('[aria-label="Settings for https://second.example.com"]')
->assertSee('www redirect')
->assertValue('#editingDomainParts-host', 'second.example.com')
->click('[aria-label="Close"]:visible')
->assertDontSee('Domain settings')
->assertNoJavaScriptErrors();
$page->click('[aria-label="Settings for https://second.example.com"]')
->fill('#editingDomainParts-path', '/discard-this')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->click('Reset')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-path', '')
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
->click('[aria-label="Close"]:visible')
->assertDontSee('Domain settings')
->assertNoJavaScriptErrors();
$page->script("document.querySelectorAll('[aria-label=\"Dismiss\"]').forEach(button => button.click()); document.documentElement.classList.remove('dark');");
$page->screenshot(filename: 'service-domains-light');
$page->resize(390, 844)
->assertSee('https://second.example.com')
->assertVisible('[aria-label="Settings for https://second.example.com"]');
$page->script("document.getElementById('service-domains-section').scrollIntoView(); window.scrollBy(0, -80);");
$page->screenshot(filename: 'service-domains-mobile');
expect($page->script('document.documentElement.scrollWidth <= window.innerWidth'))->toBeTrue();
});
it('inherits the internal port when enabling the www redirect without a warning', function (?int $override) {
config()->set('app.maintenance.store', 'array');
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
Cache::forget('instance_settings');
$this->service->update([
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB_80\n api:\n image: httpd:alpine\n",
]);
$this->serviceApplication->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both', 'domain_port_overrides' => $override === null ? null : ['https://web.example.com' => $override]]);
loginAndSkipBoarding();
$url = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->service).'/domains';
$page = visit($url);
$page->click('Accept and close')
->click('[aria-label="Settings for https://web.example.com"]')
->click('[id^="service-domain-direction-"][id$="-trigger"]')
->click('Redirect to www')
->assertDontSee('Use a different port?')
->assertSee('Redirect updated.')
->assertNoJavaScriptErrors()
->screenshot(filename: 'service-redirect-port-'.($override ?? 80));
expect($this->serviceApplication->fresh()->redirect)->toBe('www')
->and($this->serviceApplication->fresh()->domain_port_overrides['https://www.web.example.com'] ?? null)->toBe($override);
$page->navigate($url)
->click('[aria-label="Settings for https://web.example.com"]')
->assertSee('Redirect to www')
->assertNoJavaScriptErrors()
->screenshot(filename: 'service-redirect-persisted-'.($override ?? 80));
})->with([null, 8080]);