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('