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.
This commit is contained in:
Andras Bacsai 2026-09-10 14:00:50 +02:00
parent a0a8c86752
commit 6028461f92
15 changed files with 1000 additions and 290 deletions

View file

@ -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.

View file

@ -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<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
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<int, string> $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']) {

View file

@ -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

View file

@ -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<int, string> $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<int, string> $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;

View file

@ -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}
*/

View file

@ -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)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@endif
@ -77,7 +90,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
</div>
@endif
@can('update', $application)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
@ -316,33 +329,20 @@ class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain pb-
</x-callout>
@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
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
<div
class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="application-domain-indexing-{{ $editingKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$application->isDomainNoindexed($editingRow['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain" :onChangeArgs="[$editingRow['url']]" portal
<x-forms.listbox id="editingIndexing"
htmlId="application-domain-indexing" label="Search engine indexing" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="application-domain-direction-{{ $editingKey }}"
label="www redirect" :wire="false" preserveValue
:value="$isCompose ? ($serviceRedirects[$editingRedirectKey] ?? 'both') : $redirect"
:x-effect="'value = $wire.get('.json_encode($editingRedirectProperty).')'"
<x-forms.listbox id="editingRedirect"
htmlId="application-domain-direction" label="www redirect"
:helper="$isCompose ? 'Applies to all domains for this Compose service.' : 'Applies to all domains for this application.'"
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
:onChangeArgs="$isCompose ? [$editingRow['service']] : []" portal
portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
@ -350,12 +350,16 @@ class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 da
]" />
</div>
@endcan
@endif
@endunless
</div>
<div data-testid="domain-settings-footer"
class="shrink-0 border-t border-neutral-200 pt-4 dark:border-white/10">
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<x-forms.button type="button" wire:click="regenerateEditingDomain"
wire:target="regenerateEditingDomain">
Regenerate hostname
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
Continue

View file

@ -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
<div wire:key="domain-row-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
@ -117,6 +121,11 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@elseif ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@ -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">
<x-reicon name="refresh" class="size-3.5"
wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
@unless ($labelsAreWritable)
@if ($isSuggested)
@ -147,7 +152,8 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
</x-forms.button>
@endif
@else
<button type="button" wire:click="startEdit({{ $index }})"
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))"
class="icon-button shrink-0"
title="Domain settings" aria-label="Settings for {{ $publicUrl }}">
<x-reicon name="settings" class="size-3.5" />

View file

@ -7,16 +7,26 @@
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
&& !$wire.showPortWarningModal;
},
openEditDomain() {
editingServiceLabel: '',
openEditDomain(index, domain, parts, service) {
if (index !== undefined) {
$wire.set('editingIndex', index, false);
$wire.set('editingDomainParts', parts, false);
}
this.editingServiceLabel = service || '';
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
this.editOpen = true;
this.$nextTick(() => this.$refs.editForm.querySelector('input[required]')?.focus());
},
closeEditDomain() { this.editOpen = false; this.editingDomainBaseline = null; },
closeEditDomain(discardDraft = true) {
this.editOpen = false;
this.editingDomainBaseline = null;
if (discardDraft) this.$wire.cancelEdit();
},
matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); },
}"
@open-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) openEditDomain()"
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain()"
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain(false)"
@keydown.escape.window="if (editOpen && !$wire.showPortWarningModal) closeEditDomain()">
@if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'))
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@ -31,7 +41,7 @@ class="input h-8! w-full sm:w-64!" placeholder="Search services or domains" />
@endif
@can('update', $preview->application)
@if (count($domainRows) > 0)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
</x-forms.button>
@ -100,6 +110,10 @@ class="border-b border-neutral-200 bg-neutral-50 px-4 py-3 text-sm font-medium d
default => 'DNS unknown',
};
$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
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" x-show="matchesDomainSearch(@js(($row['service'] ?? '').' '.$row['url']))" class="env-table-item">
<div class="data-table-row service-domains-overview-grid">
@ -152,7 +166,14 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
</div>
<div class="service-domain-dns flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
@if ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
@endif
</div>
<div class="service-domain-actions flex items-center justify-end gap-1">
@can('update', $preview->application)
@ -160,12 +181,10 @@ 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">
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
<button type="button" wire:click="startEdit({{ $index }})"
<button type="button"
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']))"
class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ getFqdnWithoutPort($row['url']) }}">
<x-reicon name="settings" class="size-3.5" />
</button>
@ -212,12 +231,12 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
</header>
<div class="application-settings-section-body">
<form x-ref="editForm" wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="editOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges" targets="updateDomain,confirmUseUnknownPort" />
</template>
@if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null))
<x-forms.input label="Service" :value="$domainRows[$editingIndex]['service']" readonly />
@endif
<div x-show="editingServiceLabel" x-cloak>
<div class="mb-1.5 flex h-4 items-center">
<label class="mb-0! leading-4">Service</label>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<x-forms.domain-input id="editingDomainParts" />
<div class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<x-forms.listbox id="preview-domain-indexing-{{ $preview->id }}" label="Search engine indexing"
@ -233,6 +252,10 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
</div>
</form>
</div>
</div>

