mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 01:40:26 -04:00
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:
@@ -12,3 +12,32 @@
|
||||
|
||||
## Prove regressions against the unchanged baseline
|
||||
- For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test.
|
||||
|
||||
## Apply shared domain UX to every supported resource type
|
||||
- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation.
|
||||
- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify.
|
||||
|
||||
## Verify manual and generated domain paths separately
|
||||
- Domain regeneration and manual hostname edits must start the same post-save DNS check.
|
||||
- Add explicit regression coverage for both entry paths across every active domain editor.
|
||||
|
||||
## Do not treat a runtime restart as behavior verification
|
||||
- A healthy restarted container proves only that the process started.
|
||||
- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works.
|
||||
|
||||
## Prove the reported live flow before reporting a UI fix
|
||||
- Do not use unit tests or a healthy process as proof for a reported live UI failure.
|
||||
- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works.
|
||||
|
||||
## Start DNS checks only for DNS-relevant edits
|
||||
- Compare the previous and saved scheme and hostname before a post-save DNS check.
|
||||
- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes.
|
||||
|
||||
## Include automatically added domains in post-save DNS checks
|
||||
- Compare the configured domain list before and after Save.
|
||||
- Start checks for each newly added counterpart, even when the edited domain itself did not change.
|
||||
|
||||
## Use one DNS progress pattern
|
||||
- All DNS check entry points must set the domain badge to the same `checking` state.
|
||||
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
|
||||
- Verify the rendered badge uses the spinner slot instead of the default status dot.
|
||||
|
||||
@@ -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 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|required|in:index,noindex',
|
||||
'editingRedirect' => 'string|required|in:both,www,non-www',
|
||||
'redirect' => 'string|required|in:both,www,non-www',
|
||||
'isForceHttpsEnabled' => 'boolean',
|
||||
'serviceRedirects' => 'array',
|
||||
@@ -692,41 +704,8 @@ class Domains extends Component
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->isCheckingDns = true;
|
||||
|
||||
try {
|
||||
$server = $this->application->destination?->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$reason = ! $this->dnsValidationEnabled
|
||||
? 'DNS validation is disabled in instance settings.'
|
||||
: ($this->application->additional_servers->count() > 0
|
||||
? 'DNS check skipped for multi-server applications.'
|
||||
: 'No server available for DNS validation.');
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = $reason;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
} finally {
|
||||
$this->isCheckingDns = false;
|
||||
foreach ($this->domainRows as $row) {
|
||||
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,18 +717,8 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $this->application->destination?->server;
|
||||
if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistDomainDnsStatuses();
|
||||
$row = $this->domainRows[$index];
|
||||
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
@@ -1214,6 +1183,34 @@ class Domains extends Component
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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 @@ class Domains extends Component
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editingRedirect = $this->serviceRedirectFor($this->editingService);
|
||||
$this->editingOriginalRedirect = $this->editingRedirect;
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
$this->showEditDomainModal = true;
|
||||
@@ -1372,6 +1374,11 @@ class Domains extends Component
|
||||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = null;
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editingRedirect = 'both';
|
||||
$this->editingOriginalRedirect = 'both';
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
if ($this->pendingAction === 'update') {
|
||||
@@ -1387,6 +1394,39 @@ class Domains extends Component
|
||||
$this->updateDomain();
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = data_get($this->application, 'destination.server');
|
||||
if (! $server) {
|
||||
$this->dispatch('error', 'No server found for this application.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($generatedHost) || $generatedHost === '') {
|
||||
$this->dispatch('error', 'Could not generate a domain.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currentHost = (string) ($this->editingDomainParts['host'] ?? '');
|
||||
$this->editingGeneratedHost = $generatedHost;
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.')
|
||||
? 'www.'.$generatedHost
|
||||
: $generatedHost;
|
||||
$this->editingDomainPartsChanged = true;
|
||||
$this->editingDomainWasRegenerated = true;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
}
|
||||
|
||||
public function updateDomain(): void
|
||||
{
|
||||
try {
|
||||
@@ -1406,6 +1446,8 @@ class Domains extends Component
|
||||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
$this->validateOnly('editingIndexing');
|
||||
$this->validateOnly('editingRedirect');
|
||||
|
||||
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
|
||||
if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) {
|
||||
@@ -1417,8 +1459,6 @@ class Domains extends Component
|
||||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$service = $this->editingService;
|
||||
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $this->application->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
@@ -1442,29 +1482,69 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->editDomainDnsFailed = true;
|
||||
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
|
||||
$this->showEditDomainModal = true;
|
||||
|
||||
return;
|
||||
$replacements = [$oldUrl => $newUrl];
|
||||
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
|
||||
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
|
||||
if (is_string($oldCounterpart)) {
|
||||
$counterpartParts = DomainUrlParts::split($oldCounterpart);
|
||||
$counterpartPort = $this->currentRowPort($oldCounterpart);
|
||||
if ($counterpartPort !== null) {
|
||||
$counterpartParts['port'] = (string) $counterpartPort;
|
||||
}
|
||||
$counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.')
|
||||
? 'www.'.$this->editingGeneratedHost
|
||||
: $this->editingGeneratedHost;
|
||||
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts);
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
|
||||
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
|
||||
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
foreach ($updated->all() as $url) {
|
||||
$counterpart = $this->wwwCounterpartUrl($url, true);
|
||||
$counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
|
||||
$hasCounterpart = filled($counterpartHost) && $updated->contains(
|
||||
fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost
|
||||
);
|
||||
if (filled($counterpart) && ! $hasCounterpart) {
|
||||
$updated->push($counterpart);
|
||||
}
|
||||
}
|
||||
}
|
||||
$urlsToCheck = $updated
|
||||
->reject(fn (string $url): bool => $current->contains(
|
||||
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
|
||||
))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains();
|
||||
foreach ($replacements as $previousUrl => $replacementUrl) {
|
||||
$wasNoindexed = $previousUrl === $oldUrl
|
||||
? $this->editingIndexing === 'noindex'
|
||||
: $this->application->isDomainNoindexed($previousUrl);
|
||||
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($replacementUrl);
|
||||
}
|
||||
}
|
||||
if ($this->isCompose) {
|
||||
$allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true);
|
||||
$existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : [];
|
||||
$allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]);
|
||||
$this->application->docker_compose_domains = json_encode($allDomains);
|
||||
} else {
|
||||
$this->application->redirect = $this->editingRedirect;
|
||||
}
|
||||
|
||||
$this->pendingAction = 'update';
|
||||
if (! $this->saveDomainList($updated, $service)) {
|
||||
if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
|
||||
$this->forceSaveDomains = false;
|
||||
@@ -1474,7 +1554,9 @@ class Domains extends Component
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns([$newUrl], $service);
|
||||
if ($urlsToCheck !== []) {
|
||||
$this->queueUrlsDns($urlsToCheck, $service);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
@@ -1640,7 +1722,7 @@ class Domains extends Component
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains);
|
||||
$this->queueUrlsDns($addedDomains);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
@@ -1737,7 +1819,7 @@ class Domains extends Component
|
||||
$this->dispatch('success', "Redirect updated for {$serviceName}.");
|
||||
}
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceName);
|
||||
$this->queueUrlsDns($addedDomains, $serviceName);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
@@ -2002,6 +2084,7 @@ class Domains extends Component
|
||||
Collection $domains,
|
||||
?string $serviceName = null,
|
||||
bool $checkConflicts = true,
|
||||
?Collection $noindexDomains = null,
|
||||
): bool {
|
||||
$domainString = $domains->filter()->unique()->implode(',');
|
||||
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
|
||||
@@ -2055,6 +2138,10 @@ class Domains extends Component
|
||||
$this->application->fqdn = $domainString;
|
||||
}
|
||||
|
||||
if ($noindexDomains !== null) {
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
}
|
||||
|
||||
if ($checkConflicts && ! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
|
||||
@@ -176,6 +176,7 @@ class PreviewDomains extends Component
|
||||
return;
|
||||
}
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain);
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
@@ -188,17 +189,75 @@ class PreviewDomains extends Component
|
||||
$this->preview->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
$this->domainRows[$this->editingIndex]['url'] = $domain;
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
|
||||
$checkId = $dnsRelevantChange ? new_public_id() : null;
|
||||
if ($dnsRelevantChange) {
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'checking';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$this->editingIndex]['check_id'] = $checkId;
|
||||
}
|
||||
$index = $this->editingIndex;
|
||||
$this->editingIndex = null;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->domainRows[$index]['url'];
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->checkDomainDns($index);
|
||||
|
||||
if (! $dnsRelevantChange) {
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($domain, $this->domainRows[$index]['service']),
|
||||
$domain,
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
$this->dispatch('success', 'Domain updated. DNS check started.');
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
$this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $this->preview->application->destination?->server;
|
||||
if (! $server) {
|
||||
$this->dispatch('error', 'No server found for this preview.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->resetErrorBag('editingDomainParts.host');
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
@@ -263,16 +322,46 @@ class PreviewDomains extends Component
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
foreach (array_keys($this->domainRows) as $index) {
|
||||
$this->applyDnsCheck($index);
|
||||
$this->queueDnsCheck($index);
|
||||
}
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function checkDomainDns(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->applyDnsCheck($index);
|
||||
$this->queueDnsCheck($index);
|
||||
}
|
||||
|
||||
private function queueDnsCheck(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $this->domainRows[$index];
|
||||
$checkId = new_public_id();
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
$this->persistDnsStatuses();
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($row['url'], $row['service']),
|
||||
$row['url'],
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
|
||||
@@ -63,6 +63,16 @@ class Domains extends Component
|
||||
|
||||
public ?int $editingServiceApplicationId = null;
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public string $editingRedirect = 'both';
|
||||
|
||||
public string $editingOriginalRedirect = 'both';
|
||||
|
||||
public bool $editingDomainWasRegenerated = false;
|
||||
|
||||
public ?string $editingGeneratedHost = null;
|
||||
|
||||
public bool $showEditDomainModal = false;
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
@@ -113,6 +123,8 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|required|in:index,noindex',
|
||||
'editingRedirect' => 'string|required|in:both,www,non-www',
|
||||
'newServiceApplicationId' => 'nullable|integer',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
@@ -481,35 +493,11 @@ class Domains extends Component
|
||||
{
|
||||
$this->authorize('update', $this->service);
|
||||
|
||||
$this->isCheckingDns = true;
|
||||
|
||||
try {
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled
|
||||
? 'DNS validation is disabled in instance settings.'
|
||||
: 'No server available for DNS validation.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexesToCheck[] = $index;
|
||||
foreach ($this->domainRows as $row) {
|
||||
$application = $this->findServiceApp((int) $row['service_application_id']);
|
||||
if ($application) {
|
||||
$this->queueUrlsDns([$row['url']], $application);
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
} finally {
|
||||
$this->isCheckingDns = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,19 +509,11 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $this->service->server;
|
||||
if (! $server || ! $this->dnsValidationEnabled) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
return;
|
||||
$row = $this->domainRows[$index];
|
||||
$application = $this->findServiceApp((int) $row['service_application_id']);
|
||||
if ($application) {
|
||||
$this->queueUrlsDns([$row['url']], $application);
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
@@ -837,7 +817,7 @@ class Domains extends Component
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
|
||||
$this->queueUrlsDns($addedDomains, $app);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
@@ -1186,6 +1166,12 @@ class Domains extends Component
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId);
|
||||
$this->editingOriginalRedirect = $this->editingRedirect;
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -1202,6 +1188,11 @@ class Domains extends Component
|
||||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = null;
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editingRedirect = 'both';
|
||||
$this->editingOriginalRedirect = 'both';
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -1229,6 +1220,8 @@ class Domains extends Component
|
||||
$this->editingDomain = DomainUrlParts::compose(...$editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
$this->validateOnly('editingIndexing');
|
||||
$this->validateOnly('editingRedirect');
|
||||
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
if (! $app) {
|
||||
@@ -1245,8 +1238,6 @@ class Domains extends Component
|
||||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $app->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
@@ -1263,31 +1254,50 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDns()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->editDomainDnsFailed = true;
|
||||
$this->editDomainDnsMessage = $dnsFailure;
|
||||
$this->showEditDomainModal = true;
|
||||
|
||||
return;
|
||||
$replacements = [$oldUrl => $newUrl];
|
||||
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
|
||||
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
|
||||
if (is_string($oldCounterpart)) {
|
||||
$parts = DomainUrlParts::split($oldCounterpart);
|
||||
$port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null;
|
||||
$parts['port'] = filled($port) ? (string) $port : $parts['port'];
|
||||
$parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost;
|
||||
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts);
|
||||
}
|
||||
}
|
||||
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
|
||||
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
foreach ($updated->all() as $url) {
|
||||
$counterpart = $this->wwwCounterpartUrl($url, true);
|
||||
$host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
|
||||
if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) {
|
||||
$updated->push($counterpart);
|
||||
}
|
||||
}
|
||||
}
|
||||
$urlsToCheck = $updated
|
||||
->reject(fn (string $url): bool => $current->contains(
|
||||
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
|
||||
))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$noindexDomains = $app->noindexDomains();
|
||||
foreach ($replacements as $previousUrl => $replacementUrl) {
|
||||
$isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl);
|
||||
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
|
||||
if ($isNoindexed) {
|
||||
$noindexDomains->push($replacementUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
|
||||
$this->pendingAction = 'update';
|
||||
|
||||
if (! $this->saveDomainListForApp($app, $updated)) {
|
||||
if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
$app->save();
|
||||
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->forceSaveDomains = false;
|
||||
@@ -1295,7 +1305,9 @@ class Domains extends Component
|
||||
$this->pendingAction = null;
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns([$newUrl], (int) $app->id);
|
||||
if ($urlsToCheck !== []) {
|
||||
$this->queueUrlsDns($urlsToCheck, $app);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
@@ -1453,6 +1465,24 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->service);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingGeneratedHost = $host;
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
|
||||
$this->editingDomainPartsChanged = true;
|
||||
$this->editingDomainWasRegenerated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $domains
|
||||
*/
|
||||
@@ -1461,6 +1491,8 @@ class Domains extends Component
|
||||
Collection $domains,
|
||||
bool $checkConflicts = true,
|
||||
bool $checkPorts = true,
|
||||
?Collection $noindexDomains = null,
|
||||
?string $redirect = null,
|
||||
): bool {
|
||||
$domainString = $domains->filter()->unique()->implode(',');
|
||||
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
|
||||
@@ -1475,6 +1507,12 @@ class Domains extends Component
|
||||
}
|
||||
|
||||
$app->fqdn = $domainString;
|
||||
if ($noindexDomains !== null) {
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
}
|
||||
if ($redirect !== null) {
|
||||
$app->redirect = $redirect;
|
||||
}
|
||||
|
||||
if ($checkConflicts && ! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $app);
|
||||
@@ -1574,6 +1612,33 @@ class Domains extends Component
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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;
|
||||
|
||||
@@ -46,6 +46,15 @@ class DomainUrlParts
|
||||
];
|
||||
}
|
||||
|
||||
public static function hasDnsRelevantChange(string $oldUrl, string $newUrl): bool
|
||||
{
|
||||
$old = self::split($oldUrl);
|
||||
$new = self::split($newUrl);
|
||||
|
||||
return $old['scheme'] !== $new['scheme']
|
||||
|| strtolower($old['host']) !== strtolower($new['host']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scheme: string, host: string, port: string, path: string}
|
||||
*/
|
||||
|
||||
@@ -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 @@
|
||||
</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 @@
|
||||
</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 @@
|
||||
]" />
|
||||
</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
|
||||
|
||||
@@ -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 @@
|
||||
@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 @@
|
||||
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 @@
|
||||
</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" />
|
||||
|
||||
@@ -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 @@
|
||||
@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 @@
|
||||
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 @@
|
||||
</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 @@
|
||||
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 @@
|
||||
</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 @@
|
||||
['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>
|
||||
|
||||
@@ -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 @@
|
||||
@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 @@
|
||||
<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 @@
|
||||
</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>
|
||||
|
||||
@@ -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 @@
|
||||
@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 @@
|
||||
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 @@
|
||||
@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?"
|
||||
|
||||
@@ -103,7 +103,7 @@ it('uses safe domain validation rules on the domains form', function () {
|
||||
});
|
||||
|
||||
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('lists existing domains as individual rows', function () {
|
||||
it('shows the HTTP redirect control for HTTPS domains and persists changes', function () {
|
||||
$this->application->update(['fqdn' => 'https://app.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('isForceHttpsEnabled', true)
|
||||
->assertSee('Redirect HTTP to HTTPS')
|
||||
->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.')
|
||||
@@ -664,8 +664,9 @@ it('shows the compose service redirect control in domain settings', function ()
|
||||
->assertSee('www redirect')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0)
|
||||
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
|
||||
->and($html)->toContain('editingRedirect')
|
||||
->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -907,7 +908,7 @@ it('updates a domain in place via modal', function () {
|
||||
'fqdn' => 'https://old.example.com,https://keep.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
->assertSet('editingDomain', 'https://old.example.com')
|
||||
@@ -927,7 +928,7 @@ it('updates a domain in place via modal', function () {
|
||||
expect($this->application->fqdn)->toBe('https://new.example.com,https://keep.example.com');
|
||||
});
|
||||
|
||||
it('blocks editing a domain with bad dns until the user continues', function () {
|
||||
it('saves an edited domain and records its dns result without a confirmation gate', function () {
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
@@ -941,21 +942,14 @@ it('blocks editing a domain with bad dns until the user continues', function ()
|
||||
->set('editingDomainParts.scheme', 'https')
|
||||
->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('updateDomain')
|
||||
->assertSet('editDomainDnsFailed', true)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
->assertSee('DNS is not pointing to the right IP')
|
||||
->assertSee('Are you sure you want to save it anyway');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBe('https://old.example.com');
|
||||
|
||||
$component->call('confirmUpdateDomainDespiteDns')
|
||||
->assertSet('editDomainDnsFailed', false)
|
||||
->assertSet('showEditDomainModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid');
|
||||
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->and($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null)
|
||||
->toBe('failed');
|
||||
});
|
||||
|
||||
it('removes a domain', function () {
|
||||
@@ -1894,8 +1888,9 @@ it('uses the compact service domains layout for compose applications', function
|
||||
->not->toContain('<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 @@ it('shows a form save button at the bottom of application domain settings', func
|
||||
->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 @@ it('updates search engine indexing from the domains view', function () {
|
||||
->assertSee('Indexable')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
|
||||
->assertSee('editingIndexing', false)
|
||||
->assertDontSee('toggleNoindexDomain', false)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://staging.example.com']);
|
||||
->toBe(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('updates search engine indexing for a git docker compose domain', function () {
|
||||
|
||||
@@ -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\Team;
|
||||
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);
|
||||
|
||||
|
||||
@@ -204,14 +204,15 @@ it('opens address fields and service-wide redirects in the same settings dialog
|
||||
$html = $component->call('startEdit', $index)
|
||||
->assertSet('editingDomain', $domain)
|
||||
->assertSee('Domain settings')
|
||||
->assertSee('Save changes')
|
||||
->assertSee('Save')
|
||||
->assertSee('Regenerate hostname')
|
||||
->assertDontSee('Save address')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertDontSee('Edit address and port')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1);
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -345,7 +346,7 @@ it('saves the explicitly selected service redirect value', function () {
|
||||
->call('updateServiceRedirect', $this->webApp->id, 'www')
|
||||
->assertDispatched('success')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
|
||||
->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null))
|
||||
->assertSet('domainRows', fn (array $rows): bool => (collect($rows)->firstWhere('url', 'https://www.web.example.com')['dns_status'] ?? null) === 'checking')
|
||||
->assertSee('https://www.web.example.com');
|
||||
|
||||
expect($this->webApp->fresh()->redirect)->toBe('www');
|
||||
@@ -862,13 +863,10 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
->assertSee('Indexable')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertSee('editingIndexing', false)
|
||||
->assertDontSee('toggleNoindexDomain', false)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success')
|
||||
->assertSet('service', fn (Service $service): bool => $service->applications
|
||||
@@ -882,6 +880,70 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
->not->toContain('<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 @@ it('prioritizes public addresses and moves domain configuration behind settings'
|
||||
->assertSee('Add domain')
|
||||
->assertSee('Domain settings')
|
||||
->call('startEdit', 0)
|
||||
->assertSee('Indexing and redirect changes save automatically.')
|
||||
->assertDontSee('Indexing and redirect changes save automatically.')
|
||||
->assertSee('Internal port 8080')
|
||||
->assertSee('Both www and non-www')
|
||||
->assertSee('Search indexing allowed')
|
||||
@@ -1030,15 +1092,40 @@ it('uses the shared mobile domain summary layout', function () {
|
||||
->toContain('Noindex');
|
||||
});
|
||||
|
||||
it('reuses the floating save bar for pending domain address edits', function () {
|
||||
it('uses explicit modal actions for pending domain edits', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)->toContain('<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('renders each service domain group as a separate card', function () {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -240,29 +240,31 @@ it('uses compact application domains with unified settings and a form save butto
|
||||
->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 @@ it('edits Compose application domain redirects in the unified settings dialog',
|
||||
->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 @@ it('uses compact preview domains and opens only the selected preview settings',
|
||||
->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 @@ it('uses compact preview domains and opens only the selected preview settings',
|
||||
$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', '')
|
||||
|
||||
@@ -220,7 +220,8 @@ it('keeps domain settings out of the overview and supports editing and removal',
|
||||
->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 @@ it('keeps domain settings out of the overview and supports editing and removal',
|
||||
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 @@ it('keeps domain settings out of the overview and supports editing and removal',
|
||||
|
||||
$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();
|
||||
|
||||
Reference in New Issue
Block a user