From 6028461f9297bf357f8b2af0d63b1e5b62ed3a8d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:00:50 +0200 Subject: [PATCH] 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. --- .ai/lessons.md | 29 ++ app/Livewire/Project/Application/Domains.php | 223 ++++++++++----- .../Project/Application/PreviewDomains.php | 103 ++++++- app/Livewire/Project/Service/Domains.php | 189 ++++++++---- app/Support/DomainUrlParts.php | 9 + .../project/application/domains.blade.php | 58 ++-- .../application/partials/domain-row.blade.php | 18 +- .../application/preview-domains.blade.php | 55 ++-- .../project/service/domains.blade.php | 64 ++--- .../service/partials/domain-table.blade.php | 17 +- tests/Feature/ApplicationDomainsTest.php | 268 ++++++++++++++++-- .../PreviewDomainPortOverridesTest.php | 86 ++++++ tests/Feature/ServiceDomainsTest.php | 121 ++++++-- .../Browser/ApplicationConfigurationTest.php | 31 +- tests/v4/Browser/ServiceConfigurationTest.php | 19 +- 15 files changed, 1000 insertions(+), 290 deletions(-) diff --git a/.ai/lessons.md b/.ai/lessons.md index 89dce29f46..3a2dbb9474 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,3 +12,32 @@ ## Prove regressions against the unchanged baseline - For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test. + +## Apply shared domain UX to every supported resource type +- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation. +- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify. + +## Verify manual and generated domain paths separately +- Domain regeneration and manual hostname edits must start the same post-save DNS check. +- Add explicit regression coverage for both entry paths across every active domain editor. + +## Do not treat a runtime restart as behavior verification +- A healthy restarted container proves only that the process started. +- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works. + +## Prove the reported live flow before reporting a UI fix +- Do not use unit tests or a healthy process as proof for a reported live UI failure. +- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works. + +## Start DNS checks only for DNS-relevant edits +- Compare the previous and saved scheme and hostname before a post-save DNS check. +- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes. + +## Include automatically added domains in post-save DNS checks +- Compare the configured domain list before and after Save. +- Start checks for each newly added counterpart, even when the edited domain itself did not change. + +## Use one DNS progress pattern +- All DNS check entry points must set the domain badge to the same `checking` state. +- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator. +- Verify the rendered badge uses the spinner slot instead of the default status dot. diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 906cc147a0..ae37e19cdb 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -58,6 +58,16 @@ class Domains extends Component public ?string $editingService = null; + public string $editingIndexing = 'index'; + + public string $editingRedirect = 'both'; + + public string $editingOriginalRedirect = 'both'; + + public bool $editingDomainWasRegenerated = false; + + public ?string $editingGeneratedHost = null; + /** @var array */ public array $domainRows = []; @@ -122,6 +132,8 @@ class Domains extends Component return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), + 'editingIndexing' => 'string|required|in:index,noindex', + 'editingRedirect' => 'string|required|in:both,www,non-www', 'redirect' => 'string|required|in:both,www,non-www', 'isForceHttpsEnabled' => 'boolean', 'serviceRedirects' => 'array', @@ -692,41 +704,8 @@ class Domains extends Component { $this->authorize('update', $this->application); - $this->isCheckingDns = true; - - try { - $server = $this->application->destination?->server; - $skipDns = ! $this->dnsValidationEnabled - || ! $server - || $this->application->additional_servers->count() > 0; - - $indexesToCheck = []; - - foreach ($this->domainRows as $index => $row) { - if ($skipDns) { - $reason = ! $this->dnsValidationEnabled - ? 'DNS validation is disabled in instance settings.' - : ($this->application->additional_servers->count() > 0 - ? 'DNS check skipped for multi-server applications.' - : 'No server available for DNS validation.'); - - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = $reason; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - - continue; - } - - $indexesToCheck[] = $index; - } - - if ($server && $indexesToCheck !== []) { - $this->applyDnsStatuses($indexesToCheck, $server); - } - - $this->persistDomainDnsStatuses(); - } finally { - $this->isCheckingDns = false; + foreach ($this->domainRows as $row) { + $this->queueUrlsDns([$row['url']], $row['service'] ?? null); } } @@ -738,18 +717,8 @@ class Domains extends Component return; } - $server = $this->application->destination?->server; - if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->persistDomainDnsStatuses(); - - return; - } - - $this->applyDnsStatus($index, $server); - $this->persistDomainDnsStatuses(); + $row = $this->domainRows[$index]; + $this->queueUrlsDns([$row['url']], $row['service'] ?? null); } protected function applyDnsStatus(int $index, Server $server): void @@ -1214,6 +1183,34 @@ class Domains extends Component $this->persistDomainDnsStatuses(); } + /** + * @param array $urls + */ + protected function queueUrlsDns(array $urls, ?string $service = null): void + { + foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) { + $checkId = new_public_id(); + $this->markUrlsAsChecking([$url], $service, $checkId); + $this->persistDomainDnsStatuses(); + + try { + CheckDomainDnsJob::dispatch( + $this->application, + $statusKey, + $url, + $this->application->destination?->server, + $this->serverIp, + $checkId, + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $this->markUrlsDnsCheckUnavailable([$url], $service, $checkId); + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.'); + } + } + } + protected function shouldValidateDnsForAdd(): bool { if (! $this->dnsValidationEnabled) { @@ -1287,6 +1284,11 @@ class Domains extends Component } $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; + $this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; + $this->editingRedirect = $this->serviceRedirectFor($this->editingService); + $this->editingOriginalRedirect = $this->editingRedirect; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); $this->showEditDomainModal = true; @@ -1372,6 +1374,11 @@ class Domains extends Component $this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainPartsChanged = false; $this->editingService = null; + $this->editingIndexing = 'index'; + $this->editingRedirect = 'both'; + $this->editingOriginalRedirect = 'both'; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->resetEditDomainDnsGate(); $this->resetErrorBag('editingDomain'); if ($this->pendingAction === 'update') { @@ -1387,6 +1394,39 @@ class Domains extends Component $this->updateDomain(); } + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->application); + + if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + + $server = data_get($this->application, 'destination.server'); + if (! $server) { + $this->dispatch('error', 'No server found for this application.'); + + return; + } + + $generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($generatedHost) || $generatedHost === '') { + $this->dispatch('error', 'Could not generate a domain.'); + + return; + } + + $currentHost = (string) ($this->editingDomainParts['host'] ?? ''); + $this->editingGeneratedHost = $generatedHost; + $this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.') + ? 'www.'.$generatedHost + : $generatedHost; + $this->editingDomainPartsChanged = true; + $this->editingDomainWasRegenerated = true; + $this->resetEditDomainDnsGate(); + $this->resetErrorBag('editingDomain'); + } + public function updateDomain(): void { try { @@ -1406,6 +1446,8 @@ class Domains extends Component $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); + $this->validateOnly('editingIndexing'); + $this->validateOnly('editingRedirect'); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) { @@ -1417,8 +1459,6 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $service = $this->editingService; - $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); - if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { $portOverrides = $this->application->domain_port_overrides ?? []; unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); @@ -1442,29 +1482,69 @@ class Domains extends Component return; } - if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage([$newUrl]); - if ($dnsFailure !== null) { - $this->editDomainDnsFailed = true; - $this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure); - $this->showEditDomainModal = true; - - return; + $replacements = [$oldUrl => $newUrl]; + if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + $oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST); + $oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost); + if (is_string($oldCounterpart)) { + $counterpartParts = DomainUrlParts::split($oldCounterpart); + $counterpartPort = $this->currentRowPort($oldCounterpart); + if ($counterpartPort !== null) { + $counterpartParts['port'] = (string) $counterpartPort; + } + $counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.') + ? 'www.'.$this->editingGeneratedHost + : $this->editingGeneratedHost; + $replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts); } } - $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); + $updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values(); + if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + foreach ($updated->all() as $url) { + $counterpart = $this->wwwCounterpartUrl($url, true); + $counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null; + $hasCounterpart = filled($counterpartHost) && $updated->contains( + fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost + ); + if (filled($counterpart) && ! $hasCounterpart) { + $updated->push($counterpart); + } + } + } + $urlsToCheck = $updated + ->reject(fn (string $url): bool => $current->contains( + fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url) + )) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->unique() + ->values() + ->all(); + + $noindexDomains = $this->application->noindexDomains(); + foreach ($replacements as $previousUrl => $replacementUrl) { + $wasNoindexed = $previousUrl === $oldUrl + ? $this->editingIndexing === 'noindex' + : $this->application->isDomainNoindexed($previousUrl); + $noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl); + if ($wasNoindexed) { + $noindexDomains->push($replacementUrl); + } + } + if ($this->isCompose) { + $allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true); + $existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : []; + $allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]); + $this->application->docker_compose_domains = json_encode($allDomains); + } else { + $this->application->redirect = $this->editingRedirect; + } + $this->pendingAction = 'update'; - if (! $this->saveDomainList($updated, $service)) { + if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) { return; } - $noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($wasNoindexed) { - $noindexDomains->push($newUrl); - } - $this->application->setNoindexDomains($noindexDomains); - $this->application->save(); $this->resetDefaultLabels(); $this->forceSaveDomains = false; @@ -1474,7 +1554,9 @@ class Domains extends Component $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); $this->refreshDomains(); - $this->checkUrlsDns([$newUrl], $service); + if ($urlsToCheck !== []) { + $this->queueUrlsDns($urlsToCheck, $service); + } } catch (\Throwable $e) { handleError($e, $this); } @@ -1640,7 +1722,7 @@ class Domains extends Component $this->resetDefaultLabels(); $this->dispatch('success', 'Redirect updated.'); $this->refreshDomains(); - $this->checkUrlsDns($addedDomains); + $this->queueUrlsDns($addedDomains); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); @@ -1737,7 +1819,7 @@ class Domains extends Component $this->dispatch('success', "Redirect updated for {$serviceName}."); } $this->refreshDomains(); - $this->checkUrlsDns($addedDomains, $serviceName); + $this->queueUrlsDns($addedDomains, $serviceName); $this->pruneDomainDnsStatusesToCurrentDomains(); } catch (\Throwable $e) { handleError($e, $this); @@ -2002,6 +2084,7 @@ class Domains extends Component Collection $domains, ?string $serviceName = null, bool $checkConflicts = true, + ?Collection $noindexDomains = null, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -2055,6 +2138,10 @@ class Domains extends Component $this->application->fqdn = $domainString; } + if ($noindexDomains !== null) { + $this->application->setNoindexDomains($noindexDomains); + } + if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); if ($result['hasConflicts']) { diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index cba3f18b65..5f454d91ba 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -176,6 +176,7 @@ class PreviewDomains extends Component return; } $oldUrl = $this->domainRows[$this->editingIndex]['url']; + $dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain); if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); @@ -188,17 +189,75 @@ class PreviewDomains extends Component $this->preview->domain_port_overrides = $portOverrides ?: null; } $this->domainRows[$this->editingIndex]['url'] = $domain; - $this->domainRows[$this->editingIndex]['dns_status'] = 'pending'; - $this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.'; + $checkId = $dnsRelevantChange ? new_public_id() : null; + if ($dnsRelevantChange) { + $this->domainRows[$this->editingIndex]['dns_status'] = 'checking'; + $this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$this->editingIndex]['check_id'] = $checkId; + } $index = $this->editingIndex; $this->editingIndex = null; if (! $this->persistDomains()) { return; } + $domain = $this->domainRows[$index]['url']; $this->forceUseUnknownPort = false; $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); - $this->dispatch('success', 'Domain updated.'); - $this->checkDomainDns($index); + + if (! $dnsRelevantChange) { + $this->dispatch('success', 'Domain updated.'); + + return; + } + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain updated. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + + $server = $this->preview->application->destination?->server; + if (! $server) { + $this->dispatch('error', 'No server found for this preview.'); + + return; + } + + $host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($host) || $host === '') { + return; + } + + $this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host; + } + + public function cancelEdit(): void + { + $this->editingIndex = null; + $this->editingDomainParts = DomainUrlParts::empty(); + $this->resetErrorBag('editingDomainParts.host'); } public function confirmUseUnknownPort(): void @@ -263,16 +322,46 @@ class PreviewDomains extends Component { $this->authorize('update', $this->preview->application); foreach (array_keys($this->domainRows) as $index) { - $this->applyDnsCheck($index); + $this->queueDnsCheck($index); } - $this->persistDnsStatuses(); } public function checkDomainDns(int $index): void { $this->authorize('update', $this->preview->application); - $this->applyDnsCheck($index); + $this->queueDnsCheck($index); + } + + private function queueDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + + $row = $this->domainRows[$index]; + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; $this->persistDnsStatuses(); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($row['url'], $row['service']), + $row['url'], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + } } public function pollDnsChecks(): void diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 9774d20d37..56c9f651f6 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -63,6 +63,16 @@ class Domains extends Component public ?int $editingServiceApplicationId = null; + public string $editingIndexing = 'index'; + + public string $editingRedirect = 'both'; + + public string $editingOriginalRedirect = 'both'; + + public bool $editingDomainWasRegenerated = false; + + public ?string $editingGeneratedHost = null; + public bool $showEditDomainModal = false; public bool $forceSaveDomains = false; @@ -113,6 +123,8 @@ class Domains extends Component return [ 'newDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(), + 'editingIndexing' => 'string|required|in:index,noindex', + 'editingRedirect' => 'string|required|in:both,www,non-www', 'newServiceApplicationId' => 'nullable|integer', 'serviceRedirects' => 'array', 'serviceRedirects.*' => 'string|in:both,www,non-www', @@ -481,35 +493,11 @@ class Domains extends Component { $this->authorize('update', $this->service); - $this->isCheckingDns = true; - - try { - $server = $this->service->server; - $skipDns = ! $this->dnsValidationEnabled || ! $server; - - $indexesToCheck = []; - - foreach ($this->domainRows as $index => $row) { - if ($skipDns) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled - ? 'DNS validation is disabled in instance settings.' - : 'No server available for DNS validation.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - - continue; - } - - $indexesToCheck[] = $index; + foreach ($this->domainRows as $row) { + $application = $this->findServiceApp((int) $row['service_application_id']); + if ($application) { + $this->queueUrlsDns([$row['url']], $application); } - - if ($server && $indexesToCheck !== []) { - $this->applyDnsStatuses($indexesToCheck, $server); - } - - $this->persistAllDomainDnsStatuses(); - } finally { - $this->isCheckingDns = false; } } @@ -521,19 +509,11 @@ class Domains extends Component return; } - $server = $this->service->server; - if (! $server || ! $this->dnsValidationEnabled) { - $this->domainRows[$index]['dns_status'] = 'skipped'; - $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); - $this->persistAllDomainDnsStatuses(); - - return; + $row = $this->domainRows[$index]; + $application = $this->findServiceApp((int) $row['service_application_id']); + if ($application) { + $this->queueUrlsDns([$row['url']], $application); } - - $this->applyDnsStatus($index, $server); - $this->persistAllDomainDnsStatuses(); } protected function applyDnsStatus(int $index, Server $server): void @@ -837,7 +817,7 @@ class Domains extends Component $this->dispatch('configurationChanged'); $this->pruneDomainDnsStatusesToCurrentDomains(); $this->refreshDomains(); - $this->checkUrlsDns($addedDomains, $serviceApplicationId); + $this->queueUrlsDns($addedDomains, $app); } catch (\Throwable $e) { handleError($e, $this); } @@ -1186,6 +1166,12 @@ class Domains extends Component } $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; + $app = $this->findServiceApp($this->editingServiceApplicationId); + $this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index'; + $this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId); + $this->editingOriginalRedirect = $this->editingRedirect; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -1202,6 +1188,11 @@ class Domains extends Component $this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = null; + $this->editingIndexing = 'index'; + $this->editingRedirect = 'both'; + $this->editingOriginalRedirect = 'both'; + $this->editingDomainWasRegenerated = false; + $this->editingGeneratedHost = null; $this->editDomainDnsFailed = false; $this->editDomainDnsMessage = ''; $this->forceSaveEditDns = false; @@ -1229,6 +1220,8 @@ class Domains extends Component $this->editingDomain = DomainUrlParts::compose(...$editingDomainParts); } $this->validateOnly('editingDomain'); + $this->validateOnly('editingIndexing'); + $this->validateOnly('editingRedirect'); $app = $this->findServiceApp($this->editingServiceApplicationId); if (! $app) { @@ -1245,8 +1238,6 @@ class Domains extends Component $newUrl = $this->splitDomains($normalized)[0]; $oldUrl = $this->domainRows[$this->editingIndex]['url']; $current = collect($this->splitDomains($app->fqdn)); - $wasNoindexed = $app->isDomainNoindexed($oldUrl); - if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { $portOverrides = $app->domain_port_overrides ?? []; unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); @@ -1263,31 +1254,50 @@ class Domains extends Component return; } - if (! $this->forceSaveEditDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage([$newUrl]); - if ($dnsFailure !== null) { - $this->editDomainDnsFailed = true; - $this->editDomainDnsMessage = $dnsFailure; - $this->showEditDomainModal = true; - - return; + $replacements = [$oldUrl => $newUrl]; + if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + $oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST); + $oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost); + if (is_string($oldCounterpart)) { + $parts = DomainUrlParts::split($oldCounterpart); + $port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null; + $parts['port'] = filled($port) ? (string) $port : $parts['port']; + $parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost; + $replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts); + } + } + $updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values(); + if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) { + foreach ($updated->all() as $url) { + $counterpart = $this->wwwCounterpartUrl($url, true); + $host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null; + if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) { + $updated->push($counterpart); + } + } + } + $urlsToCheck = $updated + ->reject(fn (string $url): bool => $current->contains( + fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url) + )) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->unique() + ->values() + ->all(); + $noindexDomains = $app->noindexDomains(); + foreach ($replacements as $previousUrl => $replacementUrl) { + $isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl); + $noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl); + if ($isNoindexed) { + $noindexDomains->push($replacementUrl); } } - - $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $this->pendingAction = 'update'; - if (! $this->saveDomainListForApp($app, $updated)) { + if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) { return; } - $noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); - if ($wasNoindexed) { - $noindexDomains->push($newUrl); - } - $app->setNoindexDomains($noindexDomains); - $app->save(); - $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->forceSaveDomains = false; @@ -1295,7 +1305,9 @@ class Domains extends Component $this->pendingAction = null; $this->dispatch('success', 'Domain updated.'); $this->refreshDomains(); - $this->checkUrlsDns([$newUrl], (int) $app->id); + if ($urlsToCheck !== []) { + $this->queueUrlsDns($urlsToCheck, $app); + } } catch (\Throwable $e) { handleError($e, $this); } @@ -1453,6 +1465,24 @@ class Domains extends Component } } + public function regenerateEditingDomain(): void + { + $this->authorize('update', $this->service); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) { + return; + } + + $host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST); + if (! is_string($host) || $host === '') { + return; + } + + $this->editingGeneratedHost = $host; + $this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host; + $this->editingDomainPartsChanged = true; + $this->editingDomainWasRegenerated = true; + } + /** * @param Collection $domains */ @@ -1461,6 +1491,8 @@ class Domains extends Component Collection $domains, bool $checkConflicts = true, bool $checkPorts = true, + ?Collection $noindexDomains = null, + ?string $redirect = null, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1475,6 +1507,12 @@ class Domains extends Component } $app->fqdn = $domainString; + if ($noindexDomains !== null) { + $app->setNoindexDomains($noindexDomains); + } + if ($redirect !== null) { + $app->redirect = $redirect; + } if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $app); @@ -1574,6 +1612,33 @@ class Domains extends Component $this->persistAllDomainDnsStatuses(); } + /** + * @param array $urls + */ + protected function queueUrlsDns(array $urls, ServiceApplication $application): void + { + foreach (array_unique($urls) as $url) { + $checkId = new_public_id(); + $this->markUrlsAsChecking([$url], (int) $application->id, $checkId); + $this->persistAllDomainDnsStatuses(); + + try { + CheckDomainDnsJob::dispatch( + $application, + $url, + $url, + $this->service->server, + $this->serverIp, + $checkId, + ); + } catch (\Throwable) { + $this->markUrlsDnsCheckUnavailable([$url], (int) $application->id, $checkId); + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.'); + } + } + } + protected function shouldValidateDns(): bool { return $this->dnsValidationEnabled && $this->service->server !== null; diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index c86d5fa9a9..91dd609a92 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -46,6 +46,15 @@ class DomainUrlParts ]; } + public static function hasDnsRelevantChange(string $oldUrl, string $newUrl): bool + { + $old = self::split($oldUrl); + $new = self::split($newUrl); + + return $old['scheme'] !== $new['scheme'] + || strtolower($old['host']) !== strtolower($new['host']); + } + /** * @return array{scheme: string, host: string, port: string, path: string} */ diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index db0c9ba5e8..dd5e344fee 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -16,14 +16,27 @@ domainSearch: '', modalOpen: @js($showEditDomainModal || $editDomainDnsFailed), editingServiceLabel: @js($editingService ?? ''), - openEditDomain() { - this.editingServiceLabel = $wire.editingService || ''; + openEditDomain(index, domain, parts, service, 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('editingService', service, 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.editingServiceLabel = service ?? $wire.editingService ?? ''; this.modalOpen = true; this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.()); }, - closeEditDomain() { + closeEditDomain(discardDraft = true) { this.modalOpen = false; this.editingServiceLabel = ''; + if (discardDraft) this.$wire.cancelEdit(); }, matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); @@ -33,7 +46,7 @@ }, }" @open-edit-domain.window="openEditDomain()" - @edit-domain-saved.window="closeEditDomain()"> + @edit-domain-saved.window="closeEditDomain(false)"> @if ($hasDnsChecksInProgress) @endif @@ -77,7 +90,7 @@ @endif @can('update', $application) - + Check all DNS @@ -316,33 +329,20 @@ @endif - @php - $editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null; - @endphp - @if ($editingRow && ! $labelsAreWritable) + @unless ($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 -
- -
@endcan - @endif + @endunless
-
+
+ + Regenerate hostname + @if ($editDomainDnsFailed) Continue diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index bcacb44dc5..ce973200fb 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -25,6 +25,10 @@ : $redirect; $isNoindexed = $application->isDomainNoindexed($row['url']); $domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? '')); + $editingParts = \App\Support\DomainUrlParts::split($row['url']); + if ($row['has_port_override'] ?? false) { + $editingParts['port'] = (string) $row['internal_port']; + } @endphp
+ @elseif ($row['dns_status'] === 'checking') + + + Checking DNS... + @else @@ -129,11 +138,7 @@ wire:loading.attr="disabled" wire:target="checkDomainDns({{ $index }}),checkAllDns" class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS"> - - + @unless ($labelsAreWritable) @if ($isSuggested) @@ -147,7 +152,8 @@ @endif @else - - @@ -212,12 +231,12 @@
- - @if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null)) - - @endif +
+
+ +
+ +
'non-www', 'label' => 'Redirect to non-www'], ]" />
+
+ Regenerate hostname + Save +
diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 0955e534cf..ff6fdf66f8 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -26,16 +26,29 @@ && JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline && !$wire.showPortWarningModal && !$wire.showDomainConflictModal; }, - openEditDomain() { + 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 = $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name || ''; + this.editingServiceLabel = serviceLabel ?? $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name ?? ''; this.modalOpen = true; this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.()); }, - closeEditDomain() { + 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()); @@ -45,7 +58,7 @@ }, }" @open-edit-domain.window="openEditDomain()" - @edit-domain-saved.window="closeEditDomain()"> + @edit-domain-saved.window="closeEditDomain(false)"> @if ($hasDnsChecksInProgress) @endif @@ -77,7 +90,7 @@ @endif @can('update', $service) @if ($configuredCount > 0) - Check all DNS @@ -239,10 +252,6 @@
-
@@ -274,41 +283,32 @@
@endif - @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 -
-