View file

@ -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)
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
@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)
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled"
wire:target="checkAllDns,checkDomainDns">
<x-reicon name="refresh" class="size-3.5" />
Check all DNS
@ -239,10 +252,6 @@ class="application-settings-form application-settings-section relative flex max-
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
<template x-if="modalOpen">
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
targets="updateDomain,confirmUpdateDomainDespiteDns" />
</template>
<div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service application</label>
@ -274,41 +283,32 @@ class="application-settings-form application-settings-section relative flex max-
</div>
@endif
</form>
@php
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
@endphp
@if ($editingRow)
@can('update', $service)
@php
$editingAppId = (int) $editingRow['service_application_id'];
$editingDomainKey = hash('sha256', $editingRow['url'].'|'.$editingAppId);
$editingNoindex = $service->applications->firstWhere('id', $editingAppId)?->isDomainNoindexed($editingRow['url']);
@endphp
<div wire:key="editing-domain-settings-{{ $editingDomainKey }}"
@can('update', $service)
<div
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
<x-forms.listbox id="service-domain-indexing-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="Search engine indexing" :wire="false" preserveValue
:value="$editingNoindex ? 'noindex' : 'index'"
onChange="toggleNoindexDomain"
:onChangeArgs="[$editingAppId, $editingRow['url']]" portal
<x-forms.listbox id="editingIndexing" htmlId="service-domain-indexing"
label="Search engine indexing" portal
:options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
<x-forms.listbox id="service-domain-direction-{{ $editingAppId }}-{{ $editingDomainKey }}"
label="www redirect" :wire="false" :value="$serviceRedirects[$editingAppId] ?? 'both'" preserveValue
x-effect="value = $wire.serviceRedirects[{{ $editingAppId }}] ?? 'both'"
<x-forms.listbox id="editingRedirect" htmlId="service-domain-direction"
label="www redirect"
helper="Applies to all domains for this service application."
onChange="updateServiceRedirect" :onChangeArgs="[$editingAppId]" portal
portal
:options="[
['value' => 'both', 'label' => 'No redirect'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@endcan
@endif
<div class="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
@unless ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="updateDomain" isHighlighted>Save</x-forms.button>
@endunless
</div>
@endcan
</div>
</div>

View file

@ -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
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
@ -150,6 +154,11 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@elseif ($row['dns_status'] === 'checking')
<x-status-badge dynamic :title="$row['dns_message']">
<x-loading compact aria-label="Checking DNS" />
<span class="truncate">Checking DNS...</span>
</x-status-badge>
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@ -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">
<x-reicon name="refresh" class="size-3.5"
wire:loading.remove.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-loading-on-button wire:loading.delay
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
<x-reicon name="refresh" class="size-3.5" />
</button>
@if ($isSuggested)
@if ($row['needs_force_add'] ?? false)
@ -184,7 +189,7 @@ class="h-7! shrink-0 px-2.5! text-[12px]!">
@endif
@else
<button type="button" class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ $publicUrl }}"
wire:click="startEdit({{ $index }})" wire:loading.attr="disabled" wire:target="startEdit">
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js((int) $row['service_application_id']), @js($serviceLabel), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))">
<x-reicon name="settings" class="size-3.5" />
</button>
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"

