coolify/resources/views/livewire/project/service/domains.blade.php
Andras Bacsai 6028461f92 fix(domains): unify editing and queued DNS checks
Add indexing, redirect, and hostname regeneration controls to domain editors. Queue DNS checks only for scheme or hostname changes and show progress consistently across application, service, and preview domains.
2026-09-10 14:00:50 +02:00

365 lines
22 KiB
PHP

@php
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
$hasRows = count($domainRows) > 0;
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
$serviceAppCount = count($serviceApps);
$domainGroups = collect($domainRows)
->groupBy('service_application_id')
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
$domainSearchValues = $domainGroups->map(function ($rows, $appId) use ($serviceApps) {
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
return $heading.' '.$rows->pluck('url')->implode(' ');
})->values();
@endphp
<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(index, domain, parts, serviceApplicationId, serviceLabel, indexing, redirect) {
if (index !== undefined) {
$wire.set('editingIndex', index, false);
$wire.set('editingDomain', domain, false);
$wire.set('editingDomainParts', parts, false);
$wire.set('editingDomainPartsChanged', false, false);
$wire.set('editingServiceApplicationId', serviceApplicationId, false);
$wire.set('editingIndexing', indexing, false);
$wire.set('editingRedirect', redirect, false);
$wire.set('editingOriginalRedirect', redirect, false);
$wire.set('editingDomainWasRegenerated', false, false);
$wire.set('editingGeneratedHost', null, false);
}
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editingServiceLabel = serviceLabel ?? $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name ?? '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
},
closeEditDomain(discardDraft = true) {
this.modalOpen = false;
this.editingDomainBaseline = null;
this.editingServiceLabel = '';
if (discardDraft) this.$wire.cancelEdit();
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
},
hasDomainSearchResults(values) {
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain(false)">
@if ($hasDnsChecksInProgress)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
@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">
<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">
<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" />
<input type="search" x-model="domainSearch" aria-label="Search services or domains"
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or domains" />
</div>
@endif
@can('update', $service)
@if ($configuredCount > 0)
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" 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')
</div>
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
canGate="update" :canResource="$service">
<x-slot:content>
<button type="button"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
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 portal
helper="Domain will be assigned to this compose service application."
:options="collect($serviceApps)->map(fn ($app) => [
'value' => $app['id'],
'label' => $app['name'].(filled($app['image'] ?? null) ? ' ('.$app['image'].')' : ''),
])->values()->all()"
:disabled="! auth()->user()->can('update', $service)" />
<x-forms.domain-input id="newDomainParts" errorId="newDomain" />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to add it anyway?
@if (filled($addDomainDnsMessage))
<div class="pt-2">{{ $addDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button canGate="update" :canResource="$service" type="button"
wire:click="generateDomain">
Generate domain
</x-forms.button>
<div class="flex flex-wrap gap-2">
@if ($addDomainDnsFailed)
<x-forms.button canGate="update" :canResource="$service" type="button"
wire:click="confirmAddDomainDespiteDns" isError>
Continue
</x-forms.button>
@else
<x-forms.button canGate="update" :canResource="$service" type="submit"
isHighlighted>
Save
</x-forms.button>
@endif
</div>
</div>
</form>
</x-modal-input>
@endif
@endcan
</div>
</div>
@if ($serviceAppCount === 0)
<div class="application-settings-section-body mt-1 w-full scroll-mt-28">
<x-empty size="sm" title="No application services"
description="Only database services are available. Domains can only be assigned to application services."
icon-name="globe" />
</div>
@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 domain button above. Choose which service application receives it."
icon-name="globe" />
</div>
@else
<div wire:key="service-domains-list" class="flex flex-col gap-3">
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
$hasHttpsDomains = $rows->contains(
fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://')
);
@endphp
<section id="service-domain-group-{{ $appId }}" wire:key="service-domain-group-{{ $appId }}"
x-show="matchesDomainSearch(@js($heading.' '.$rows->pluck('url')->implode(' ')))"
class="application-settings-section-body is-flush overflow-visible">
<div class="flex w-full flex-wrap items-center gap-3 rounded-t-lg 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="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 }}" preserveValue
onChange="updateForceHttps"
:onChangeArgs="[(int) $appId]"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" :disabled="! auth()->user()->can('update', $service)" />
</div>
@endif
</div>
<div wire:key="service-domain-rows-{{ $appId }}">
@include('livewire.project.service.partials.domain-table', [
'rows' => $rows,
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => false,
'showHeader' => true,
])
</div>
</section>
@endforeach
<div x-cloak
x-show="domainSearch.trim() && !hasDomainSearchResults(@js($domainSearchValues))"
class="px-4 py-8">
<x-empty size="sm" title="No domains found"
description="No service or domain matches your search." icon-name="search" />
</div>
</div>
@endif
{{-- 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">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
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">Domain settings</h3>
<button type="button" @click="closeEditDomain()" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<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">
<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>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Domains stay on the service they were added to. Remove and re-add to move.
</p>
</div>
<x-forms.domain-input id="editingDomainParts" errorId="editingDomain" />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to save it anyway?
@if (filled($editDomainDnsMessage))
<div class="pt-2">{{ $editDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
@if ($editDomainDnsFailed)
<div class="flex justify-end">
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
Continue
</x-forms.button>
</div>
@endif
</form>
@can('update', $service)
<div
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="editingIndexing" htmlId="service-domain-indexing"
label="Search engine indexing" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="editingRedirect" htmlId="service-domain-direction"
label="www redirect"
helper="Applies to all domains for this service application."
portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
@unless ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="updateDomain" isHighlighted>Save</x-forms.button>
@endunless
</div>
@endcan
</div>
</div>
</div>
</div>
</template>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
@if ($showPortWarningModal)
<div x-data="{ modalOpen: true }"
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
:class="{ 'z-40': modalOpen }" class="relative">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<h3>Use a different port?</h3>
<button type="button"
@click="modalOpen = false; $wire.call('cancelRemovePort')"
class="icon-button" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body">
<x-callout type="warning" title="Port requirement" class="mb-4">
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
One or more of your domains use a different port, or none.
</x-callout>
<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"
wire:click="cancelRemovePort"
@click="modalOpen = false">
Keep required port
</x-forms.button>
<x-forms.button type="button" wire:click="confirmRemovePort"
@click="modalOpen = false" isError>
Use this port anyway
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>