Indexing and redirect changes save automatically.

- -
- @endcan - @endif +
+ Regenerate hostname + @unless ($editDomainDnsFailed) + Save + @endunless +
+ @endcan
diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 5b4c0d1e11..97127904e4 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -57,6 +57,10 @@ ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' : null; $domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? '')); + $editingParts = \App\Support\DomainUrlParts::split($row['url']); + if ($row['has_port_override'] ?? false) { + $editingParts['port'] = (string) $row['internal_port']; + } @endphp
+ @elseif ($row['dns_status'] === 'checking') + + + Checking DNS... + @else @@ -162,11 +171,7 @@ wire:loading.attr="disabled" wire:target="checkDomainDns({{ $index }}),checkAllDns" class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS"> - - + @if ($isSuggested) @if ($row['needs_force_add'] ?? false) @@ -184,7 +189,7 @@ @endif @else $this->application->fresh()]) + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomainParts.host', 'aaa') ->call('addDomain') ->assertDispatched('error'); @@ -627,7 +627,7 @@ it('lists existing domains as individual rows', function () { it('shows the HTTP redirect control for HTTPS domains and persists changes', function () { $this->application->update(['fqdn' => 'https://app.example.com']); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('isForceHttpsEnabled', true) ->assertSee('Redirect HTTP to HTTPS') ->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.') @@ -664,8 +664,9 @@ it('shows the compose service redirect control in domain settings', function () ->assertSee('www redirect') ->html(); - expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1) + expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0) ->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0) + ->and($html)->toContain('editingRedirect') ->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0); }); @@ -907,7 +908,7 @@ it('updates a domain in place via modal', function () { 'fqdn' => 'https://old.example.com,https://keep.example.com', ]); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('startEdit', 0) ->assertSet('showEditDomainModal', true) ->assertSet('editingDomain', 'https://old.example.com') @@ -927,7 +928,7 @@ it('updates a domain in place via modal', function () { expect($this->application->fqdn)->toBe('https://new.example.com,https://keep.example.com'); }); -it('blocks editing a domain with bad dns until the user continues', function () { +it('saves an edited domain and records its dns result without a confirmation gate', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); @@ -941,21 +942,14 @@ it('blocks editing a domain with bad dns until the user continues', function () ->set('editingDomainParts.scheme', 'https') ->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('updateDomain') - ->assertSet('editDomainDnsFailed', true) - ->assertSet('showEditDomainModal', true) - ->assertSee('DNS is not pointing to the right IP') - ->assertSee('Are you sure you want to save it anyway'); - - $this->application->refresh(); - expect($this->application->fqdn)->toBe('https://old.example.com'); - - $component->call('confirmUpdateDomainDespiteDns') ->assertSet('editDomainDnsFailed', false) ->assertSet('showEditDomainModal', false) ->assertDispatched('success'); $this->application->refresh(); - expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid'); + expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid') + ->and($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null) + ->toBe('failed'); }); it('removes a domain', function () { @@ -1894,8 +1888,9 @@ it('uses the compact service domains layout for compose applications', function ->not->toContain('Last checked') ->not->toContain('id="edit-domain-direction"') ->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"') - ->toContain('id="application-domain-direction-{{ $editingKey }}"') - ->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'") + ->toContain('htmlId="application-domain-direction"') + ->toContain('id="editingRedirect"') + ->not->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'") ->not->toContain('title="No domains for this service"'); }); @@ -1922,6 +1917,232 @@ it('shows a form save button at the bottom of application domain settings', func ->not->toContain('not->toContain('wire:click="startEdit(') + ->toContain('@click="openEditDomain(') + ->toContain('not->toContain('application->update(['fqdn' => 'https://badge.example.com']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call($action, ...$parameters) + ->assertSet('domainRows.0.dns_status', 'checking') + ->assertSee('Checking DNS...') + ->assertSeeHtml('loading-indicator'); + + Queue::assertPushed(CheckDomainDnsJob::class, 1); +})->with([ + 'single domain' => ['checkDomainDns', [0]], + 'all domains' => ['checkAllDns', []], +]); + +it('keeps domain settings as a draft until the modal is saved', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'redirect' => 'both', + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingIndexing', 'noindex') + ->set('editingRedirect', 'www'); + + expect($this->application->fresh()->redirect)->toBe('both') + ->and($this->application->noindexDomains()->all())->toBe([]); +}); + +it('saves all domain modal settings together', function () { + Queue::fake(); + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'redirect' => 'both', + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingIndexing', 'noindex') + ->set('editingRedirect', 'www') + ->call('updateDomain') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $application = $this->application->fresh(); + + expect($application->redirect)->toBe('www') + ->and($application->noindexDomains()->all())->toBe(['https://app.example.com']) + ->and(explode(',', $application->fqdn))->toContain('https://www.app.example.com'); + Queue::assertPushed(CheckDomainDnsJob::class, 1); + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.app.example.com'); +}); + +it('regenerates an application domain as a draft while preserving its url settings', function () { + $this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']); + $this->application->update(['fqdn' => 'http://old.example.com:8080/api']); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->call('regenerateEditingDomain') + ->assertSet('editingDomainParts.scheme', 'http') + ->assertSet('editingDomainParts.port', '8080') + ->assertSet('editingDomainParts.path', '/api'); + + expect($component->get('editingDomainParts')['host']) + ->toEndWith('.sslip.io') + ->not->toBe('old.example.com') + ->and($this->application->fresh()->fqdn)->toBe('http://old.example.com/api') + ->and($this->application->fresh()->domain_port_overrides)->toMatchArray([ + 'http://old.example.com/api' => 8080, + ]); +}); + +it('starts a dns check after a manually edited application domain is saved', function () { + Queue::fake(); + $settings = InstanceSettings::get(); + $settings->is_dns_validation_enabled = true; + $settings->save(); + $this->application->update(['fqdn' => 'https://old.example.com:81']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->assertSet('editingDomainParts.port', '81') + ->set('editingDomainParts.host', 'manual.example.com') + ->call('updateDomain') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains( + fn (array $row): bool => $row['url'] === 'https://manual.example.com' && $row['dns_status'] === 'checking' + )); + + expect($this->application->fresh()->domain_dns_statuses['https://manual.example.com']['status'] ?? null)->toBe('checking'); + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual.example.com' + && $job->statusKey === 'https://manual.example.com'); +}); + +it('does not start a dns check when only application domain settings change', function () { + Queue::fake(); + $this->application->update(['fqdn' => 'https://unchanged.example.com']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingIndexing', 'noindex') + ->call('updateDomain') + ->assertHasNoErrors(); + + Queue::assertNotPushed(CheckDomainDnsJob::class); +}); + +it('starts a dns check when the application domain scheme changes', function () { + Queue::fake(); + $this->application->update(['fqdn' => 'http://scheme.example.com']); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingDomainParts.scheme', 'https') + ->call('updateDomain') + ->assertHasNoErrors(); + + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://scheme.example.com'); +}); + +it('does not start a dns check when only the internal port changes', function () { + Queue::fake(); + $this->application->update([ + 'fqdn' => 'https://port.example.com:81', + 'ports_exposes' => '81,82', + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingDomainParts.port', '82') + ->call('updateDomain') + ->assertHasNoErrors(); + + Queue::assertNotPushed(CheckDomainDnsJob::class); +}); + +it('regenerates configured www pairs together and preserves each url settings', function () { + $this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']); + $this->application->update([ + 'fqdn' => 'http://app.example.com:8080/api,https://www.app.example.com:9090/admin', + 'redirect' => 'www', + 'noindex_domains' => ['https://www.app.example.com/admin'], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->call('regenerateEditingDomain'); + + $generatedHost = $component->get('editingDomainParts')['host']; + + $component->call('updateDomain')->assertHasNoErrors(); + + $application = $this->application->fresh(); + expect(explode(',', $application->fqdn))->toBe([ + "http://{$generatedHost}/api", + "https://www.{$generatedHost}/admin", + ])->and($application->noindexDomains()->all())->toBe([ + "https://www.{$generatedHost}/admin", + ])->and($application->domain_port_overrides)->toMatchArray([ + "http://{$generatedHost}/api" => 8080, + "https://www.{$generatedHost}/admin" => 9090, + ]); +}); + +it('does not create a missing redirect counterpart while regenerating one domain', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'redirect' => 'www', + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->call('regenerateEditingDomain'); + + $generatedHost = $component->get('editingDomainParts')['host']; + $component->call('updateDomain')->assertHasNoErrors(); + + expect($this->application->fresh()->fqdn)->toBe("https://{$generatedHost}"); +}); + +it('saves regenerated compose domain drafts for only the selected service', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com/api', 'redirect' => 'both'], + 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], + ]), + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + $webIndex = collect($component->get('domainRows'))->search( + fn (array $row): bool => ($row['service'] ?? null) === 'web' && ! ($row['is_suggested'] ?? false) + ); + + $component + ->call('startEdit', $webIndex) + ->set('editingIndexing', 'noindex') + ->set('editingRedirect', 'www') + ->call('regenerateEditingDomain'); + + $generatedHost = $component->get('editingDomainParts')['host']; + + $component->call('updateDomain')->assertHasNoErrors(); + + $application = $this->application->fresh(); + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains['web']['domain'])->toBe("https://{$generatedHost}/api,https://www.{$generatedHost}/api") + ->and($domains['web']['redirect'])->toBe('www') + ->and($domains['api'])->toMatchArray(['domain' => 'https://api.example.com', 'redirect' => 'both']) + ->and($application->noindexDomains()->all())->toBe(["https://{$generatedHost}/api"]); +}); + it('does not render a last checked column in the domains table', function () { $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); $row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php')); @@ -2146,18 +2367,15 @@ it('updates search engine indexing from the domains view', function () { ->assertSee('Indexable') ->assertSee('Search engine indexing') ->assertSee('www redirect') - ->assertSee('toggleNoindexDomain', false) - ->assertSee('updateRedirect', false) - ->assertSee('wire:ignore', false) - ->assertDontSee('x-model="localIndexing"', false) - ->assertDontSee('x-model="localDirection"', false) - ->assertDontSee('@js(', false) - ->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex') + ->assertSee('editingIndexing', false) + ->assertDontSee('toggleNoindexDomain', false) + ->set('editingIndexing', 'noindex') + ->call('updateDomain') ->assertDispatched('configurationChanged') ->assertDispatched('success'); expect($this->application->refresh()->noindexDomains()->all()) - ->toBe(['https://staging.example.com']); + ->toBe(['https://app.example.com']); }); it('updates search engine indexing for a git docker compose domain', function () { diff --git a/tests/Feature/PreviewDomainPortOverridesTest.php b/tests/Feature/PreviewDomainPortOverridesTest.php index b8825353b0..ec8cb12e35 100644 --- a/tests/Feature/PreviewDomainPortOverridesTest.php +++ b/tests/Feature/PreviewDomainPortOverridesTest.php @@ -1,5 +1,6 @@ not->toContain('wire:click="startEdit(') + ->toContain('@click="openEditDomain(') + ->toContain('not->toContain('withoutVite(); config(['app.maintenance.driver' => 'file']); @@ -90,6 +102,80 @@ function createPreviewForPortTests(Application $application, int $pullRequestId, ], $attributes)); } +it('regenerates an existing preview domain only when the modal is saved', function () { + $preview = createPreviewForPortTests($this->application, 100, [ + 'fqdn' => 'http://preview.example.com:8080/api', + ]); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('startEdit', 0) + ->call('regenerateEditingDomain') + ->assertSet('editingDomainParts.scheme', 'http') + ->assertSet('editingDomainParts.port', '8080') + ->assertSet('editingDomainParts.path', '/api'); + + $generatedHost = $component->get('editingDomainParts')['host']; + + expect($preview->fresh()->fqdn)->toBe('http://preview.example.com/api'); + + $component->call('updateDomain')->assertHasNoErrors(); + + expect($preview->fresh()->fqdn)->toBe("http://{$generatedHost}/api") + ->and($preview->fresh()->domain_port_overrides)->toHaveKey("http://{$generatedHost}/api", 8080); +}); + +it('runs a dns check after a manually edited preview domain is saved', function () { + Queue::fake(); + $preview = createPreviewForPortTests($this->application, 99, [ + 'fqdn' => 'https://old-preview.example.com:81', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('startEdit', 0) + ->assertSet('editingDomainParts.port', '81') + ->set('editingDomainParts.host', 'manual-preview.example.com') + ->call('updateDomain') + ->assertSet('domainRows.0.dns_status', 'checking'); + + expect(collect($preview->fresh()->domain_dns_statuses)->contains( + fn (array $status): bool => ($status['status'] ?? null) === 'checking' + ))->toBeTrue(); + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-preview.example.com' + && $job->statusKey === hash('sha256', 'https://manual-preview.example.com|')); +}); + +it('uses the dns badge as progress for single and all preview checks', function (string $action, array $parameters) { + Queue::fake(); + $preview = createPreviewForPortTests($this->application, 98, [ + 'fqdn' => 'https://badge-preview.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call($action, ...$parameters) + ->assertSet('domainRows.0.dns_status', 'checking') + ->assertSee('Checking DNS...') + ->assertSeeHtml('loading-indicator'); + + Queue::assertPushed(CheckDomainDnsJob::class, 1); +})->with([ + 'single domain' => ['checkDomainDns', [0]], + 'all domains' => ['checkAllDns', []], +]); + +it('does not start a dns check when a preview domain address is unchanged', function () { + Queue::fake(); + $preview = createPreviewForPortTests($this->application, 100, [ + 'fqdn' => 'https://unchanged-preview.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('startEdit', 0) + ->call('updateDomain') + ->assertHasNoErrors(); + + Queue::assertNotPushed(CheckDomainDnsJob::class); +}); + it('saves preview domain port overrides separately from the public FQDN', function () { $preview = createPreviewForPortTests($this->application, 101); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index ef61912f07..581972b864 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -204,14 +204,15 @@ it('opens address fields and service-wide redirects in the same settings dialog $html = $component->call('startEdit', $index) ->assertSet('editingDomain', $domain) ->assertSee('Domain settings') - ->assertSee('Save changes') + ->assertSee('Save') + ->assertSee('Regenerate hostname') ->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); + expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0); } }); @@ -345,7 +346,7 @@ it('saves the explicitly selected service redirect value', function () { ->call('updateServiceRedirect', $this->webApp->id, 'www') ->assertDispatched('success') ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com')) - ->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null)) + ->assertSet('domainRows', fn (array $rows): bool => (collect($rows)->firstWhere('url', 'https://www.web.example.com')['dns_status'] ?? null) === 'checking') ->assertSee('https://www.web.example.com'); expect($this->webApp->fresh()->redirect)->toBe('www'); @@ -862,13 +863,10 @@ it('updates search engine indexing from the service domains view', function () { ->assertSee('Indexable') ->assertSee('Search engine indexing') ->assertSee('www redirect') - ->assertSee('toggleNoindexDomain', false) - ->assertSee('updateServiceRedirect', false) - ->assertSee('wire:ignore', false) - ->assertDontSee('x-model="localIndexing"', false) - ->assertDontSee('x-model="localDirection"', false) - ->assertDontSee('@js(', false) - ->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex') + ->assertSee('editingIndexing', false) + ->assertDontSee('toggleNoindexDomain', false) + ->set('editingIndexing', 'noindex') + ->call('updateDomain') ->assertDispatched('configurationChanged') ->assertDispatched('success') ->assertSet('service', fn (Service $service): bool => $service->applications @@ -882,6 +880,70 @@ it('updates search engine indexing from the service domains view', function () { ->not->toContain(' $this->service->fresh(['applications', 'server'])]) + ->call('startEdit', 0) + ->set('editingIndexing', 'noindex') + ->call('regenerateEditingDomain'); + + $generatedHost = $component->get('editingDomainParts')['host']; + + expect($generatedHost)->not->toBe('api.example.com') + ->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com'); + + $component->call('updateDomain')->assertHasNoErrors(); + + expect($this->apiApp->fresh()->fqdn)->toBe("https://{$generatedHost}") + ->and($this->apiApp->fresh()->noindexDomains()->all())->toBe(["https://{$generatedHost}"]); +}); + +it('starts a dns check after a manually edited service domain is saved', function () { + Queue::fake(); + $settings = InstanceSettings::get(); + $settings->is_dns_validation_enabled = true; + $settings->save(); + $this->apiApp->update(['fqdn' => 'https://api.example.com:81']); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->call('startEdit', 0) + ->assertSet('editingDomainParts.port', '81') + ->set('editingDomainParts.host', 'manual-service.example.com') + ->call('updateDomain') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains( + fn (array $row): bool => $row['url'] === 'https://manual-service.example.com' && $row['dns_status'] === 'checking' + )); + + expect($this->apiApp->fresh()->domain_dns_statuses['https://manual-service.example.com']['status'] ?? null)->toBe('checking'); + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-service.example.com' + && $job->statusKey === 'https://manual-service.example.com'); +}); + +it('does not start a dns check when only service domain settings change', function () { + Queue::fake(); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->call('startEdit', 0) + ->set('editingIndexing', 'noindex') + ->call('updateDomain') + ->assertHasNoErrors(); + + Queue::assertNotPushed(CheckDomainDnsJob::class); +}); + +it('checks the counterpart added by a service domain redirect change', function () { + Queue::fake(); + + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->call('startEdit', 0) + ->set('editingRedirect', 'www') + ->call('updateDomain') + ->assertHasNoErrors(); + + expect(explode(',', (string) $this->apiApp->fresh()->fqdn))->toContain('https://www.api.example.com'); + Queue::assertPushed(CheckDomainDnsJob::class, 1); + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.api.example.com'); +}); + it('keeps noindex domains when normalizing a custom service domain port', function () { $this->apiApp->update([ 'fqdn' => 'https://api.example.com:8080', @@ -961,7 +1023,7 @@ it('prioritizes public addresses and moves domain configuration behind settings' ->assertSee('Add domain') ->assertSee('Domain settings') ->call('startEdit', 0) - ->assertSee('Indexing and redirect changes save automatically.') + ->assertDontSee('Indexing and redirect changes save automatically.') ->assertSee('Internal port 8080') ->assertSee('Both www and non-www') ->assertSee('Search indexing allowed') @@ -1030,15 +1092,40 @@ it('uses the shared mobile domain summary layout', function () { ->toContain('Noindex'); }); -it('reuses the floating save bar for pending domain address edits', function () { +it('uses explicit modal actions for pending domain edits', function () { $view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php')); - expect($view)->toContain('toContain('dirty="hasAddressChanges"') - ->toContain('