View file

@ -103,7 +103,7 @@
});
it('does not add a single-label hostname as an application domain', function () {
Livewire::test(Domains::class, ['application' => $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('<span>Last checked</span>')
->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('<x-unsaved-bar action="updateDomain"');
});
it('opens application domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) {
Queue::fake();
$this->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 () {

View file

@ -1,5 +1,6 @@
<?php
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Project\Application\PreviewDomains;
use App\Models\Application;
use App\Models\ApplicationPreview;
@ -12,11 +13,22 @@
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('opens preview domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
beforeEach(function () {
$this->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);

View file

@ -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('<select');
});
it('regenerates a service application domain only when the modal is saved', function () {
$component = Livewire::test(Domains::class, ['service' => $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('<x-unsaved-bar action="updateDomain"')
->toContain('dirty="hasAddressChanges"')
->toContain('<template x-if="modalOpen">')
->not->toContain('Save address');
expect($view)->not->toContain('<x-unsaved-bar action="updateDomain"')
->toContain('wire:click="regenerateEditingDomain"')
->toContain('wire:click="updateDomain"')
->toContain('Save');
});
it('opens service domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
});
it('uses the dns badge as progress for single and all service checks', function (string $action, array $parameters) {
Queue::fake();
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call($action, ...$parameters)
->assertSet('domainRows.0.dns_status', 'checking')
->assertSee('Checking DNS...')
->assertSeeHtml('loading-indicator');
Queue::assertPushed(CheckDomainDnsJob::class);
})->with([
'single domain' => ['checkDomainDns', [0]],
'all domains' => ['checkAllDns', []],
]);
it('inherits the counterpart internal port when enabling redirects without a port warning', function (?int $override, string $redirect) {
$this->service->update([
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_URL_WEB_80\n api:\n image: node:alpine\n",
@ -1147,5 +1234,5 @@
it('lays out the domain settings dropdowns in responsive columns', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')
->toContain('class="sm:col-span-2 text-[12px]');
->toContain('flex flex-wrap items-center justify-between gap-2');
});

View file

@ -240,29 +240,31 @@
->fill('#editingDomainParts-path', '/blog')
->click('[id^="application-domain-indexing-"][id$="-trigger"]')
->click('Noindex')
->assertSee('Search engine indexing updated.')
->assertDontSee('Search engine indexing updated.')
->click('Regenerate hostname')
->screenshot(filename: 'application-domain-unified-settings')
->click('Save')
->assertDontSee('Domain settings')
->assertSee('https://first.example.com/blog')
->assertSee('Internal port 8069')
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-domains-compact');
expect($this->application->fresh()->domain_port_overrides)
->toHaveKey('https://first.example.com/blog', 8069);
$savedDomain = str($this->application->fresh()->fqdn)->before(',')->toString();
expect($savedDomain)->not->toContain('first.example.com')
->and($this->application->fresh()->domain_port_overrides)->toHaveKey($savedDomain, 8069)
->and($this->application->fresh()->noindexDomains()->all())->toContain($savedDomain);
$page->click('[aria-label="Settings for https://second.example.com"]')
->fill('#editingDomainParts-path', '/discard')
->click('Reset')
->click('[aria-label="Close"]:visible')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-path', '')
->click('[aria-label="Close"]:visible')
->click('[wire\\:key="domain-row-'.md5('https://first.example.com/blog|').'"] [aria-label="Remove domain"]')
->click('[wire\\:key="domain-row-'.md5($savedDomain.'|').'"] [aria-label="Remove domain"]')
->assertSee('Remove domain?')
->click('button:has([x-text="step2ButtonText"]):visible')
->assertDontSee('https://first.example.com/blog')
->assertDontSee($savedDomain)
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-host', 'second.example.com')
->click('[aria-label="Close"]:visible')
@ -292,14 +294,16 @@
->click('[id^="application-domain-direction-"][id$="-trigger"]')
->click('Redirect to www')
->assertDontSee('Use a different port?')
->assertSee('Redirect updated for web.api.')
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-compose-domain-settings');
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
$page->click('[aria-label="Close"]:visible')
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('both');
$page->click('Save')
->assertDontSee('Domain settings')
->assertSee('https://www.web.example.com')
->screenshot(filename: 'application-compose-domain-overview');
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
});
it('uses compact preview domains and opens only the selected preview settings', function (bool $isCompose) {
@ -332,9 +336,9 @@
->assertVisible('[aria-label="Search indexing blocked"] >> nth=0');
expect($page->script("[...document.querySelectorAll('[data-preview-domain-dialog]')].filter(el => el.getClientRects().length > 0).length"))->toBe(1);
$page->fill('#editingDomainParts-host:visible', 'renamed-preview.example.com')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->assertSee('Regenerate hostname')
->screenshot(filename: 'preview-domain-unified-settings')
->click('.is-dirty [wire\\:click="updateDomain"]')
->click('[data-preview-domain-dialog]:visible button:has-text("Save")')
->assertDontSee('Domain settings')
->assertSee('https://renamed-preview.example.com')
->assertSee('https://preview-102.example.com')
@ -343,9 +347,8 @@
$page->click('[aria-label="Settings for https://preview-102.example.com"]')
->assertSee('Domain settings')
->fill('#editingDomainParts-path:visible', '/discard')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'preview-domain-before-reset')
->click('.is-dirty button:has-text("Reset")')
->click('[data-preview-domain-dialog]:visible [aria-label="Close"]')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://preview-102.example.com"]')
->assertValue('#editingDomainParts-path:visible', '')

View file

@ -220,7 +220,8 @@
->assertValue('#editingDomainParts-port', '8080')
->assertDontSee('Edit address and port')
->assertDontSee('Save address')
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
->assertSee('Regenerate hostname')
->assertSee('Save')
->assertSee('Search engine indexing')
->screenshot(filename: 'service-domain-settings');
@ -233,24 +234,24 @@
JS))->toBeTrue();
$page->fill('#editingDomainParts-port', '80')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->screenshot(filename: 'service-domain-unsaved-changes');
$domainKey = hash('sha256', 'https://long-public-domain-for-the-service.example.com|'.$this->serviceApplication->id);
$page->click('#service-domain-indexing-'.$this->serviceApplication->id.'-'.$domainKey.'-trigger')
->click('Noindex')
->screenshot(filename: 'service-domain-indexing-saved')
->screenshot(filename: 'service-domain-indexing-draft')
->assertSee('Domain settings')
->assertVisible('[aria-label="Search indexing blocked"]')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->assertVisible('[aria-label="Search indexing allowed"]')
->assertNoJavaScriptErrors();
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeFalse();
$page->click('[wire\\:click="updateDomain"]')
$page->click('Save')
->assertDontSee('Domain settings')
->assertVisible('[aria-label="Internal port 80"] >> nth=0');
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
$page->click('[wire\\:key="svc-domain-'.$this->serviceApplication->id.'-'.md5('https://long-public-domain-for-the-service.example.com').'"] [aria-label="Remove domain"]')
->assertSee('Remove domain?')
->assertNoJavaScriptErrors()
@ -267,12 +268,10 @@
$page->click('[aria-label="Settings for https://second.example.com"]')
->fill('#editingDomainParts-path', '/discard-this')
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
->click('Reset')
->click('[aria-label="Close"]:visible')
->assertDontSee('Domain settings')
->click('[aria-label="Settings for https://second.example.com"]')
->assertValue('#editingDomainParts-path', '')
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
->click('[aria-label="Close"]:visible')
->assertDontSee('Domain settings')
->assertNoJavaScriptErrors();