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:
parent
a0a8c86752
commit
6028461f92
15 changed files with 1000 additions and 290 deletions
|
|
@ -12,3 +12,32 @@ ## Displayed defaults must not become stored overrides
|
|||
|
||||
## Prove regressions against the unchanged baseline
|
||||
- For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test.
|
||||
|
||||
## Apply shared domain UX to every supported resource type
|
||||
- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation.
|
||||
- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify.
|
||||
|
||||
## Verify manual and generated domain paths separately
|
||||
- Domain regeneration and manual hostname edits must start the same post-save DNS check.
|
||||
- Add explicit regression coverage for both entry paths across every active domain editor.
|
||||
|
||||
## Do not treat a runtime restart as behavior verification
|
||||
- A healthy restarted container proves only that the process started.
|
||||
- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works.
|
||||
|
||||
## Prove the reported live flow before reporting a UI fix
|
||||
- Do not use unit tests or a healthy process as proof for a reported live UI failure.
|
||||
- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works.
|
||||
|
||||
## Start DNS checks only for DNS-relevant edits
|
||||
- Compare the previous and saved scheme and hostname before a post-save DNS check.
|
||||
- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes.
|
||||
|
||||
## Include automatically added domains in post-save DNS checks
|
||||
- Compare the configured domain list before and after Save.
|
||||
- Start checks for each newly added counterpart, even when the edited domain itself did not change.
|
||||
|
||||
## Use one DNS progress pattern
|
||||
- All DNS check entry points must set the domain badge to the same `checking` state.
|
||||
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
|
||||
- Verify the rendered badge uses the spinner slot instead of the default status dot.
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ class Domains extends Component
|
|||
|
||||
public ?string $editingService = null;
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public string $editingRedirect = 'both';
|
||||
|
||||
public string $editingOriginalRedirect = 'both';
|
||||
|
||||
public bool $editingDomainWasRegenerated = false;
|
||||
|
||||
public ?string $editingGeneratedHost = null;
|
||||
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
|
||||
public array $domainRows = [];
|
||||
|
||||
|
|
@ -122,6 +132,8 @@ protected function rules(): array
|
|||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|required|in:index,noindex',
|
||||
'editingRedirect' => 'string|required|in:both,www,non-www',
|
||||
'redirect' => 'string|required|in:both,www,non-www',
|
||||
'isForceHttpsEnabled' => 'boolean',
|
||||
'serviceRedirects' => 'array',
|
||||
|
|
@ -692,41 +704,8 @@ public function checkAllDns(): void
|
|||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->isCheckingDns = true;
|
||||
|
||||
try {
|
||||
$server = $this->application->destination?->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$reason = ! $this->dnsValidationEnabled
|
||||
? 'DNS validation is disabled in instance settings.'
|
||||
: ($this->application->additional_servers->count() > 0
|
||||
? 'DNS check skipped for multi-server applications.'
|
||||
: 'No server available for DNS validation.');
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = $reason;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
} finally {
|
||||
$this->isCheckingDns = false;
|
||||
foreach ($this->domainRows as $row) {
|
||||
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -738,18 +717,8 @@ public function checkDomainDns(int $index): void
|
|||
return;
|
||||
}
|
||||
|
||||
$server = $this->application->destination?->server;
|
||||
if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistDomainDnsStatuses();
|
||||
$row = $this->domainRows[$index];
|
||||
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
|
|
@ -1214,6 +1183,34 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
|
|||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function queueUrlsDns(array $urls, ?string $service = null): void
|
||||
{
|
||||
foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) {
|
||||
$checkId = new_public_id();
|
||||
$this->markUrlsAsChecking([$url], $service, $checkId);
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->application,
|
||||
$statusKey,
|
||||
$url,
|
||||
$this->application->destination?->server,
|
||||
$this->serverIp,
|
||||
$checkId,
|
||||
$this->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$this->markUrlsDnsCheckUnavailable([$url], $service, $checkId);
|
||||
$this->persistDomainDnsStatuses();
|
||||
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function shouldValidateDnsForAdd(): bool
|
||||
{
|
||||
if (! $this->dnsValidationEnabled) {
|
||||
|
|
@ -1287,6 +1284,11 @@ public function startEdit(int $index): void
|
|||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editingRedirect = $this->serviceRedirectFor($this->editingService);
|
||||
$this->editingOriginalRedirect = $this->editingRedirect;
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
$this->showEditDomainModal = true;
|
||||
|
|
@ -1372,6 +1374,11 @@ public function cancelEdit(): void
|
|||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = null;
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editingRedirect = 'both';
|
||||
$this->editingOriginalRedirect = 'both';
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
if ($this->pendingAction === 'update') {
|
||||
|
|
@ -1387,6 +1394,39 @@ public function confirmUpdateDomainDespiteDns(): void
|
|||
$this->updateDomain();
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = data_get($this->application, 'destination.server');
|
||||
if (! $server) {
|
||||
$this->dispatch('error', 'No server found for this application.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($generatedHost) || $generatedHost === '') {
|
||||
$this->dispatch('error', 'Could not generate a domain.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currentHost = (string) ($this->editingDomainParts['host'] ?? '');
|
||||
$this->editingGeneratedHost = $generatedHost;
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.')
|
||||
? 'www.'.$generatedHost
|
||||
: $generatedHost;
|
||||
$this->editingDomainPartsChanged = true;
|
||||
$this->editingDomainWasRegenerated = true;
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
}
|
||||
|
||||
public function updateDomain(): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -1406,6 +1446,8 @@ public function updateDomain(): void
|
|||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
$this->validateOnly('editingIndexing');
|
||||
$this->validateOnly('editingRedirect');
|
||||
|
||||
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
|
||||
if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) {
|
||||
|
|
@ -1417,8 +1459,6 @@ public function updateDomain(): void
|
|||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$service = $this->editingService;
|
||||
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $this->application->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
|
|
@ -1442,29 +1482,69 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->editDomainDnsFailed = true;
|
||||
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
|
||||
$this->showEditDomainModal = true;
|
||||
|
||||
return;
|
||||
$replacements = [$oldUrl => $newUrl];
|
||||
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
|
||||
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
|
||||
if (is_string($oldCounterpart)) {
|
||||
$counterpartParts = DomainUrlParts::split($oldCounterpart);
|
||||
$counterpartPort = $this->currentRowPort($oldCounterpart);
|
||||
if ($counterpartPort !== null) {
|
||||
$counterpartParts['port'] = (string) $counterpartPort;
|
||||
}
|
||||
$counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.')
|
||||
? 'www.'.$this->editingGeneratedHost
|
||||
: $this->editingGeneratedHost;
|
||||
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts);
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
|
||||
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
|
||||
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
foreach ($updated->all() as $url) {
|
||||
$counterpart = $this->wwwCounterpartUrl($url, true);
|
||||
$counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
|
||||
$hasCounterpart = filled($counterpartHost) && $updated->contains(
|
||||
fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost
|
||||
);
|
||||
if (filled($counterpart) && ! $hasCounterpart) {
|
||||
$updated->push($counterpart);
|
||||
}
|
||||
}
|
||||
}
|
||||
$urlsToCheck = $updated
|
||||
->reject(fn (string $url): bool => $current->contains(
|
||||
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
|
||||
))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains();
|
||||
foreach ($replacements as $previousUrl => $replacementUrl) {
|
||||
$wasNoindexed = $previousUrl === $oldUrl
|
||||
? $this->editingIndexing === 'noindex'
|
||||
: $this->application->isDomainNoindexed($previousUrl);
|
||||
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($replacementUrl);
|
||||
}
|
||||
}
|
||||
if ($this->isCompose) {
|
||||
$allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true);
|
||||
$existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : [];
|
||||
$allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]);
|
||||
$this->application->docker_compose_domains = json_encode($allDomains);
|
||||
} else {
|
||||
$this->application->redirect = $this->editingRedirect;
|
||||
}
|
||||
|
||||
$this->pendingAction = 'update';
|
||||
if (! $this->saveDomainList($updated, $service)) {
|
||||
if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
|
||||
$this->forceSaveDomains = false;
|
||||
|
|
@ -1474,7 +1554,9 @@ public function updateDomain(): void
|
|||
$this->dispatch('edit-domain-saved');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns([$newUrl], $service);
|
||||
if ($urlsToCheck !== []) {
|
||||
$this->queueUrlsDns($urlsToCheck, $service);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
|
|
@ -1640,7 +1722,7 @@ public function setRedirect(): void
|
|||
$this->resetDefaultLabels();
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains);
|
||||
$this->queueUrlsDns($addedDomains);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
|
@ -1737,7 +1819,7 @@ public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): vo
|
|||
$this->dispatch('success', "Redirect updated for {$serviceName}.");
|
||||
}
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceName);
|
||||
$this->queueUrlsDns($addedDomains, $serviceName);
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
|
@ -2002,6 +2084,7 @@ protected function saveDomainList(
|
|||
Collection $domains,
|
||||
?string $serviceName = null,
|
||||
bool $checkConflicts = true,
|
||||
?Collection $noindexDomains = null,
|
||||
): bool {
|
||||
$domainString = $domains->filter()->unique()->implode(',');
|
||||
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
|
||||
|
|
@ -2055,6 +2138,10 @@ protected function saveDomainList(
|
|||
$this->application->fqdn = $domainString;
|
||||
}
|
||||
|
||||
if ($noindexDomains !== null) {
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
}
|
||||
|
||||
if ($checkConflicts && ! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain);
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
|
|
@ -188,17 +189,75 @@ public function updateDomain(): void
|
|||
$this->preview->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
$this->domainRows[$this->editingIndex]['url'] = $domain;
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
|
||||
$checkId = $dnsRelevantChange ? new_public_id() : null;
|
||||
if ($dnsRelevantChange) {
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'checking';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$this->editingIndex]['check_id'] = $checkId;
|
||||
}
|
||||
$index = $this->editingIndex;
|
||||
$this->editingIndex = null;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->domainRows[$index]['url'];
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->checkDomainDns($index);
|
||||
|
||||
if (! $dnsRelevantChange) {
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($domain, $this->domainRows[$index]['service']),
|
||||
$domain,
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
$this->dispatch('success', 'Domain updated. DNS check started.');
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
$this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $this->preview->application->destination?->server;
|
||||
if (! $server) {
|
||||
$this->dispatch('error', 'No server found for this preview.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->resetErrorBag('editingDomainParts.host');
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
|
|
@ -263,16 +322,46 @@ public function checkAllDns(): void
|
|||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
foreach (array_keys($this->domainRows) as $index) {
|
||||
$this->applyDnsCheck($index);
|
||||
$this->queueDnsCheck($index);
|
||||
}
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function checkDomainDns(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->applyDnsCheck($index);
|
||||
$this->queueDnsCheck($index);
|
||||
}
|
||||
|
||||
private function queueDnsCheck(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $this->domainRows[$index];
|
||||
$checkId = new_public_id();
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
$this->persistDnsStatuses();
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($row['url'], $row['service']),
|
||||
$row['url'],
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ class Domains extends Component
|
|||
|
||||
public ?int $editingServiceApplicationId = null;
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public string $editingRedirect = 'both';
|
||||
|
||||
public string $editingOriginalRedirect = 'both';
|
||||
|
||||
public bool $editingDomainWasRegenerated = false;
|
||||
|
||||
public ?string $editingGeneratedHost = null;
|
||||
|
||||
public bool $showEditDomainModal = false;
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
|
@ -113,6 +123,8 @@ protected function rules(): array
|
|||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|required|in:index,noindex',
|
||||
'editingRedirect' => 'string|required|in:both,www,non-www',
|
||||
'newServiceApplicationId' => 'nullable|integer',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
|
|
@ -481,35 +493,11 @@ public function checkAllDns(): void
|
|||
{
|
||||
$this->authorize('update', $this->service);
|
||||
|
||||
$this->isCheckingDns = true;
|
||||
|
||||
try {
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled
|
||||
? 'DNS validation is disabled in instance settings.'
|
||||
: 'No server available for DNS validation.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexesToCheck[] = $index;
|
||||
foreach ($this->domainRows as $row) {
|
||||
$application = $this->findServiceApp((int) $row['service_application_id']);
|
||||
if ($application) {
|
||||
$this->queueUrlsDns([$row['url']], $application);
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
} finally {
|
||||
$this->isCheckingDns = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -521,19 +509,11 @@ public function checkDomainDns(int $index): void
|
|||
return;
|
||||
}
|
||||
|
||||
$server = $this->service->server;
|
||||
if (! $server || ! $this->dnsValidationEnabled) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
return;
|
||||
$row = $this->domainRows[$index];
|
||||
$application = $this->findServiceApp((int) $row['service_application_id']);
|
||||
if ($application) {
|
||||
$this->queueUrlsDns([$row['url']], $application);
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
|
|
@ -837,7 +817,7 @@ public function setServiceRedirect(int $serviceApplicationId, mixed ...$modalArg
|
|||
$this->dispatch('configurationChanged');
|
||||
$this->pruneDomainDnsStatusesToCurrentDomains();
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
|
||||
$this->queueUrlsDns($addedDomains, $app);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
|
|
@ -1186,6 +1166,12 @@ public function startEdit(int $index): void
|
|||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId);
|
||||
$this->editingOriginalRedirect = $this->editingRedirect;
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
|
|
@ -1202,6 +1188,11 @@ public function cancelEdit(): void
|
|||
$this->editingDomainParts = DomainUrlParts::empty();
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = null;
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editingRedirect = 'both';
|
||||
$this->editingOriginalRedirect = 'both';
|
||||
$this->editingDomainWasRegenerated = false;
|
||||
$this->editingGeneratedHost = null;
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
|
|
@ -1229,6 +1220,8 @@ public function updateDomain(): void
|
|||
$this->editingDomain = DomainUrlParts::compose(...$editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
$this->validateOnly('editingIndexing');
|
||||
$this->validateOnly('editingRedirect');
|
||||
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
if (! $app) {
|
||||
|
|
@ -1245,8 +1238,6 @@ public function updateDomain(): void
|
|||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $app->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
|
|
@ -1263,31 +1254,50 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDns()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->editDomainDnsFailed = true;
|
||||
$this->editDomainDnsMessage = $dnsFailure;
|
||||
$this->showEditDomainModal = true;
|
||||
|
||||
return;
|
||||
$replacements = [$oldUrl => $newUrl];
|
||||
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
|
||||
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
|
||||
if (is_string($oldCounterpart)) {
|
||||
$parts = DomainUrlParts::split($oldCounterpart);
|
||||
$port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null;
|
||||
$parts['port'] = filled($port) ? (string) $port : $parts['port'];
|
||||
$parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost;
|
||||
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts);
|
||||
}
|
||||
}
|
||||
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
|
||||
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
|
||||
foreach ($updated->all() as $url) {
|
||||
$counterpart = $this->wwwCounterpartUrl($url, true);
|
||||
$host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
|
||||
if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) {
|
||||
$updated->push($counterpart);
|
||||
}
|
||||
}
|
||||
}
|
||||
$urlsToCheck = $updated
|
||||
->reject(fn (string $url): bool => $current->contains(
|
||||
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
|
||||
))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$noindexDomains = $app->noindexDomains();
|
||||
foreach ($replacements as $previousUrl => $replacementUrl) {
|
||||
$isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl);
|
||||
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
|
||||
if ($isNoindexed) {
|
||||
$noindexDomains->push($replacementUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
|
||||
$this->pendingAction = 'update';
|
||||
|
||||
if (! $this->saveDomainListForApp($app, $updated)) {
|
||||
if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
$app->save();
|
||||
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->forceSaveDomains = false;
|
||||
|
|
@ -1295,7 +1305,9 @@ public function updateDomain(): void
|
|||
$this->pendingAction = null;
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns([$newUrl], (int) $app->id);
|
||||
if ($urlsToCheck !== []) {
|
||||
$this->queueUrlsDns($urlsToCheck, $app);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
|
|
@ -1453,6 +1465,24 @@ public function generateDomain(): void
|
|||
}
|
||||
}
|
||||
|
||||
public function regenerateEditingDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->service);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingGeneratedHost = $host;
|
||||
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
|
||||
$this->editingDomainPartsChanged = true;
|
||||
$this->editingDomainWasRegenerated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $domains
|
||||
*/
|
||||
|
|
@ -1461,6 +1491,8 @@ protected function saveDomainListForApp(
|
|||
Collection $domains,
|
||||
bool $checkConflicts = true,
|
||||
bool $checkPorts = true,
|
||||
?Collection $noindexDomains = null,
|
||||
?string $redirect = null,
|
||||
): bool {
|
||||
$domainString = $domains->filter()->unique()->implode(',');
|
||||
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
|
||||
|
|
@ -1475,6 +1507,12 @@ protected function saveDomainListForApp(
|
|||
}
|
||||
|
||||
$app->fqdn = $domainString;
|
||||
if ($noindexDomains !== null) {
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
}
|
||||
if ($redirect !== null) {
|
||||
$app->redirect = $redirect;
|
||||
}
|
||||
|
||||
if ($checkConflicts && ! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $app);
|
||||
|
|
@ -1574,6 +1612,33 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null):
|
|||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function queueUrlsDns(array $urls, ServiceApplication $application): void
|
||||
{
|
||||
foreach (array_unique($urls) as $url) {
|
||||
$checkId = new_public_id();
|
||||
$this->markUrlsAsChecking([$url], (int) $application->id, $checkId);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$application,
|
||||
$url,
|
||||
$url,
|
||||
$this->service->server,
|
||||
$this->serverIp,
|
||||
$checkId,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$this->markUrlsDnsCheckUnavailable([$url], (int) $application->id, $checkId);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function shouldValidateDns(): bool
|
||||
{
|
||||
return $this->dnsValidationEnabled && $this->service->server !== null;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ public static function split(?string $url): array
|
|||
];
|
||||
}
|
||||
|
||||
public static function hasDnsRelevantChange(string $oldUrl, string $newUrl): bool
|
||||
{
|
||||
$old = self::split($oldUrl);
|
||||
$new = self::split($newUrl);
|
||||
|
||||
return $old['scheme'] !== $new['scheme']
|
||||
|| strtolower($old['host']) !== strtolower($new['host']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scheme: string, host: string, port: string, path: string}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,14 +16,27 @@
|
|||
domainSearch: '',
|
||||
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
|
||||
editingServiceLabel: @js($editingService ?? ''),
|
||||
openEditDomain() {
|
||||
this.editingServiceLabel = $wire.editingService || '';
|
||||
openEditDomain(index, domain, parts, service, indexing, redirect) {
|
||||
if (index !== undefined) {
|
||||
$wire.set('editingIndex', index, false);
|
||||
$wire.set('editingDomain', domain, false);
|
||||
$wire.set('editingDomainParts', parts, false);
|
||||
$wire.set('editingDomainPartsChanged', false, false);
|
||||
$wire.set('editingService', service, false);
|
||||
$wire.set('editingIndexing', indexing, false);
|
||||
$wire.set('editingRedirect', redirect, false);
|
||||
$wire.set('editingOriginalRedirect', redirect, false);
|
||||
$wire.set('editingDomainWasRegenerated', false, false);
|
||||
$wire.set('editingGeneratedHost', null, false);
|
||||
}
|
||||
this.editingServiceLabel = service ?? $wire.editingService ?? '';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
|
||||
},
|
||||
closeEditDomain() {
|
||||
closeEditDomain(discardDraft = true) {
|
||||
this.modalOpen = false;
|
||||
this.editingServiceLabel = '';
|
||||
if (discardDraft) this.$wire.cancelEdit();
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
|
||||
|
|
@ -33,7 +46,7 @@
|
|||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@edit-domain-saved.window="closeEditDomain(false)">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
|
|
@ -77,7 +90,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
|
|||
</div>
|
||||
@endif
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
|
||||
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Check all DNS
|
||||
</x-forms.button>
|
||||
|
|
@ -316,33 +329,20 @@ class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain pb-
|
|||
</x-callout>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
|
||||
@endphp
|
||||
@if ($editingRow && ! $labelsAreWritable)
|
||||
@unless ($labelsAreWritable)
|
||||
@can('update', $application)
|
||||
@php
|
||||
$editingKey = hash('sha256', $editingRow['url'].'|'.($editingRow['service'] ?? ''));
|
||||
$editingRedirectKey = $isCompose ? $this->serviceRedirectWireKey($editingRow['service']) : null;
|
||||
$editingRedirectProperty = $isCompose ? 'serviceRedirects.'.$editingRedirectKey : 'redirect';
|
||||
@endphp
|
||||
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
|
||||
<x-forms.listbox id="application-domain-indexing-{{ $editingKey }}"
|
||||
label="Search engine indexing" :wire="false" preserveValue
|
||||
:value="$application->isDomainNoindexed($editingRow['url']) ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain" :onChangeArgs="[$editingRow['url']]" portal
|
||||
<x-forms.listbox id="editingIndexing"
|
||||
htmlId="application-domain-indexing" label="Search engine indexing" portal
|
||||
:options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
<x-forms.listbox id="application-domain-direction-{{ $editingKey }}"
|
||||
label="www redirect" :wire="false" preserveValue
|
||||
:value="$isCompose ? ($serviceRedirects[$editingRedirectKey] ?? 'both') : $redirect"
|
||||
:x-effect="'value = $wire.get('.json_encode($editingRedirectProperty).')'"
|
||||
<x-forms.listbox id="editingRedirect"
|
||||
htmlId="application-domain-direction" label="www redirect"
|
||||
:helper="$isCompose ? 'Applies to all domains for this Compose service.' : 'Applies to all domains for this application.'"
|
||||
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
|
||||
:onChangeArgs="$isCompose ? [$editingRow['service']] : []" portal
|
||||
portal
|
||||
:options="[
|
||||
['value' => 'both', 'label' => 'No redirect'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
|
|
@ -350,12 +350,16 @@ class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 da
|
|||
]" />
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
<div data-testid="domain-settings-footer"
|
||||
class="shrink-0 border-t border-neutral-200 pt-4 dark:border-white/10">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<x-forms.button type="button" wire:click="regenerateEditingDomain"
|
||||
wire:target="regenerateEditingDomain">
|
||||
Regenerate hostname
|
||||
</x-forms.button>
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
|
||||
Continue
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
: $redirect;
|
||||
$isNoindexed = $application->isDomainNoindexed($row['url']);
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
|
||||
if ($row['has_port_override'] ?? false) {
|
||||
$editingParts['port'] = (string) $row['internal_port'];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div wire:key="domain-row-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
|
||||
|
|
@ -117,6 +121,11 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
@if ($row['dns_status'] === 'failed')
|
||||
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
|
||||
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
|
||||
@elseif ($row['dns_status'] === 'checking')
|
||||
<x-status-badge dynamic :title="$row['dns_message']">
|
||||
<x-loading compact aria-label="Checking DNS" />
|
||||
<span class="truncate">Checking DNS...</span>
|
||||
</x-status-badge>
|
||||
@else
|
||||
<x-status-badge :status="$dnsLabel" :type="$dnsType"
|
||||
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
|
||||
|
|
@ -129,11 +138,7 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
wire:loading.attr="disabled"
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns"
|
||||
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
||||
<x-reicon name="refresh" class="size-3.5"
|
||||
wire:loading.remove.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-loading-on-button wire:loading.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</button>
|
||||
@unless ($labelsAreWritable)
|
||||
@if ($isSuggested)
|
||||
|
|
@ -147,7 +152,8 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
|||
</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
<button type="button" wire:click="startEdit({{ $index }})"
|
||||
<button type="button"
|
||||
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))"
|
||||
class="icon-button shrink-0"
|
||||
title="Domain settings" aria-label="Settings for {{ $publicUrl }}">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -7,16 +7,26 @@
|
|||
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
|
||||
&& !$wire.showPortWarningModal;
|
||||
},
|
||||
openEditDomain() {
|
||||
editingServiceLabel: '',
|
||||
openEditDomain(index, domain, parts, service) {
|
||||
if (index !== undefined) {
|
||||
$wire.set('editingIndex', index, false);
|
||||
$wire.set('editingDomainParts', parts, false);
|
||||
}
|
||||
this.editingServiceLabel = service || '';
|
||||
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
|
||||
this.editOpen = true;
|
||||
this.$nextTick(() => this.$refs.editForm.querySelector('input[required]')?.focus());
|
||||
},
|
||||
closeEditDomain() { this.editOpen = false; this.editingDomainBaseline = null; },
|
||||
closeEditDomain(discardDraft = true) {
|
||||
this.editOpen = false;
|
||||
this.editingDomainBaseline = null;
|
||||
if (discardDraft) this.$wire.cancelEdit();
|
||||
},
|
||||
matchesDomainSearch(value) { return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); },
|
||||
}"
|
||||
@open-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) openEditDomain()"
|
||||
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain()"
|
||||
@close-preview-domain-edit.window="if ($event.detail.previewId === {{ $preview->id }}) closeEditDomain(false)"
|
||||
@keydown.escape.window="if (editOpen && !$wire.showPortWarningModal) closeEditDomain()">
|
||||
@if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'))
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
|
|
@ -31,7 +41,7 @@ class="input h-8! w-full sm:w-64!" placeholder="Search services or domains" />
|
|||
@endif
|
||||
@can('update', $preview->application)
|
||||
@if (count($domainRows) > 0)
|
||||
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
|
||||
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Check all DNS
|
||||
</x-forms.button>
|
||||
|
|
@ -100,6 +110,10 @@ class="border-b border-neutral-200 bg-neutral-50 px-4 py-3 text-sm font-medium d
|
|||
default => 'DNS unknown',
|
||||
};
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
|
||||
if ($row['has_port_override'] ?? false) {
|
||||
$editingParts['port'] = (string) $row['internal_port'];
|
||||
}
|
||||
@endphp
|
||||
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" x-show="matchesDomainSearch(@js(($row['service'] ?? '').' '.$row['url']))" class="env-table-item">
|
||||
<div class="data-table-row service-domains-overview-grid">
|
||||
|
|
@ -152,7 +166,14 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
</div>
|
||||
|
||||
<div class="service-domain-dns flex min-w-0 items-center">
|
||||
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
|
||||
@if ($row['dns_status'] === 'checking')
|
||||
<x-status-badge dynamic :title="$row['dns_message']">
|
||||
<x-loading compact aria-label="Checking DNS" />
|
||||
<span class="truncate">Checking DNS...</span>
|
||||
</x-status-badge>
|
||||
@else
|
||||
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="service-domain-actions flex items-center justify-end gap-1">
|
||||
@can('update', $preview->application)
|
||||
|
|
@ -160,12 +181,10 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
wire:loading.attr="disabled"
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns"
|
||||
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
||||
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-loading-on-button wire:loading.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</button>
|
||||
<button type="button" wire:click="startEdit({{ $index }})"
|
||||
<button type="button"
|
||||
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js($row['service']))"
|
||||
class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ getFqdnWithoutPort($row['url']) }}">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
|
|
@ -212,12 +231,12 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
|
|||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<form x-ref="editForm" wire:submit="updateDomain" class="flex flex-col gap-4">
|
||||
<template x-if="editOpen">
|
||||
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges" targets="updateDomain,confirmUseUnknownPort" />
|
||||
</template>
|
||||
@if ($editingIndex !== null && filled($domainRows[$editingIndex]['service'] ?? null))
|
||||
<x-forms.input label="Service" :value="$domainRows[$editingIndex]['service']" readonly />
|
||||
@endif
|
||||
<div x-show="editingServiceLabel" x-cloak>
|
||||
<div class="mb-1.5 flex h-4 items-center">
|
||||
<label class="mb-0! leading-4">Service</label>
|
||||
</div>
|
||||
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
|
||||
</div>
|
||||
<x-forms.domain-input id="editingDomainParts" />
|
||||
<div class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
|
||||
<x-forms.listbox id="preview-domain-indexing-{{ $preview->id }}" label="Search engine indexing"
|
||||
|
|
@ -233,6 +252,10 @@ class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 da
|
|||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
|
||||
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
|
||||
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,16 +26,29 @@
|
|||
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
|
||||
&& !$wire.showPortWarningModal && !$wire.showDomainConflictModal;
|
||||
},
|
||||
openEditDomain() {
|
||||
openEditDomain(index, domain, parts, serviceApplicationId, serviceLabel, indexing, redirect) {
|
||||
if (index !== undefined) {
|
||||
$wire.set('editingIndex', index, false);
|
||||
$wire.set('editingDomain', domain, false);
|
||||
$wire.set('editingDomainParts', parts, false);
|
||||
$wire.set('editingDomainPartsChanged', false, false);
|
||||
$wire.set('editingServiceApplicationId', serviceApplicationId, false);
|
||||
$wire.set('editingIndexing', indexing, false);
|
||||
$wire.set('editingRedirect', redirect, false);
|
||||
$wire.set('editingOriginalRedirect', redirect, false);
|
||||
$wire.set('editingDomainWasRegenerated', false, false);
|
||||
$wire.set('editingGeneratedHost', null, false);
|
||||
}
|
||||
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
|
||||
this.editingServiceLabel = $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name || '';
|
||||
this.editingServiceLabel = serviceLabel ?? $wire.serviceApps.find(app => app.id === $wire.editingServiceApplicationId)?.name ?? '';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
|
||||
},
|
||||
closeEditDomain() {
|
||||
closeEditDomain(discardDraft = true) {
|
||||
this.modalOpen = false;
|
||||
this.editingDomainBaseline = null;
|
||||
this.editingServiceLabel = '';
|
||||
if (discardDraft) this.$wire.cancelEdit();
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
|
||||
|
|
@ -45,7 +58,7 @@
|
|||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@edit-domain-saved.window="closeEditDomain(false)">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
|
|
@ -77,7 +90,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
|
|||
@endif
|
||||
@can('update', $service)
|
||||
@if ($configuredCount > 0)
|
||||
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
|
||||
<x-forms.button wire:click="checkAllDns" :showLoadingIndicator="false" wire:loading.attr="disabled"
|
||||
wire:target="checkAllDns,checkDomainDns">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Check all DNS
|
||||
|
|
@ -239,10 +252,6 @@ class="application-settings-form application-settings-section relative flex max-
|
|||
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
<form wire:submit="updateDomain" class="flex flex-col gap-4">
|
||||
<template x-if="modalOpen">
|
||||
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
|
||||
targets="updateDomain,confirmUpdateDomainDespiteDns" />
|
||||
</template>
|
||||
<div x-show="editingServiceLabel" x-cloak class="w-full">
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service application</label>
|
||||
|
|
@ -274,41 +283,32 @@ class="application-settings-form application-settings-section relative flex max-
|
|||
</div>
|
||||
@endif
|
||||
</form>
|
||||
@php
|
||||
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
|
||||
@endphp
|
||||
@if ($editingRow)
|
||||
@can('update', $service)
|
||||
@php
|
||||
$editingAppId = (int) $editingRow['service_application_id'];
|
||||
$editingDomainKey = hash('sha256', $editingRow['url'].'|'.$editingAppId);
|
||||
$editingNoindex = $service->applications->firstWhere('id', $editingAppId)?->isDomainNoindexed($editingRow['url']);
|
||||
@endphp
|
||||
<div wire:key="editing-domain-settings-{{ $editingDomainKey }}"
|
||||
@can('update', $service)
|
||||
<div
|
||||
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
|
||||
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
|
||||
<x-forms.listbox id="service-domain-indexing-{{ $editingAppId }}-{{ $editingDomainKey }}"
|
||||
label="Search engine indexing" :wire="false" preserveValue
|
||||
:value="$editingNoindex ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain"
|
||||
:onChangeArgs="[$editingAppId, $editingRow['url']]" portal
|
||||
<x-forms.listbox id="editingIndexing" htmlId="service-domain-indexing"
|
||||
label="Search engine indexing" portal
|
||||
:options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
<x-forms.listbox id="service-domain-direction-{{ $editingAppId }}-{{ $editingDomainKey }}"
|
||||
label="www redirect" :wire="false" :value="$serviceRedirects[$editingAppId] ?? 'both'" preserveValue
|
||||
x-effect="value = $wire.serviceRedirects[{{ $editingAppId }}] ?? 'both'"
|
||||
<x-forms.listbox id="editingRedirect" htmlId="service-domain-direction"
|
||||
label="www redirect"
|
||||
helper="Applies to all domains for this service application."
|
||||
onChange="updateServiceRedirect" :onChangeArgs="[$editingAppId]" portal
|
||||
portal
|
||||
:options="[
|
||||
['value' => 'both', 'label' => 'No redirect'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/10">
|
||||
<x-forms.button type="button" wire:click="regenerateEditingDomain">Regenerate hostname</x-forms.button>
|
||||
@unless ($editDomainDnsFailed)
|
||||
<x-forms.button type="button" wire:click="updateDomain" isHighlighted>Save</x-forms.button>
|
||||
@endunless
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@
|
|||
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
|
||||
: null;
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? ''));
|
||||
$editingParts = \App\Support\DomainUrlParts::split($row['url']);
|
||||
if ($row['has_port_override'] ?? false) {
|
||||
$editingParts['port'] = (string) $row['internal_port'];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
|
||||
|
|
@ -150,6 +154,11 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
@if ($row['dns_status'] === 'failed')
|
||||
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
|
||||
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
|
||||
@elseif ($row['dns_status'] === 'checking')
|
||||
<x-status-badge dynamic :title="$row['dns_message']">
|
||||
<x-loading compact aria-label="Checking DNS" />
|
||||
<span class="truncate">Checking DNS...</span>
|
||||
</x-status-badge>
|
||||
@else
|
||||
<x-status-badge :status="$dnsLabel" :type="$dnsType"
|
||||
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
|
||||
|
|
@ -162,11 +171,7 @@ class="min-w-0 flex-1 truncate text-[13px] text-black underline decoration-neutr
|
|||
wire:loading.attr="disabled"
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns"
|
||||
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
||||
<x-reicon name="refresh" class="size-3.5"
|
||||
wire:loading.remove.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-loading-on-button wire:loading.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</button>
|
||||
@if ($isSuggested)
|
||||
@if ($row['needs_force_add'] ?? false)
|
||||
|
|
@ -184,7 +189,7 @@ class="h-7! shrink-0 px-2.5! text-[12px]!">
|
|||
@endif
|
||||
@else
|
||||
<button type="button" class="icon-button shrink-0" title="Domain settings" aria-label="Settings for {{ $publicUrl }}"
|
||||
wire:click="startEdit({{ $index }})" wire:loading.attr="disabled" wire:target="startEdit">
|
||||
@click="openEditDomain(@js($index), @js($row['url']), @js($editingParts), @js((int) $row['service_application_id']), @js($serviceLabel), @js($isNoindexed ? 'noindex' : 'index'), @js($rowDirection))">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@
|
|||
});
|
||||
|
||||
it('does not add a single-label hostname as an application domain', function () {
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomainParts.host', 'aaa')
|
||||
->call('addDomain')
|
||||
->assertDispatched('error');
|
||||
|
|
@ -627,7 +627,7 @@
|
|||
it('shows the HTTP redirect control for HTTPS domains and persists changes', function () {
|
||||
$this->application->update(['fqdn' => 'https://app.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSet('isForceHttpsEnabled', true)
|
||||
->assertSee('Redirect HTTP to HTTPS')
|
||||
->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.')
|
||||
|
|
@ -664,8 +664,9 @@
|
|||
->assertSee('www redirect')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0)
|
||||
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
|
||||
->and($html)->toContain('editingRedirect')
|
||||
->and(substr_count($html, 'application-domain-direction-'))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
@ -907,7 +908,7 @@
|
|||
'fqdn' => 'https://old.example.com,https://keep.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
->assertSet('editingDomain', 'https://old.example.com')
|
||||
|
|
@ -927,7 +928,7 @@
|
|||
expect($this->application->fqdn)->toBe('https://new.example.com,https://keep.example.com');
|
||||
});
|
||||
|
||||
it('blocks editing a domain with bad dns until the user continues', function () {
|
||||
it('saves an edited domain and records its dns result without a confirmation gate', function () {
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
|
|
@ -941,21 +942,14 @@
|
|||
->set('editingDomainParts.scheme', 'https')
|
||||
->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('updateDomain')
|
||||
->assertSet('editDomainDnsFailed', true)
|
||||
->assertSet('showEditDomainModal', true)
|
||||
->assertSee('DNS is not pointing to the right IP')
|
||||
->assertSee('Are you sure you want to save it anyway');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBe('https://old.example.com');
|
||||
|
||||
$component->call('confirmUpdateDomainDespiteDns')
|
||||
->assertSet('editDomainDnsFailed', false)
|
||||
->assertSet('showEditDomainModal', false)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid');
|
||||
expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->and($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null)
|
||||
->toBe('failed');
|
||||
});
|
||||
|
||||
it('removes a domain', function () {
|
||||
|
|
@ -1894,8 +1888,9 @@
|
|||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
|
||||
->toContain('id="application-domain-direction-{{ $editingKey }}"')
|
||||
->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
|
||||
->toContain('htmlId="application-domain-direction"')
|
||||
->toContain('id="editingRedirect"')
|
||||
->not->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
|
|
@ -1922,6 +1917,232 @@
|
|||
->not->toContain('<x-unsaved-bar action="updateDomain"');
|
||||
});
|
||||
|
||||
it('opens application domain settings from browser data and shows a dns spinner', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('wire:click="startEdit(')
|
||||
->toContain('@click="openEditDomain(')
|
||||
->toContain('<x-loading compact aria-label="Checking DNS"')
|
||||
->not->toContain('<x-loading-on-button wire:loading.delay');
|
||||
});
|
||||
|
||||
it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) {
|
||||
Queue::fake();
|
||||
$this->application->update(['fqdn' => 'https://badge.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call($action, ...$parameters)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSeeHtml('loading-indicator');
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
})->with([
|
||||
'single domain' => ['checkDomainDns', [0]],
|
||||
'all domains' => ['checkAllDns', []],
|
||||
]);
|
||||
|
||||
it('keeps domain settings as a draft until the modal is saved', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->set('editingRedirect', 'www');
|
||||
|
||||
expect($this->application->fresh()->redirect)->toBe('both')
|
||||
->and($this->application->noindexDomains()->all())->toBe([]);
|
||||
});
|
||||
|
||||
it('saves all domain modal settings together', function () {
|
||||
Queue::fake();
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->set('editingRedirect', 'www')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->redirect)->toBe('www')
|
||||
->and($application->noindexDomains()->all())->toBe(['https://app.example.com'])
|
||||
->and(explode(',', $application->fqdn))->toContain('https://www.app.example.com');
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.app.example.com');
|
||||
});
|
||||
|
||||
it('regenerates an application domain as a draft while preserving its url settings', function () {
|
||||
$this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']);
|
||||
$this->application->update(['fqdn' => 'http://old.example.com:8080/api']);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->call('regenerateEditingDomain')
|
||||
->assertSet('editingDomainParts.scheme', 'http')
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->assertSet('editingDomainParts.path', '/api');
|
||||
|
||||
expect($component->get('editingDomainParts')['host'])
|
||||
->toEndWith('.sslip.io')
|
||||
->not->toBe('old.example.com')
|
||||
->and($this->application->fresh()->fqdn)->toBe('http://old.example.com/api')
|
||||
->and($this->application->fresh()->domain_port_overrides)->toMatchArray([
|
||||
'http://old.example.com/api' => 8080,
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a dns check after a manually edited application domain is saved', function () {
|
||||
Queue::fake();
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
$this->application->update(['fqdn' => 'https://old.example.com:81']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '81')
|
||||
->set('editingDomainParts.host', 'manual.example.com')
|
||||
->call('updateDomain')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
|
||||
fn (array $row): bool => $row['url'] === 'https://manual.example.com' && $row['dns_status'] === 'checking'
|
||||
));
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses['https://manual.example.com']['status'] ?? null)->toBe('checking');
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual.example.com'
|
||||
&& $job->statusKey === 'https://manual.example.com');
|
||||
});
|
||||
|
||||
it('does not start a dns check when only application domain settings change', function () {
|
||||
Queue::fake();
|
||||
$this->application->update(['fqdn' => 'https://unchanged.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNotPushed(CheckDomainDnsJob::class);
|
||||
});
|
||||
|
||||
it('starts a dns check when the application domain scheme changes', function () {
|
||||
Queue::fake();
|
||||
$this->application->update(['fqdn' => 'http://scheme.example.com']);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomainParts.scheme', 'https')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://scheme.example.com');
|
||||
});
|
||||
|
||||
it('does not start a dns check when only the internal port changes', function () {
|
||||
Queue::fake();
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://port.example.com:81',
|
||||
'ports_exposes' => '81,82',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDomainParts.port', '82')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNotPushed(CheckDomainDnsJob::class);
|
||||
});
|
||||
|
||||
it('regenerates configured www pairs together and preserves each url settings', function () {
|
||||
$this->server->settings()->update(['wildcard_domain' => 'https://wildcard.example.net']);
|
||||
$this->application->update([
|
||||
'fqdn' => 'http://app.example.com:8080/api,https://www.app.example.com:9090/admin',
|
||||
'redirect' => 'www',
|
||||
'noindex_domains' => ['https://www.app.example.com/admin'],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->call('regenerateEditingDomain');
|
||||
|
||||
$generatedHost = $component->get('editingDomainParts')['host'];
|
||||
|
||||
$component->call('updateDomain')->assertHasNoErrors();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
expect(explode(',', $application->fqdn))->toBe([
|
||||
"http://{$generatedHost}/api",
|
||||
"https://www.{$generatedHost}/admin",
|
||||
])->and($application->noindexDomains()->all())->toBe([
|
||||
"https://www.{$generatedHost}/admin",
|
||||
])->and($application->domain_port_overrides)->toMatchArray([
|
||||
"http://{$generatedHost}/api" => 8080,
|
||||
"https://www.{$generatedHost}/admin" => 9090,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not create a missing redirect counterpart while regenerating one domain', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'redirect' => 'www',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->call('startEdit', 0)
|
||||
->call('regenerateEditingDomain');
|
||||
|
||||
$generatedHost = $component->get('editingDomainParts')['host'];
|
||||
$component->call('updateDomain')->assertHasNoErrors();
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBe("https://{$generatedHost}");
|
||||
});
|
||||
|
||||
it('saves regenerated compose domain drafts for only the selected service', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com/api', 'redirect' => 'both'],
|
||||
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
$webIndex = collect($component->get('domainRows'))->search(
|
||||
fn (array $row): bool => ($row['service'] ?? null) === 'web' && ! ($row['is_suggested'] ?? false)
|
||||
);
|
||||
|
||||
$component
|
||||
->call('startEdit', $webIndex)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->set('editingRedirect', 'www')
|
||||
->call('regenerateEditingDomain');
|
||||
|
||||
$generatedHost = $component->get('editingDomainParts')['host'];
|
||||
|
||||
$component->call('updateDomain')->assertHasNoErrors();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains['web']['domain'])->toBe("https://{$generatedHost}/api,https://www.{$generatedHost}/api")
|
||||
->and($domains['web']['redirect'])->toBe('www')
|
||||
->and($domains['api'])->toMatchArray(['domain' => 'https://api.example.com', 'redirect' => 'both'])
|
||||
->and($application->noindexDomains()->all())->toBe(["https://{$generatedHost}/api"]);
|
||||
});
|
||||
|
||||
it('does not render a last checked column in the domains table', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
|
@ -2146,18 +2367,15 @@
|
|||
->assertSee('Indexable')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
|
||||
->assertSee('editingIndexing', false)
|
||||
->assertDontSee('toggleNoindexDomain', false)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://staging.example.com']);
|
||||
->toBe(['https://app.example.com']);
|
||||
});
|
||||
|
||||
it('updates search engine indexing for a git docker compose domain', function () {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Project\Application\PreviewDomains;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -12,11 +13,22 @@
|
|||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('opens preview domain settings from browser data and shows a dns spinner', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('wire:click="startEdit(')
|
||||
->toContain('@click="openEditDomain(')
|
||||
->toContain('<x-loading compact aria-label="Checking DNS"')
|
||||
->not->toContain('<x-loading-on-button wire:loading.delay');
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
|
@ -90,6 +102,80 @@ function createPreviewForPortTests(Application $application, int $pullRequestId,
|
|||
], $attributes));
|
||||
}
|
||||
|
||||
it('regenerates an existing preview domain only when the modal is saved', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 100, [
|
||||
'fqdn' => 'http://preview.example.com:8080/api',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->call('regenerateEditingDomain')
|
||||
->assertSet('editingDomainParts.scheme', 'http')
|
||||
->assertSet('editingDomainParts.port', '8080')
|
||||
->assertSet('editingDomainParts.path', '/api');
|
||||
|
||||
$generatedHost = $component->get('editingDomainParts')['host'];
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe('http://preview.example.com/api');
|
||||
|
||||
$component->call('updateDomain')->assertHasNoErrors();
|
||||
|
||||
expect($preview->fresh()->fqdn)->toBe("http://{$generatedHost}/api")
|
||||
->and($preview->fresh()->domain_port_overrides)->toHaveKey("http://{$generatedHost}/api", 8080);
|
||||
});
|
||||
|
||||
it('runs a dns check after a manually edited preview domain is saved', function () {
|
||||
Queue::fake();
|
||||
$preview = createPreviewForPortTests($this->application, 99, [
|
||||
'fqdn' => 'https://old-preview.example.com:81',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '81')
|
||||
->set('editingDomainParts.host', 'manual-preview.example.com')
|
||||
->call('updateDomain')
|
||||
->assertSet('domainRows.0.dns_status', 'checking');
|
||||
|
||||
expect(collect($preview->fresh()->domain_dns_statuses)->contains(
|
||||
fn (array $status): bool => ($status['status'] ?? null) === 'checking'
|
||||
))->toBeTrue();
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-preview.example.com'
|
||||
&& $job->statusKey === hash('sha256', 'https://manual-preview.example.com|'));
|
||||
});
|
||||
|
||||
it('uses the dns badge as progress for single and all preview checks', function (string $action, array $parameters) {
|
||||
Queue::fake();
|
||||
$preview = createPreviewForPortTests($this->application, 98, [
|
||||
'fqdn' => 'https://badge-preview.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call($action, ...$parameters)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSeeHtml('loading-indicator');
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
})->with([
|
||||
'single domain' => ['checkDomainDns', [0]],
|
||||
'all domains' => ['checkAllDns', []],
|
||||
]);
|
||||
|
||||
it('does not start a dns check when a preview domain address is unchanged', function () {
|
||||
Queue::fake();
|
||||
$preview = createPreviewForPortTests($this->application, 100, [
|
||||
'fqdn' => 'https://unchanged-preview.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(PreviewDomains::class, ['preview' => $preview])
|
||||
->call('startEdit', 0)
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNotPushed(CheckDomainDnsJob::class);
|
||||
});
|
||||
|
||||
it('saves preview domain port overrides separately from the public FQDN', function () {
|
||||
$preview = createPreviewForPortTests($this->application, 101);
|
||||
|
||||
|
|
|
|||
|
|
@ -204,14 +204,15 @@
|
|||
$html = $component->call('startEdit', $index)
|
||||
->assertSet('editingDomain', $domain)
|
||||
->assertSee('Domain settings')
|
||||
->assertSee('Save changes')
|
||||
->assertSee('Save')
|
||||
->assertSee('Regenerate hostname')
|
||||
->assertDontSee('Save address')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertDontSee('Edit address and port')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1);
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -345,7 +346,7 @@
|
|||
->call('updateServiceRedirect', $this->webApp->id, 'www')
|
||||
->assertDispatched('success')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
|
||||
->assertSet('domainRows', fn (array $rows): bool => filled(collect($rows)->firstWhere('url', 'https://www.web.example.com')['checked_at'] ?? null))
|
||||
->assertSet('domainRows', fn (array $rows): bool => (collect($rows)->firstWhere('url', 'https://www.web.example.com')['dns_status'] ?? null) === 'checking')
|
||||
->assertSee('https://www.web.example.com');
|
||||
|
||||
expect($this->webApp->fresh()->redirect)->toBe('www');
|
||||
|
|
@ -862,13 +863,10 @@
|
|||
->assertSee('Indexable')
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('www redirect')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertSee('editingIndexing', false)
|
||||
->assertDontSee('toggleNoindexDomain', false)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success')
|
||||
->assertSet('service', fn (Service $service): bool => $service->applications
|
||||
|
|
@ -882,6 +880,70 @@
|
|||
->not->toContain('<select');
|
||||
});
|
||||
|
||||
it('regenerates a service application domain only when the modal is saved', function () {
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('regenerateEditingDomain');
|
||||
|
||||
$generatedHost = $component->get('editingDomainParts')['host'];
|
||||
|
||||
expect($generatedHost)->not->toBe('api.example.com')
|
||||
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
|
||||
|
||||
$component->call('updateDomain')->assertHasNoErrors();
|
||||
|
||||
expect($this->apiApp->fresh()->fqdn)->toBe("https://{$generatedHost}")
|
||||
->and($this->apiApp->fresh()->noindexDomains()->all())->toBe(["https://{$generatedHost}"]);
|
||||
});
|
||||
|
||||
it('starts a dns check after a manually edited service domain is saved', function () {
|
||||
Queue::fake();
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
$this->apiApp->update(['fqdn' => 'https://api.example.com:81']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDomainParts.port', '81')
|
||||
->set('editingDomainParts.host', 'manual-service.example.com')
|
||||
->call('updateDomain')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
|
||||
fn (array $row): bool => $row['url'] === 'https://manual-service.example.com' && $row['dns_status'] === 'checking'
|
||||
));
|
||||
|
||||
expect($this->apiApp->fresh()->domain_dns_statuses['https://manual-service.example.com']['status'] ?? null)->toBe('checking');
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://manual-service.example.com'
|
||||
&& $job->statusKey === 'https://manual-service.example.com');
|
||||
});
|
||||
|
||||
it('does not start a dns check when only service domain settings change', function () {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingIndexing', 'noindex')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Queue::assertNotPushed(CheckDomainDnsJob::class);
|
||||
});
|
||||
|
||||
it('checks the counterpart added by a service domain redirect change', function () {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingRedirect', 'www')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(explode(',', (string) $this->apiApp->fresh()->fqdn))->toContain('https://www.api.example.com');
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->url === 'https://www.api.example.com');
|
||||
});
|
||||
|
||||
it('keeps noindex domains when normalizing a custom service domain port', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com:8080',
|
||||
|
|
@ -961,7 +1023,7 @@
|
|||
->assertSee('Add domain')
|
||||
->assertSee('Domain settings')
|
||||
->call('startEdit', 0)
|
||||
->assertSee('Indexing and redirect changes save automatically.')
|
||||
->assertDontSee('Indexing and redirect changes save automatically.')
|
||||
->assertSee('Internal port 8080')
|
||||
->assertSee('Both www and non-www')
|
||||
->assertSee('Search indexing allowed')
|
||||
|
|
@ -1030,15 +1092,40 @@
|
|||
->toContain('Noindex');
|
||||
});
|
||||
|
||||
it('reuses the floating save bar for pending domain address edits', function () {
|
||||
it('uses explicit modal actions for pending domain edits', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)->toContain('<x-unsaved-bar action="updateDomain"')
|
||||
->toContain('dirty="hasAddressChanges"')
|
||||
->toContain('<template x-if="modalOpen">')
|
||||
->not->toContain('Save address');
|
||||
expect($view)->not->toContain('<x-unsaved-bar action="updateDomain"')
|
||||
->toContain('wire:click="regenerateEditingDomain"')
|
||||
->toContain('wire:click="updateDomain"')
|
||||
->toContain('Save');
|
||||
});
|
||||
|
||||
it('opens service domain settings from browser data and shows a dns spinner', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('wire:click="startEdit(')
|
||||
->toContain('@click="openEditDomain(')
|
||||
->toContain('<x-loading compact aria-label="Checking DNS"')
|
||||
->not->toContain('<x-loading-on-button wire:loading.delay');
|
||||
});
|
||||
|
||||
it('uses the dns badge as progress for single and all service checks', function (string $action, array $parameters) {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call($action, ...$parameters)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSeeHtml('loading-indicator');
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class);
|
||||
})->with([
|
||||
'single domain' => ['checkDomainDns', [0]],
|
||||
'all domains' => ['checkAllDns', []],
|
||||
]);
|
||||
|
||||
it('inherits the counterpart internal port when enabling redirects without a port warning', function (?int $override, string $redirect) {
|
||||
$this->service->update([
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_URL_WEB_80\n api:\n image: node:alpine\n",
|
||||
|
|
@ -1147,5 +1234,5 @@
|
|||
it('lays out the domain settings dropdowns in responsive columns', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')
|
||||
->toContain('class="sm:col-span-2 text-[12px]');
|
||||
->toContain('flex flex-wrap items-center justify-between gap-2');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -240,29 +240,31 @@
|
|||
->fill('#editingDomainParts-path', '/blog')
|
||||
->click('[id^="application-domain-indexing-"][id$="-trigger"]')
|
||||
->click('Noindex')
|
||||
->assertSee('Search engine indexing updated.')
|
||||
->assertDontSee('Search engine indexing updated.')
|
||||
->click('Regenerate hostname')
|
||||
->screenshot(filename: 'application-domain-unified-settings')
|
||||
->click('Save')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertSee('https://first.example.com/blog')
|
||||
->assertSee('Internal port 8069')
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'application-domains-compact');
|
||||
|
||||
expect($this->application->fresh()->domain_port_overrides)
|
||||
->toHaveKey('https://first.example.com/blog', 8069);
|
||||
$savedDomain = str($this->application->fresh()->fqdn)->before(',')->toString();
|
||||
expect($savedDomain)->not->toContain('first.example.com')
|
||||
->and($this->application->fresh()->domain_port_overrides)->toHaveKey($savedDomain, 8069)
|
||||
->and($this->application->fresh()->noindexDomains()->all())->toContain($savedDomain);
|
||||
|
||||
$page->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->fill('#editingDomainParts-path', '/discard')
|
||||
->click('Reset')
|
||||
->click('[aria-label="Close"]:visible')
|
||||
->assertDontSee('Domain settings')
|
||||
->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->assertValue('#editingDomainParts-path', '')
|
||||
->click('[aria-label="Close"]:visible')
|
||||
->click('[wire\\:key="domain-row-'.md5('https://first.example.com/blog|').'"] [aria-label="Remove domain"]')
|
||||
->click('[wire\\:key="domain-row-'.md5($savedDomain.'|').'"] [aria-label="Remove domain"]')
|
||||
->assertSee('Remove domain?')
|
||||
->click('button:has([x-text="step2ButtonText"]):visible')
|
||||
->assertDontSee('https://first.example.com/blog')
|
||||
->assertDontSee($savedDomain)
|
||||
->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->assertValue('#editingDomainParts-host', 'second.example.com')
|
||||
->click('[aria-label="Close"]:visible')
|
||||
|
|
@ -292,14 +294,16 @@
|
|||
->click('[id^="application-domain-direction-"][id$="-trigger"]')
|
||||
->click('Redirect to www')
|
||||
->assertDontSee('Use a different port?')
|
||||
->assertSee('Redirect updated for web.api.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'application-compose-domain-settings');
|
||||
|
||||
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
|
||||
$page->click('[aria-label="Close"]:visible')
|
||||
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('both');
|
||||
$page->click('Save')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertSee('https://www.web.example.com')
|
||||
->screenshot(filename: 'application-compose-domain-overview');
|
||||
|
||||
expect(json_decode($this->application->fresh()->docker_compose_domains, true)['web.api']['redirect'])->toBe('www');
|
||||
});
|
||||
|
||||
it('uses compact preview domains and opens only the selected preview settings', function (bool $isCompose) {
|
||||
|
|
@ -332,9 +336,9 @@
|
|||
->assertVisible('[aria-label="Search indexing blocked"] >> nth=0');
|
||||
expect($page->script("[...document.querySelectorAll('[data-preview-domain-dialog]')].filter(el => el.getClientRects().length > 0).length"))->toBe(1);
|
||||
$page->fill('#editingDomainParts-host:visible', 'renamed-preview.example.com')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->assertSee('Regenerate hostname')
|
||||
->screenshot(filename: 'preview-domain-unified-settings')
|
||||
->click('.is-dirty [wire\\:click="updateDomain"]')
|
||||
->click('[data-preview-domain-dialog]:visible button:has-text("Save")')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertSee('https://renamed-preview.example.com')
|
||||
->assertSee('https://preview-102.example.com')
|
||||
|
|
@ -343,9 +347,8 @@
|
|||
$page->click('[aria-label="Settings for https://preview-102.example.com"]')
|
||||
->assertSee('Domain settings')
|
||||
->fill('#editingDomainParts-path:visible', '/discard')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->screenshot(filename: 'preview-domain-before-reset')
|
||||
->click('.is-dirty button:has-text("Reset")')
|
||||
->click('[data-preview-domain-dialog]:visible [aria-label="Close"]')
|
||||
->assertDontSee('Domain settings')
|
||||
->click('[aria-label="Settings for https://preview-102.example.com"]')
|
||||
->assertValue('#editingDomainParts-path:visible', '')
|
||||
|
|
|
|||
|
|
@ -220,7 +220,8 @@
|
|||
->assertValue('#editingDomainParts-port', '8080')
|
||||
->assertDontSee('Edit address and port')
|
||||
->assertDontSee('Save address')
|
||||
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
|
||||
->assertSee('Regenerate hostname')
|
||||
->assertSee('Save')
|
||||
->assertSee('Search engine indexing')
|
||||
->screenshot(filename: 'service-domain-settings');
|
||||
|
||||
|
|
@ -233,24 +234,24 @@
|
|||
JS))->toBeTrue();
|
||||
|
||||
$page->fill('#editingDomainParts-port', '80')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->screenshot(filename: 'service-domain-unsaved-changes');
|
||||
|
||||
$domainKey = hash('sha256', 'https://long-public-domain-for-the-service.example.com|'.$this->serviceApplication->id);
|
||||
$page->click('#service-domain-indexing-'.$this->serviceApplication->id.'-'.$domainKey.'-trigger')
|
||||
->click('Noindex')
|
||||
->screenshot(filename: 'service-domain-indexing-saved')
|
||||
->screenshot(filename: 'service-domain-indexing-draft')
|
||||
->assertSee('Domain settings')
|
||||
->assertVisible('[aria-label="Search indexing blocked"]')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->assertVisible('[aria-label="Search indexing allowed"]')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
|
||||
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeFalse();
|
||||
|
||||
$page->click('[wire\\:click="updateDomain"]')
|
||||
$page->click('Save')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertVisible('[aria-label="Internal port 80"] >> nth=0');
|
||||
|
||||
expect($this->serviceApplication->fresh()->isDomainNoindexed('https://long-public-domain-for-the-service.example.com'))->toBeTrue();
|
||||
|
||||
$page->click('[wire\\:key="svc-domain-'.$this->serviceApplication->id.'-'.md5('https://long-public-domain-for-the-service.example.com').'"] [aria-label="Remove domain"]')
|
||||
->assertSee('Remove domain?')
|
||||
->assertNoJavaScriptErrors()
|
||||
|
|
@ -267,12 +268,10 @@
|
|||
|
||||
$page->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->fill('#editingDomainParts-path', '/discard-this')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->click('Reset')
|
||||
->click('[aria-label="Close"]:visible')
|
||||
->assertDontSee('Domain settings')
|
||||
->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->assertValue('#editingDomainParts-path', '')
|
||||
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
|
||||
->click('[aria-label="Close"]:visible')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
|
|
|||
Loading…
Reference in a new issue