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
+60
View File
@@ -4396,3 +4396,63 @@ a.command-palette-item:focus-visible {
.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);
}
}
@@ -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') &&
@@ -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>
@@ -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 @@
</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 @@
<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 @@
</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 @@
<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 @@
'row' => $row,
'application' => $application,
'labelsAreWritable' => $labelsAreWritable,
'isCompose' => false,
'showDirectionControl' => false,
'domainGridClass' => 'domains-table-grid-service',
'isCompose' => true,
])
@endforeach
</div>
@@ -273,13 +253,6 @@
</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 @@
])
@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="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 @@
<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 @@
</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>
@@ -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="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 @@
@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 @@
@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 @@
@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"
@@ -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)
@@ -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 @@
</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 @@
<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 @@
@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 @@
<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 @@
@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 @@
</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="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 @@
<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 @@
</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 @@
<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"
@@ -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 @@
'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="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 @@
</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 @@
@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 @@
</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?"