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 89dce29f4..3a2dbb947 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,3 +12,32 @@ ## Displayed defaults must not become stored overrides ## 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 906cc147a..ae37e19cd 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 @@ protected function rules(): array 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 @@ public function checkAllDns(): void { $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 @@ public function checkDomainDns(int $index): void 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 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void $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 @@ public function startEdit(int $index): void } $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 @@ public function cancelEdit(): void $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 @@ public function confirmUpdateDomainDespiteDns(): void $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 @@ public function updateDomain(): void $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 @@ public function updateDomain(): void $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 @@ public function updateDomain(): void 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 @@ public function updateDomain(): void $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 @@ public function setRedirect(): void $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 @@ public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): vo $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 @@ protected function saveDomainList( 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 @@ protected function saveDomainList( $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 cba3f18b6..5f454d91b 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -176,6 +176,7 @@ public function updateDomain(): void 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 @@ public function updateDomain(): void $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 @@ public function checkAllDns(): void { $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 9774d20d3..56c9f651f 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 @@ protected function rules(): array 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 @@ public function checkAllDns(): void { $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 @@ public function checkDomainDns(int $index): void 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 @@ public function setServiceRedirect(int $serviceApplicationId, mixed ...$modalArg $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 @@ public function startEdit(int $index): void } $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 @@ public function cancelEdit(): void $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 @@ public function updateDomain(): void $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 @@ public function updateDomain(): void $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 @@ public function updateDomain(): void 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 @@ public function updateDomain(): void $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 @@ public function generateDomain(): void } } + 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 @@ protected function saveDomainListForApp( 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 @@ protected function saveDomainListForApp( } $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 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null): $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 c86d5fa9a..91dd609a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -46,6 +46,15 @@ public static function split(?string $url): array ]; } + 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 db0c9ba5e..dd5e344fe 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 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom @endif @can('update', $application) - + Check all DNS @@ -316,33 +329,20 @@ class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain pb- @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 bcacb44dc..ce973200f 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 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr 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 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS"> @endif @else - - @@ -212,12 +231,12 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
- - @if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null)) - - @endif +
+
+ +
+ +
+
+ Regenerate hostname + Save +
diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 0955e534c..ff6fdf66f 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 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom @endif @can('update', $service) @if ($configuredCount > 0) - Check all DNS @@ -239,10 +252,6 @@ class="application-settings-form application-settings-section relative flex max-
-
@@ -274,41 +283,32 @@ class="application-settings-form application-settings-section relative flex max-
@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 5b4c0d1e1..97127904e 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 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr 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 @@ class="h-7! shrink-0 px-2.5! text-[12px]!"> @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('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 @@ ->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 @@ '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 @@ 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 @@ ->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 @@ ->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 @@ ->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 @@ ->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 b8825353b..ec8cb12e3 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 ef61912f0..581972b86 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -204,14 +204,15 @@ $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 @@ ->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 @@ ->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 @@ ->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 @@ ->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 @@ ->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('