mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
feat(domains): add structured URL editing and responsive domain controls
Add reusable domain URL parsing and input components, expose per-domain redirect and indexing controls, and improve application and service domain layouts across responsive breakpoints.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Lessons
|
||||
|
||||
## Alpine x-transition + tw-animate-css exit animations flash at the end
|
||||
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
|
||||
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
|
||||
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
|
||||
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
|
||||
@@ -41,10 +41,6 @@ class Domains extends Component
|
||||
|
||||
public string $editingDomain = '';
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public string $editingDirection = 'both';
|
||||
|
||||
public ?string $editingService = 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}> */
|
||||
@@ -103,8 +99,6 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingIndexing' => 'string|in:index,noindex',
|
||||
'editingDirection' => 'string|in:both,www,non-www',
|
||||
'redirect' => 'string|required|in:both,www,non-www',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
@@ -151,6 +145,12 @@ class Domains extends Component
|
||||
$this->dispatch('success', 'Search engine indexing updated.');
|
||||
}
|
||||
|
||||
public function updateRedirect(string $redirect): void
|
||||
{
|
||||
$this->redirect = $redirect;
|
||||
$this->setRedirect();
|
||||
}
|
||||
|
||||
public function loadDomainState(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
@@ -912,8 +912,6 @@ class Domains extends Component
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->editingDirection = $this->serviceRedirectFor($this->editingService);
|
||||
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
$this->showEditDomainModal = true;
|
||||
@@ -996,8 +994,6 @@ class Domains extends Component
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomain = '';
|
||||
$this->editingService = null;
|
||||
$this->editingDirection = 'both';
|
||||
$this->editingIndexing = 'index';
|
||||
$this->resetEditDomainDnsGate();
|
||||
$this->resetErrorBag('editingDomain');
|
||||
if ($this->pendingAction === 'update') {
|
||||
@@ -1040,6 +1036,7 @@ class Domains extends Component
|
||||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$service = $this->editingService;
|
||||
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
|
||||
|
||||
$current = $this->currentDomainList($service);
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
@@ -1066,20 +1063,13 @@ class Domains extends Component
|
||||
}
|
||||
|
||||
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($this->editingIndexing === 'noindex') {
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$this->application->setNoindexDomains($noindexDomains);
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
|
||||
if ($this->isCompose && filled($service) && $this->editingDirection !== $this->savedRedirectForService($service)) {
|
||||
$this->serviceRedirects[$this->serviceRedirectWireKey($service)] = $this->editingDirection;
|
||||
$this->notifyRedirectUpdate = false;
|
||||
$this->setServiceRedirect($service);
|
||||
$this->notifyRedirectUpdate = true;
|
||||
}
|
||||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->cancelEdit();
|
||||
@@ -1237,6 +1227,12 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updateServiceRedirect(string $serviceName, string $redirect): void
|
||||
{
|
||||
$this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $redirect;
|
||||
$this->setServiceRedirect($serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.)
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -46,10 +47,6 @@ class Domains extends Component
|
||||
|
||||
public string $editingDomain = '';
|
||||
|
||||
public string $editingDirection = 'both';
|
||||
|
||||
public string $editingIndexing = 'index';
|
||||
|
||||
public ?int $editingServiceApplicationId = null;
|
||||
|
||||
public bool $showEditDomainModal = false;
|
||||
@@ -102,8 +99,6 @@ class Domains extends Component
|
||||
return [
|
||||
'newDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDomain' => ValidationPatterns::applicationDomainRules(),
|
||||
'editingDirection' => 'string|in:both,www,non-www',
|
||||
'editingIndexing' => 'string|in:index,noindex',
|
||||
'newServiceApplicationId' => 'nullable|integer',
|
||||
'serviceRedirects' => 'array',
|
||||
'serviceRedirects.*' => 'string|in:both,www,non-www',
|
||||
@@ -924,9 +919,6 @@ class Domains extends Component
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$app = $this->findServiceApp($this->editingServiceApplicationId);
|
||||
$this->editingDirection = $this->normalizeRedirect($app?->redirect);
|
||||
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -940,8 +932,6 @@ class Domains extends Component
|
||||
$this->editingIndex = null;
|
||||
$this->editingDomain = '';
|
||||
$this->editingServiceApplicationId = null;
|
||||
$this->editingDirection = 'both';
|
||||
$this->editingIndexing = 'index';
|
||||
$this->editDomainDnsFailed = false;
|
||||
$this->editDomainDnsMessage = '';
|
||||
$this->forceSaveEditDns = false;
|
||||
@@ -974,6 +964,7 @@ class Domains extends Component
|
||||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured for this service.");
|
||||
@@ -1000,18 +991,12 @@ class Domains extends Component
|
||||
}
|
||||
|
||||
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
|
||||
if ($this->editingIndexing === 'noindex') {
|
||||
if ($wasNoindexed) {
|
||||
$noindexDomains->push($newUrl);
|
||||
}
|
||||
$app->setNoindexDomains($noindexDomains);
|
||||
$app->save();
|
||||
|
||||
if ($this->editingDirection !== $this->normalizeRedirect($app->redirect)) {
|
||||
$this->notifyRedirectUpdate = false;
|
||||
$this->updateServiceRedirect((int) $app->id, $this->editingDirection);
|
||||
$this->notifyRedirectUpdate = true;
|
||||
}
|
||||
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->forceSaveDomains = false;
|
||||
@@ -1140,12 +1125,9 @@ class Domains extends Component
|
||||
$domain = generateUrl(server: $server, random: new_public_id());
|
||||
$requiredPort = $app->getRequiredPort();
|
||||
if ($requiredPort !== null) {
|
||||
$parts = parse_url($domain);
|
||||
if (is_array($parts) && empty($parts['port'])) {
|
||||
$scheme = $parts['scheme'] ?? 'https';
|
||||
$host = $parts['host'] ?? '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$domain = "{$scheme}://{$host}:{$requiredPort}{$path}";
|
||||
$parts = DomainUrlParts::split($domain);
|
||||
if ($parts['port'] === '') {
|
||||
$domain = DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $requiredPort, $parts['path']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ class ExecuteContainerCommand extends Component
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->resource);
|
||||
if (! $this->resource->isRunning()) {
|
||||
$this->containersLoaded = true;
|
||||
}
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class DomainUrlParts
|
||||
{
|
||||
public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string
|
||||
{
|
||||
$scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https';
|
||||
$host = trim($host);
|
||||
$port = trim($port);
|
||||
$path = trim($path);
|
||||
|
||||
if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
return $scheme.'://'.$host.($port !== '' ? ':'.$port : '').$path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scheme: string, host: string, port: string, path: string}
|
||||
*/
|
||||
public static function split(?string $url): array
|
||||
{
|
||||
$parts = filled($url) ? parse_url($url) : false;
|
||||
if (! is_array($parts) || blank($parts['host'] ?? null)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
$path = $parts['path'] ?? '';
|
||||
if (isset($parts['query'])) {
|
||||
$path .= '?'.$parts['query'];
|
||||
}
|
||||
if (isset($parts['fragment'])) {
|
||||
$path .= '#'.$parts['fragment'];
|
||||
}
|
||||
|
||||
return [
|
||||
'scheme' => in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
||||
? strtolower($parts['scheme'])
|
||||
: 'https',
|
||||
'host' => (string) $parts['host'],
|
||||
'port' => isset($parts['port']) ? (string) $parts['port'] : '',
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scheme: string, host: string, port: string, path: string}
|
||||
*/
|
||||
public static function empty(): array
|
||||
{
|
||||
return ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
|
||||
}
|
||||
}
|
||||
+61
-12
@@ -2114,17 +2114,21 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.domains-table-grid {
|
||||
grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem;
|
||||
grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem;
|
||||
}
|
||||
|
||||
.domains-table-grid-compose {
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem;
|
||||
}
|
||||
|
||||
.domains-mobile-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Domains table: collapse secondary columns on tablet/phone */
|
||||
@media (max-width: 900px) {
|
||||
.domains-table-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 8.25rem 5.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 10rem 5.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -2134,7 +2138,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.domains-table-grid-compose {
|
||||
grid-template-columns: minmax(0, 1fr) 8.25rem 5.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 10rem 5.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -2145,7 +2149,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.data-table-header.domains-table-grid,
|
||||
.data-table-header.domains-table-grid-compose {
|
||||
display: none;
|
||||
@@ -2156,14 +2160,28 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'domain actions'
|
||||
'meta actions';
|
||||
gap: 0.35rem 0.75rem;
|
||||
'domain domain'
|
||||
'meta actions'
|
||||
'indexing indexing'
|
||||
'direction direction';
|
||||
gap: 0.5rem 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.875rem 1rem;
|
||||
padding: 0.75rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-table-row.domains-row-without-direction {
|
||||
grid-template-areas:
|
||||
'domain domain'
|
||||
'meta actions'
|
||||
'indexing indexing';
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(5),
|
||||
.data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Domain cell */
|
||||
.data-table-row.domains-table-grid > :nth-child(1),
|
||||
.data-table-row.domains-table-grid-compose > :nth-child(1) {
|
||||
@@ -2178,7 +2196,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Actions */
|
||||
/* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Indexing, 5 Direction, 6 Actions */
|
||||
.data-table-row.domains-table-grid > :nth-child(2) {
|
||||
grid-area: meta;
|
||||
display: flex !important;
|
||||
@@ -2192,11 +2210,19 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid > :nth-child(4) {
|
||||
grid-area: indexing;
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid > :nth-child(5) {
|
||||
grid-area: direction;
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid > :nth-child(6) {
|
||||
grid-area: actions;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Actions */
|
||||
/* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Indexing, 6 Direction, 7 Actions */
|
||||
.data-table-row.domains-table-grid-compose > :nth-child(2) {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -2214,10 +2240,33 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid-compose > :nth-child(5) {
|
||||
grid-area: indexing;
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid-compose > :nth-child(6) {
|
||||
grid-area: direction;
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid-compose > :nth-child(7) {
|
||||
grid-area: actions;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.domains-mobile-label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1rem;
|
||||
color: var(--coollabs-subtle);
|
||||
}
|
||||
|
||||
.data-table-row.domains-table-grid .listbox-trigger,
|
||||
.data-table-row.domains-table-grid-compose .listbox-trigger {
|
||||
height: 2rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Service name as badge under domain on mobile (compose only) */
|
||||
.data-table-row.domains-table-grid-compose .domains-service-mobile {
|
||||
display: inline-flex !important;
|
||||
@@ -2228,7 +2277,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.domains-service-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@props([
|
||||
'id',
|
||||
'wire' => true,
|
||||
'value' => '',
|
||||
'errorId' => null,
|
||||
])
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]" x-data="{
|
||||
value: @if ($wire) @entangle($id) @else @js($value) @endif,
|
||||
scheme: 'https',
|
||||
host: '',
|
||||
port: '',
|
||||
path: '',
|
||||
syncing: false,
|
||||
init() {
|
||||
this.read(this.value);
|
||||
this.$watch('value', value => {
|
||||
if (!this.syncing) this.read(value);
|
||||
});
|
||||
['scheme', 'host', 'port', 'path'].forEach(part => this.$watch(part, () => this.write()));
|
||||
},
|
||||
read(value) {
|
||||
if (!value) return;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:\[[^\]]+\]|[^\/:?#]+)(?::(\d+))?/i);
|
||||
this.syncing = true;
|
||||
this.scheme = url.protocol.replace(':', '') === 'http' ? 'http' : 'https';
|
||||
this.host = url.hostname;
|
||||
this.port = authority?.[1] || url.port;
|
||||
this.path = `${url.pathname === '/' ? '' : url.pathname}${url.search}${url.hash}`;
|
||||
this.$nextTick(() => this.syncing = false);
|
||||
} catch (_) {}
|
||||
},
|
||||
write() {
|
||||
if (this.syncing) return;
|
||||
const path = this.path.trim();
|
||||
const normalizedPath = path && !['/', '?', '#'].includes(path[0]) ? `/${path}` : path;
|
||||
const next = `${this.scheme}://${this.host.trim()}${this.port ? `:${this.port}` : ''}${normalizedPath}`;
|
||||
if (this.value !== next) this.value = next;
|
||||
},
|
||||
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}>
|
||||
<div class="min-w-0">
|
||||
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https"
|
||||
x-model="scheme" portal :options="[
|
||||
['value' => 'https', 'label' => 'HTTPS'],
|
||||
['value' => 'http', 'label' => 'HTTP'],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<label for="{{ $id }}" class="mb-1.5 block text-sm font-medium">
|
||||
Domain <x-highlighted text="*" />
|
||||
</label>
|
||||
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="app.example.com"
|
||||
autocomplete="off" required />
|
||||
@error($errorId ?? $id)
|
||||
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<label for="{{ $id }}-port" class="mb-1.5 block text-sm font-medium">Port</label>
|
||||
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000"
|
||||
min="1" max="65535" inputmode="numeric" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 sm:col-span-3">
|
||||
<label for="{{ $id }}-path" class="mb-1.5 block text-sm font-medium">Path</label>
|
||||
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3"
|
||||
autocomplete="off" />
|
||||
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
Optional path, query, or fragment appended after the domain and port.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,11 +38,11 @@
|
||||
this.visible = true;
|
||||
const rect = target.getBoundingClientRect();
|
||||
this.below = rect.top < 48;
|
||||
this.x = rect.left + rect.width / 2;
|
||||
this.x = rect.left;
|
||||
this.y = this.below ? rect.bottom + 8 : rect.top - 8;
|
||||
this.$nextTick(() => {
|
||||
const width = this.$refs.tooltip?.offsetWidth || 0;
|
||||
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
|
||||
this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x));
|
||||
this.$nextTick(() => this.positioned = true);
|
||||
});
|
||||
},
|
||||
@@ -71,6 +71,6 @@
|
||||
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
|
||||
:style="`left: ${x}px; top: ${y}px;`"
|
||||
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
|
||||
class="pointer-events-none fixed z-[100] -translate-x-1/2 whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
|
||||
class="pointer-events-none fixed z-[100] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,23 @@
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="translate-y-0 opacity-100"
|
||||
x-transition:leave-end="translate-y-3 opacity-0"
|
||||
class="fixed bottom-4 right-4 z-999"
|
||||
:class="iconOnly
|
||||
? 'w-auto max-w-[calc(100%-2rem)]'
|
||||
: (compact
|
||||
? 'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]'
|
||||
: 'w-[calc(100%-2rem)] max-w-sm')">
|
||||
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
|
||||
:class="compact ? (iconOnly ? 'cursor-pointer p-2! pr-2!' : 'cursor-pointer') : ''" @click="restore()"
|
||||
class="fixed bottom-4 right-4 z-999">
|
||||
<button x-show="iconOnly" type="button" @click="restore()" aria-label="Restore warning"
|
||||
class="flex rounded-lg p-2"
|
||||
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
|
||||
@isset($icon)
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
|
||||
{{ $icon }}
|
||||
</span>
|
||||
@endisset
|
||||
</button>
|
||||
|
||||
<div x-show="!iconOnly" class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
|
||||
:class="compact
|
||||
? 'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'
|
||||
: 'w-[calc(100vw-2rem)] max-w-sm'"
|
||||
@click="restore()"
|
||||
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
|
||||
@isset($icon)
|
||||
<div
|
||||
@@ -66,7 +75,7 @@
|
||||
</div>
|
||||
@endisset
|
||||
|
||||
<div x-show="!iconOnly" class="min-w-0 flex-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-[13px] font-semibold leading-4 text-neutral-950 dark:text-fg">
|
||||
{{ $title }}
|
||||
</h4>
|
||||
@@ -76,7 +85,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button x-show="!iconOnly" type="button" @click.stop="minimizeToIcon()" aria-label="Minimize warning"
|
||||
<button type="button" @click.stop="minimizeToIcon()" aria-label="Minimize warning"
|
||||
class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-black/5 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -176,20 +176,12 @@
|
||||
|
||||
const toggle = qs(root, '[data-sth-toggle]');
|
||||
if (toggle) {
|
||||
if (isMobileSlot) {
|
||||
// Compact pill: just "83ms" — no full metric breakdown in the bar.
|
||||
toggle.style.padding = '4px 8px';
|
||||
toggle.style.fontSize = '11px';
|
||||
toggle.style.maxWidth = 'none';
|
||||
toggle.style.overflow = 'visible';
|
||||
toggle.style.whiteSpace = 'nowrap';
|
||||
} else {
|
||||
toggle.style.padding = '';
|
||||
toggle.style.fontSize = '';
|
||||
toggle.style.maxWidth = '100%';
|
||||
toggle.style.overflow = 'hidden';
|
||||
toggle.style.textOverflow = 'ellipsis';
|
||||
}
|
||||
// Keep the navbar pill compact at every breakpoint; details stay in the panel.
|
||||
toggle.style.padding = '4px 8px';
|
||||
toggle.style.fontSize = '11px';
|
||||
toggle.style.maxWidth = 'none';
|
||||
toggle.style.overflow = 'visible';
|
||||
toggle.style.whiteSpace = 'nowrap';
|
||||
}
|
||||
|
||||
const panel = qs(root, '[data-sth-panel]');
|
||||
@@ -680,10 +672,8 @@
|
||||
const q = latest.metrics.queries !== undefined ? Math.round(Number(latest.metrics.queries)) + 'q' : '—';
|
||||
const db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—';
|
||||
const n = history.length;
|
||||
// Mobile navbar is tight — show only total app time; full breakdown lives in the panel.
|
||||
const compactSummary = root.getAttribute('data-sth-mode') === 'docked'
|
||||
&& root.parentElement
|
||||
&& root.parentElement.id === 'server-timing-hud-slot-mobile';
|
||||
// Navbar pills show only total app time; full breakdown lives in the panel.
|
||||
const compactSummary = root.getAttribute('data-sth-mode') === 'docked';
|
||||
summary.textContent = compactSummary
|
||||
? app
|
||||
: ('ST ' + app + ' · ' + db + ' · ' + q + (n > 1 ? ' · ×' + n : ''));
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
selectedIndex: -1,
|
||||
isSearching: false,
|
||||
isLoadingInitialData: false,
|
||||
showLoadingSpinner: false,
|
||||
spinnerTimer: null,
|
||||
closeResetTimer: null,
|
||||
isPaletteTransitioning: false,
|
||||
allSearchableItems: [],
|
||||
searchQuery: '',
|
||||
@@ -97,29 +100,46 @@
|
||||
console.warn('Global search: $wire not available, skipping open');
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.closeResetTimer);
|
||||
clearTimeout(this.spinnerTimer);
|
||||
this.modalOpen = true;
|
||||
this.selectedIndex = -1;
|
||||
this.isLoadingInitialData = true;
|
||||
this.showLoadingSpinner = false;
|
||||
this.searchQuery = '';
|
||||
// Only show the spinner when loading takes longer than 150ms, so fast (cached) loads do not flash the icon
|
||||
this.spinnerTimer = setTimeout(() => {
|
||||
if (this.isLoadingInitialData) this.showLoadingSpinner = true;
|
||||
}, 150);
|
||||
$wire.openSearchModal().then(() => {
|
||||
this.allSearchableItems = $wire.allSearchableItems || [];
|
||||
this.creatableItems = $wire.creatableItems || [];
|
||||
clearTimeout(this.spinnerTimer);
|
||||
this.isLoadingInitialData = false;
|
||||
this.showLoadingSpinner = false;
|
||||
setTimeout(() => this.$refs.searchInput?.focus(), 50);
|
||||
}).catch(() => {
|
||||
// Handle case where component was destroyed during navigation
|
||||
clearTimeout(this.spinnerTimer);
|
||||
this.modalOpen = false;
|
||||
this.isLoadingInitialData = false;
|
||||
this.showLoadingSpinner = false;
|
||||
});
|
||||
},
|
||||
closeModal() {
|
||||
this.modalOpen = false;
|
||||
this.selectedIndex = -1;
|
||||
this.isSearching = false;
|
||||
this.isLoadingInitialData = false;
|
||||
this.searchQuery = '';
|
||||
this.allSearchableItems = [];
|
||||
this.isPaletteTransitioning = false;
|
||||
// Keep the palette content intact until the leave animation (100ms) ends,
|
||||
// otherwise the panel collapses to header height while it fades out
|
||||
clearTimeout(this.closeResetTimer);
|
||||
this.closeResetTimer = setTimeout(() => {
|
||||
this.isLoadingInitialData = false;
|
||||
this.showLoadingSpinner = false;
|
||||
this.searchQuery = '';
|
||||
this.allSearchableItems = [];
|
||||
this.isPaletteTransitioning = false;
|
||||
}, 150);
|
||||
},
|
||||
runPaletteTransition(callback) {
|
||||
this.isPaletteTransitioning = true;
|
||||
@@ -312,24 +332,24 @@
|
||||
class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
|
||||
<div x-show="modalOpen" @click="closeModal()"
|
||||
x-transition:enter="animate-in fade-in-0 duration-150"
|
||||
x-transition:leave="animate-out fade-out-0 duration-100"
|
||||
x-transition:leave="animate-out fade-out-0 duration-100 fill-mode-forwards"
|
||||
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
|
||||
</div>
|
||||
<div x-show="modalOpen" x-trap.inert="modalOpen"
|
||||
x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"
|
||||
x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100 fill-mode-forwards"
|
||||
class="command-palette relative mx-auto"
|
||||
@click.stop>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="command-palette-header">
|
||||
<span class="command-palette-header-icon" :class="isLoadingInitialData && 'is-loading'">
|
||||
<svg x-show="!isLoadingInitialData" class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<span class="command-palette-header-icon" :class="showLoadingSpinner && 'is-loading'">
|
||||
<svg x-show="!showLoadingSpinner" class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.5 2.75C6.66751 2.75 2.75 6.66751 2.75 11.5C2.75 16.3325 6.66751 20.25 11.5 20.25C16.3325 20.25 20.25 16.3325 20.25 11.5C20.25 6.66751 16.3325 2.75 11.5 2.75ZM1.25 11.5C1.25 5.83908 5.83908 1.25 11.5 1.25C17.1609 1.25 21.75 5.83908 21.75 11.5C21.75 14.0605 20.8111 16.4017 19.2589 18.1982L22.5303 21.4697C22.8232 21.7626 22.8232 22.2374 22.5303 22.5303C22.2374 22.8232 21.7626 22.8232 21.4697 22.5303L18.1982 19.2589C16.4017 20.8111 14.0605 21.75 11.5 21.75C5.83908 21.75 1.25 17.1609 1.25 11.5Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg x-show="isLoadingInitialData" x-cloak class="size-4 animate-spin" viewBox="0 0 24 24" fill="none"
|
||||
<svg x-show="showLoadingSpinner" x-cloak class="size-4 animate-spin" viewBox="0 0 24 24" fill="none"
|
||||
aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
|
||||
@@ -18,15 +18,11 @@
|
||||
localEditingIndex: @js($editingIndex),
|
||||
localEditingDomain: @js($editingDomain),
|
||||
localEditingService: @js($editingService),
|
||||
localDirection: 'both',
|
||||
localIndexing: 'index',
|
||||
openEditDomain(index, url, service, indexing, direction) {
|
||||
openEditDomain(index, url, service) {
|
||||
this.localEditingIndex = index;
|
||||
this.localEditingDomain = url;
|
||||
this.localEditingService = service;
|
||||
this.editingServiceLabel = service || '';
|
||||
this.localDirection = direction || 'both';
|
||||
this.localIndexing = indexing || 'index';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
|
||||
},
|
||||
@@ -42,8 +38,6 @@
|
||||
$wire.editingIndex = this.localEditingIndex;
|
||||
$wire.editingDomain = this.localEditingDomain;
|
||||
$wire.editingService = this.localEditingService;
|
||||
$wire.editingDirection = this.localDirection;
|
||||
$wire.editingIndexing = this.localIndexing;
|
||||
$wire.showEditDomainModal = true;
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
@@ -53,7 +47,7 @@
|
||||
return values.some((value) => this.matchesDomainSearch(value));
|
||||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service, $event.detail.indexing, $event.detail.direction)"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
|
||||
@can('update', $application)
|
||||
@@ -84,37 +78,6 @@
|
||||
</x-callout>
|
||||
@endcannot
|
||||
|
||||
@if (! $isCompose)
|
||||
@if ($labelsAreWritable)
|
||||
<x-forms.input label="Direction" value="{{ match ($application->redirect) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow www & non-www',
|
||||
} }}" readonly helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@else
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.listbox id="redirect" label="Direction" required :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" helper="Add <strong>both</strong> www and non-www in Coolify. Both hostnames must resolve to this server so the proxy can serve or redirect them. Do not use a DNS-provider URL redirect record for the non-canonical host; Coolify handles the HTTP redirect. Changes apply when you click Set direction."
|
||||
:disabled="! auth()->user()?->can('update', $application)" />
|
||||
</div>
|
||||
@can('update', $application)
|
||||
<div class="w-full shrink-0 sm:w-auto">
|
||||
<x-modal-confirmation title="Confirm redirection setting?" buttonTitle="Set direction"
|
||||
submitAction="setRedirect" :actions="['All traffic will be redirected to the selected direction.']"
|
||||
confirmationText="{{ ($application->fqdn ?: 'domains') . '/' }}"
|
||||
confirmationLabel="Please confirm the execution of the action by entering the Application URL below"
|
||||
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
|
||||
step2ButtonText="Set direction" canGate="update" :canResource="$application" />
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
@@ -153,16 +116,15 @@
|
||||
</x-slot:content>
|
||||
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
|
||||
@if ($isCompose && count($composeServices) > 0)
|
||||
<x-forms.select label="Service" id="newDomainService" required>
|
||||
@foreach ($composeServices as $serviceName)
|
||||
<option value="{{ $serviceName }}">{{ $serviceName }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.listbox label="Service" id="newDomainService" required
|
||||
:options="collect($composeServices)->map(fn ($serviceName) => [
|
||||
'value' => $serviceName,
|
||||
'label' => $serviceName,
|
||||
])->values()->all()"
|
||||
:disabled="! auth()->user()->can('update', $application)" />
|
||||
@endif
|
||||
|
||||
<x-forms.input id="newDomain" label="Domain URL" placeholder="https://app.example.com"
|
||||
helper="Full URL including scheme. Optional path and container port are supported.<br><br><span class='text-helper'>Examples</span><br>- https://app.coolify.io<br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000<br>- https://app.coolify.io:8080/api"
|
||||
required />
|
||||
<x-forms.domain-input id="newDomain" />
|
||||
|
||||
@if ($addDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
@@ -252,6 +214,8 @@
|
||||
<span>Domain</span>
|
||||
<span>DNS</span>
|
||||
<span>Last checked</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@foreach ($rows as $row)
|
||||
@@ -287,6 +251,8 @@
|
||||
<span>Domain</span>
|
||||
<span>DNS Check</span>
|
||||
<span>Last checked</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@foreach ($domainRows as $index => $row)
|
||||
@@ -340,40 +306,8 @@
|
||||
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
|
||||
</div>
|
||||
|
||||
<div 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" for="editingDomainLocal">
|
||||
Domain URL <x-highlighted text="*" />
|
||||
</label>
|
||||
</div>
|
||||
<input id="editingDomainLocal" type="url" class="input" required
|
||||
placeholder="https://app.example.com"
|
||||
x-model="localEditingDomain" />
|
||||
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Full URL including scheme. Optional path and container port are supported.
|
||||
</p>
|
||||
@error('editingDomain')
|
||||
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@unless ($labelsAreWritable)
|
||||
<div class="grid gap-4 {{ $isCompose ? 'sm:grid-cols-2' : '' }}">
|
||||
@if ($isCompose)
|
||||
<x-forms.listbox id="edit-domain-direction" label="Direction"
|
||||
:wire="false" value="both" x-model="localDirection" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
@endif
|
||||
<x-forms.listbox id="edit-domain-indexing" label="Search engine indexing"
|
||||
:wire="false" value="index" x-model="localIndexing" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
</div>
|
||||
@endunless
|
||||
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false"
|
||||
x-model="localEditingDomain" />
|
||||
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
}
|
||||
}">
|
||||
<form wire:submit='submit' class="application-settings-form flex flex-col">
|
||||
<x-unsaved-bar action="submit" />
|
||||
<x-unsaved-bar action="submit"
|
||||
targets="name,description,buildPack,staticImage,baseDirectory,dockerComposeLocation,dockerComposeCustomBuildCommand,dockerComposeCustomStartCommand,watchPaths,dockerfileLocation,dockerfileTargetBuild,publishDirectory,installCommand,buildCommand,startCommand,customNginxConfiguration,dockerfile,dockerRegistryImageName,dockerRegistryImageTag,portsExposes,portsMappings,customNetworkAliases,customDockerRunOptions,httpBasicAuthUsername,httpBasicAuthPassword,preDeploymentCommand,preDeploymentCommandContainer,postDeploymentCommand,postDeploymentCommandContainer,isContainerLabelReadonlyEnabled,isContainerLabelEscapeEnabled,customLabels" />
|
||||
<div class="application-settings-grid flex flex-col gap-6">
|
||||
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
|
||||
@if ($buildPack === 'dockercompose')
|
||||
@@ -302,7 +303,7 @@
|
||||
@endif
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<div x-data="{ showRaw: true }" class="mt-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-4">
|
||||
<h3>Docker Compose</h3>
|
||||
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
|
||||
@click.prevent="showRaw = !showRaw"
|
||||
|
||||
@@ -17,6 +17,28 @@
|
||||
? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans()
|
||||
: null;
|
||||
$gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid';
|
||||
$domainParts = $isSuggested ? null : parse_url($row['url']);
|
||||
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
|
||||
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
|
||||
: null;
|
||||
$redirectPairKey = function (string $url): string {
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts) || ! isset($parts['host'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$host = preg_replace('/^www\./i', '', $parts['host']);
|
||||
|
||||
return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? ''));
|
||||
};
|
||||
$pairKey = $redirectPairKey($row['url']);
|
||||
$firstPairRowIndex = collect($domainRows)
|
||||
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
|
||||
->filter(fn ($item) => ($item['service'] ?? null) === ($row['service'] ?? null))
|
||||
->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey)
|
||||
->keys()
|
||||
->first();
|
||||
$showDirection = ! $isSuggested && $firstPairRowIndex === $index;
|
||||
@endphp
|
||||
|
||||
<div wire:key="domain-row-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
|
||||
@@ -25,6 +47,7 @@
|
||||
'data-table-row',
|
||||
$gridClass,
|
||||
'domains-row-suggested' => $isSuggested,
|
||||
'domains-row-without-direction' => ! $showDirection,
|
||||
])>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -35,6 +58,11 @@
|
||||
{{ $row['url'] }}
|
||||
</span>
|
||||
@else
|
||||
@if ($faviconUrl)
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer" x-on:error="$el.remove()"
|
||||
class="size-4 shrink-0 rounded-sm" />
|
||||
@endif
|
||||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">
|
||||
@@ -78,6 +106,51 @@
|
||||
{{ $checkedAt ?: '-' }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0" title="Search engine indexing">
|
||||
@unless ($isSuggested)
|
||||
<span class="domains-mobile-label">Search engine indexing</span>
|
||||
@endunless
|
||||
@if ($isSuggested)
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
|
||||
@elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
|
||||
<x-forms.listbox id="domain-indexing-{{ $index }}" :wire="false"
|
||||
:value="$application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain" :onChangeArgs="[$row['url']]" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
@else
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $application->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0" title="Direction">
|
||||
@php
|
||||
$rowDirection = $domainDirection ?? $redirect;
|
||||
$directionLabel = match ($rowDirection) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow both',
|
||||
};
|
||||
@endphp
|
||||
@if ($showDirection)
|
||||
<span class="domains-mobile-label">Direction</span>
|
||||
@endif
|
||||
@if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable)
|
||||
<x-forms.listbox id="domain-direction-{{ $index }}" :wire="false" :value="$rowDirection"
|
||||
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
|
||||
:onChangeArgs="$isCompose ? [$row['service']] : []" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
@elseif ($showDirection)
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@can('update', $application)
|
||||
<button type="button" wire:click="checkDomainDns({{ $index }})"
|
||||
@@ -107,8 +180,6 @@
|
||||
index: {{ $index }},
|
||||
url: @js($row['url']),
|
||||
service: @js($row['service'] ?? null),
|
||||
indexing: @js($application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
|
||||
direction: @js($domainDirection ?? $redirect),
|
||||
})"
|
||||
class="icon-button shrink-0"
|
||||
title="Edit domain" aria-label="Edit domain">
|
||||
|
||||
@@ -22,15 +22,11 @@
|
||||
localEditingIndex: @js($editingIndex),
|
||||
localEditingDomain: @js($editingDomain),
|
||||
localEditingServiceApplicationId: @js($editingServiceApplicationId),
|
||||
localDirection: 'both',
|
||||
localIndexing: 'index',
|
||||
openEditDomain(index, url, serviceApplicationId, serviceLabel, direction, indexing) {
|
||||
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
|
||||
this.localEditingIndex = index;
|
||||
this.localEditingDomain = url;
|
||||
this.localEditingServiceApplicationId = serviceApplicationId;
|
||||
this.editingServiceLabel = serviceLabel || '';
|
||||
this.localDirection = direction || 'both';
|
||||
this.localIndexing = indexing || 'index';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
|
||||
},
|
||||
@@ -45,8 +41,6 @@
|
||||
$wire.editingIndex = this.localEditingIndex;
|
||||
$wire.editingDomain = this.localEditingDomain;
|
||||
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
|
||||
$wire.editingDirection = this.localDirection;
|
||||
$wire.editingIndexing = this.localIndexing;
|
||||
$wire.showEditDomainModal = true;
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
@@ -56,7 +50,7 @@
|
||||
return values.some((value) => this.matchesDomainSearch(value));
|
||||
},
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel, $event.detail.direction, $event.detail.indexing)"
|
||||
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
<x-application.settings-section id="service-domains-section" title="Domains">
|
||||
@can('update', $service)
|
||||
@@ -114,20 +108,15 @@
|
||||
</x-slot:content>
|
||||
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
|
||||
{{-- Always show which service receives the domain --}}
|
||||
<x-forms.select canGate="update" :canResource="$service" label="Service application"
|
||||
id="newServiceApplicationId" required
|
||||
helper="Domain will be assigned to this compose service application.">
|
||||
@foreach ($serviceApps as $app)
|
||||
<option value="{{ $app['id'] }}">
|
||||
{{ $app['name'] }}{{ filled($app['image'] ?? null) ? ' ('.$app['image'].')' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.listbox label="Service application" id="newServiceApplicationId" required
|
||||
helper="Domain will be assigned to this compose service application."
|
||||
:options="collect($serviceApps)->map(fn ($app) => [
|
||||
'value' => $app['id'],
|
||||
'label' => $app['name'].(filled($app['image'] ?? null) ? ' ('.$app['image'].')' : ''),
|
||||
])->values()->all()"
|
||||
:disabled="! auth()->user()->can('update', $service)" />
|
||||
|
||||
<x-forms.input canGate="update" :canResource="$service" id="newDomain" label="Domain URL"
|
||||
placeholder="https://app.example.com"
|
||||
helper="Full URL including scheme. Optional path and container port are supported.<br><br><span class='text-helper'>Examples</span><br>- https://app.coolify.io<br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000<br>- https://app.coolify.io:8080/api"
|
||||
required />
|
||||
<x-forms.domain-input id="newDomain" />
|
||||
|
||||
@if ($addDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
@@ -254,33 +243,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div 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" for="editingDomainLocal">
|
||||
Domain URL <x-highlighted text="*" />
|
||||
</label>
|
||||
</div>
|
||||
<input id="editingDomainLocal" type="url" class="input" required
|
||||
placeholder="https://app.example.com"
|
||||
x-model="localEditingDomain" />
|
||||
@error('editingDomain')
|
||||
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.listbox id="edit-service-domain-direction" label="Direction"
|
||||
:wire="false" value="both" x-model="localDirection" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
<x-forms.listbox id="edit-service-domain-indexing" label="Search engine indexing"
|
||||
:wire="false" value="index" x-model="localIndexing" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false"
|
||||
x-model="localEditingDomain" />
|
||||
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-callout type="danger" title="DNS is not pointing to the right IP">
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
@endif
|
||||
<span>DNS</span>
|
||||
<span>Last checked</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@endif
|
||||
@@ -43,6 +45,25 @@
|
||||
$serviceLabel = filled($row['service_name'] ?? null)
|
||||
? \Illuminate\Support\Str::headline($row['service_name'])
|
||||
: '-';
|
||||
$domainParts = $isSuggested ? null : parse_url($row['url']);
|
||||
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
|
||||
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
|
||||
: null;
|
||||
$redirectPairKey = function (string $url): string {
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts) || ! isset($parts['host'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$host = preg_replace('/^www\./i', '', $parts['host']);
|
||||
|
||||
return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? ''));
|
||||
};
|
||||
$pairKey = $redirectPairKey($row['url']);
|
||||
$firstPairRowUrl = collect($rows)
|
||||
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
|
||||
->first(fn ($item) => $redirectPairKey($item['url']) === $pairKey)['url'] ?? null;
|
||||
$showDirection = ! $isSuggested && $firstPairRowUrl === $row['url'];
|
||||
@endphp
|
||||
|
||||
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
|
||||
@@ -51,6 +72,7 @@
|
||||
'data-table-row',
|
||||
$gridClass,
|
||||
'domains-row-suggested' => $isSuggested,
|
||||
'domains-row-without-direction' => ! $showDirection,
|
||||
])>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -61,6 +83,11 @@
|
||||
{{ $row['url'] }}
|
||||
</span>
|
||||
@else
|
||||
@if ($faviconUrl)
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer" x-on:error="$el.remove()"
|
||||
class="size-4 shrink-0 rounded-sm" />
|
||||
@endif
|
||||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">
|
||||
@@ -100,6 +127,53 @@
|
||||
{{ $checkedAt ?: '-' }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
@unless ($isSuggested)
|
||||
<span class="domains-mobile-label">Search engine indexing</span>
|
||||
@endunless
|
||||
@if ($isSuggested)
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
|
||||
@elseif (auth()->user()?->can('update', $service))
|
||||
<x-forms.listbox id="service-domain-indexing-{{ $row['service_application_id'] }}-{{ $index }}"
|
||||
:wire="false"
|
||||
:value="$service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain"
|
||||
:onChangeArgs="[(int) $row['service_application_id'], $row['url']]" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
['value' => 'noindex', 'label' => 'Noindex'],
|
||||
]" />
|
||||
@else
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
@php
|
||||
$rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both';
|
||||
$directionLabel = match ($rowDirection) {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow both',
|
||||
};
|
||||
@endphp
|
||||
@if ($showDirection)
|
||||
<span class="domains-mobile-label">Direction</span>
|
||||
@endif
|
||||
@if ($showDirection && auth()->user()?->can('update', $service))
|
||||
<x-forms.listbox id="service-domain-direction-{{ $row['service_application_id'] }}-{{ $index }}"
|
||||
:wire="false" :value="$rowDirection" onChange="updateServiceRedirect"
|
||||
:onChangeArgs="[(int) $row['service_application_id']]" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
@elseif ($showDirection)
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@can('update', $service)
|
||||
<button type="button" wire:click="checkDomainDns({{ $index }})"
|
||||
@@ -133,8 +207,6 @@
|
||||
url: @js($row['url']),
|
||||
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
|
||||
serviceLabel: @js($serviceLabel),
|
||||
direction: @js($serviceRedirects[$row['service_application_id']] ?? 'both'),
|
||||
indexing: @js($service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
|
||||
})"
|
||||
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
: md5((string) $resource->config_hash);
|
||||
$compactStorageKey = $compactStoragePrefix.$currentConfigurationHash;
|
||||
@endphp
|
||||
<div x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
|
||||
<div wire:key="configuration-warning-{{ $currentConfigurationHash }}"
|
||||
x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
|
||||
<x-popup-small :compact-after="5000" :compact-storage-key="$compactStorageKey"
|
||||
:compact-storage-prefix="$compactStoragePrefix">
|
||||
<x-slot:title>
|
||||
|
||||
@@ -83,12 +83,12 @@
|
||||
helper="Non-root SSH users are experimental." />
|
||||
<x-forms.input type="number" id="port" label="Port" required />
|
||||
</div>
|
||||
<x-forms.select id="is_build_server"
|
||||
<x-forms.listbox id="is_build_server"
|
||||
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
|
||||
label="Use as a dedicated build server">
|
||||
<option value="0">No</option>
|
||||
<option value="1">Yes</option>
|
||||
</x-forms.select>
|
||||
label="Use as a dedicated build server" :options="[
|
||||
['value' => false, 'label' => 'No'],
|
||||
['value' => true, 'label' => 'Yes'],
|
||||
]" />
|
||||
</x-forms.collapsible>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
@@ -82,13 +82,12 @@
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" type="number" id="customPort"
|
||||
label="SSH port" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.select canGate="update" :canResource="$gitlab_app" id="privateKeyId"
|
||||
label="SSH private key (optional)">
|
||||
<option value="">None</option>
|
||||
@foreach ($privateKeys as $key)
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.listbox id="privateKeyId" label="SSH private key (optional)"
|
||||
:options="collect($privateKeys)->map(fn ($key) => [
|
||||
'value' => $key->id,
|
||||
'label' => $key->name,
|
||||
])->prepend(['value' => null, 'label' => 'None'])->values()->all()"
|
||||
:disabled="! auth()->user()->can('update', $gitlab_app)" />
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@@ -249,22 +248,14 @@
|
||||
helper="Use a custom public URL when Coolify is behind a tunnel or reverse proxy." />
|
||||
</div>
|
||||
<div class="lg:col-span-2" x-show="!useCustomWebhookEndpoint">
|
||||
<x-forms.select wire:model.live="webhook_endpoint" x-model="webhookEndpoint"
|
||||
<x-forms.listbox id="webhook_endpoint" x-model="webhookEndpoint"
|
||||
label="Selected endpoint"
|
||||
helper="GitLab will use this endpoint unless custom mode is enabled.">
|
||||
@if ($fqdn)
|
||||
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
|
||||
@endif
|
||||
@if ($ipv4)
|
||||
<option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option>
|
||||
@endif
|
||||
@if ($ipv6)
|
||||
<option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option>
|
||||
@endif
|
||||
@if (config('app.url'))
|
||||
<option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option>
|
||||
@endif
|
||||
</x-forms.select>
|
||||
helper="GitLab will use this endpoint unless custom mode is enabled."
|
||||
:options="collect([$fqdn, $ipv4, $ipv6, config('app.url')])
|
||||
->filter()->unique()->map(fn ($endpoint) => [
|
||||
'value' => $endpoint,
|
||||
'label' => 'Use '.$endpoint,
|
||||
])->values()->all()" />
|
||||
</div>
|
||||
<div class="lg:col-span-2" x-cloak x-show="useCustomWebhookEndpoint">
|
||||
<x-forms.input x-model="customWebhookEndpoint" id="custom_webhook_endpoint"
|
||||
|
||||
@@ -126,9 +126,10 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu
|
||||
->toContain('server-timing-hud-slot-mobile')
|
||||
->toContain("matchMedia('(min-width: 1024px)')")
|
||||
->toContain('floats bottom-left only if no navbar slot is available')
|
||||
// Mobile pill is compact (app ms only); full "ST · db · q" breakdown stays desktop/float.
|
||||
->toContain("parentElement.id === 'server-timing-hud-slot-mobile'")
|
||||
->toContain('compactSummary');
|
||||
// Both navbar pills stay compact (app ms only); the full breakdown lives in the panel/float fallback.
|
||||
->toContain("root.getAttribute('data-sth-mode') === 'docked'")
|
||||
->toContain('compactSummary')
|
||||
->not->toContain("compactSummary = root.getAttribute('data-sth-mode') === 'docked'\n && root.parentElement");
|
||||
});
|
||||
|
||||
test('Server-Timing HUD follows the application color mode', function () {
|
||||
|
||||
@@ -98,15 +98,20 @@ it('uses safe domain validation rules on the domains form', function () {
|
||||
|
||||
it('lists existing domains as individual rows', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com,https://www.example.com',
|
||||
'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com',
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
->assertSet('domainRows.0.url', 'https://app.example.com')
|
||||
->assertSet('domainRows.0.url', 'https://example.com')
|
||||
->assertSet('domainRows.1.url', 'https://www.example.com')
|
||||
->assertSee('https://app.example.com')
|
||||
->assertSee('https://www.example.com');
|
||||
->assertSee('https://example.com')
|
||||
->assertSee('https://www.example.com')
|
||||
->assertSee('https://example.com/favicon.ico', false)
|
||||
->assertSee('x-on:error="$el.remove()"', false)
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
@@ -1221,13 +1226,49 @@ it('uses the compact service domains layout for compose applications', function
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
|
||||
->toContain('class="data-table-header domains-table-grid"')
|
||||
->toContain('id="edit-domain-direction"')
|
||||
->toContain('<span>Direction</span>')
|
||||
->toContain('<span>Search engine indexing</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
|
||||
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the edit domain modal', function () {
|
||||
it('uses compact labeled domain cards on mobile', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($styles)
|
||||
->toContain('@media (max-width: 768px)')
|
||||
->toContain('.domains-mobile-label')
|
||||
->toContain('.domains-table-grid .listbox-trigger')
|
||||
->and($row)
|
||||
->toContain('domains-mobile-label')
|
||||
->toContain('Search engine indexing')
|
||||
->toContain('Direction');
|
||||
});
|
||||
|
||||
it('uses segmented fields when adding and editing application domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-forms.domain-input id="newDomain"')
|
||||
->toContain('<x-forms.domain-input id="editingDomainLocal"')
|
||||
->not->toContain('placeholder="https://app.example.com"')
|
||||
->and($component)
|
||||
->toContain('Protocol')
|
||||
->toContain('Domain')
|
||||
->toContain('Port')
|
||||
->toContain('Path')
|
||||
->toContain("scheme: 'https'")
|
||||
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
|
||||
->not->toContain('<select id="{{ $id }}-protocol"')
|
||||
->toContain('min="1"')
|
||||
->toContain('max="65535"');
|
||||
});
|
||||
|
||||
it('updates a compose service redirect from the domains table', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
@@ -1240,10 +1281,7 @@ it('updates a compose service redirect from the edit domain modal', function ()
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('isCompose', true)
|
||||
->set('composeServices', ['web'])
|
||||
->call('startEdit', 0)
|
||||
->assertSet('editingDirection', 'both')
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->call('updateServiceRedirect', 'web', 'www')
|
||||
->assertDispatched('success');
|
||||
|
||||
$domains = json_decode($this->application->fresh()->docker_compose_domains, true);
|
||||
@@ -1365,8 +1403,12 @@ it('updates search engine indexing from the domains view', function () {
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', 'https://staging.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
|
||||
@@ -18,7 +18,22 @@ test('docker compose heading separates its title and action', function () {
|
||||
|
||||
expect($view)
|
||||
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
|
||||
->toContain('<div class="flex items-center justify-between gap-4">');
|
||||
->toContain('<div class="mb-2 flex items-center justify-between gap-4">');
|
||||
});
|
||||
|
||||
test('unsaved changes ignore compose initialization state', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$unsavedBar = str($view)
|
||||
->after('<x-unsaved-bar action="submit"')
|
||||
->before('/>')
|
||||
->toString();
|
||||
|
||||
expect($unsavedBar)
|
||||
->toContain('targets="')
|
||||
->toContain('name,description')
|
||||
->not->toContain('initLoadingCompose')
|
||||
->not->toContain('dockerComposeRaw')
|
||||
->not->toContain('dockerCompose,');
|
||||
});
|
||||
|
||||
test('onboarding uses the reusable advanced settings component', function () {
|
||||
|
||||
@@ -35,3 +35,13 @@ it('keeps a tooltip hidden until its measured position is applied', function ()
|
||||
->toContain('this.$nextTick(() => this.positioned = true);')
|
||||
->toContain("positioned ? 'visible' : 'invisible'");
|
||||
});
|
||||
|
||||
it('anchors tooltips to the trigger and lets them grow toward the right', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('this.x = rect.left;')
|
||||
->toContain('Math.min(window.innerWidth - width - 8, this.x)')
|
||||
->not->toContain('rect.left + rect.width / 2')
|
||||
->not->toContain('-translate-x-1/2');
|
||||
});
|
||||
|
||||
@@ -54,10 +54,14 @@ it('opens the command palette without changing page scrollbar visibility', funct
|
||||
it('animates the command palette with tw animate utilities', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// Exit animations need fill-mode-forwards: without it the element snaps
|
||||
// back to full opacity when the keyframe animation ends, one frame before
|
||||
// Alpine applies display:none, which flashes the palette on close.
|
||||
expect($view)
|
||||
->toContain('<div x-show="modalOpen" @click="closeModal()"')
|
||||
->toContain('x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100 fill-mode-forwards"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 duration-100 fill-mode-forwards"')
|
||||
->not->toContain('<div x-show="modalOpen" x-cloak\n class="fixed inset-0');
|
||||
});
|
||||
|
||||
@@ -68,3 +72,26 @@ it('closes the client-side command palette without a Livewire request', function
|
||||
->not->toContain('$wire.closeSearchModal()')
|
||||
->not->toContain('closeTimer');
|
||||
});
|
||||
|
||||
it('keeps palette content intact during the close animation to prevent flicker', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// closeModal() must only hide the palette immediately; content resets
|
||||
// (searchQuery, allSearchableItems) are deferred past the 100ms leave
|
||||
// animation so the panel does not collapse while fading out.
|
||||
expect($view)
|
||||
->toContain('clearTimeout(this.closeResetTimer);')
|
||||
->toContain("this.closeResetTimer = setTimeout(() => {\n this.isLoadingInitialData = false;")
|
||||
->toContain("this.searchQuery = '';\n this.allSearchableItems = [];");
|
||||
});
|
||||
|
||||
it('delays the header spinner so fast cached loads do not flash the icon', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('showLoadingSpinner')
|
||||
->toContain('this.spinnerTimer = setTimeout(')
|
||||
->toContain('x-show="!showLoadingSpinner"')
|
||||
->toContain('x-show="showLoadingSpinner"')
|
||||
->not->toContain(':class="isLoadingInitialData && \'is-loading\'"');
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ it('renders the changed configuration labels without a second backend request',
|
||||
expect($view)
|
||||
->toContain(':compact-after="5000"')
|
||||
->toContain(':compact-storage-key="$compactStorageKey"')
|
||||
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
|
||||
->toContain('x-on:click="configurationDiffModalOpen = true"')
|
||||
->not->toContain('$wire.refreshConfigurationChanges()');
|
||||
});
|
||||
@@ -93,9 +94,11 @@ it('supports timed compact popup notifications', function () {
|
||||
->toContain('compact = true')
|
||||
->toContain('@click="restore()"')
|
||||
->toContain('@click.stop="minimizeToIcon()"')
|
||||
->toContain('x-show="iconOnly"')
|
||||
->toContain('x-show="!iconOnly"')
|
||||
->not->toContain(':class="iconOnly')
|
||||
->toContain('x-show="!compact"')
|
||||
->toContain("'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]'");
|
||||
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
|
||||
});
|
||||
|
||||
it('warns when a service has missing required environment variables', function () {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
test('remaining user-facing dropdown fields use the shared animated listbox', function () {
|
||||
$views = [
|
||||
'livewire/source/gitlab/change.blade.php' => ['privateKeyId', 'webhook_endpoint'],
|
||||
'livewire/project/application/domains.blade.php' => ['newDomainService'],
|
||||
'livewire/project/service/domains.blade.php' => ['newServiceApplicationId'],
|
||||
'livewire/server/new/by-ip.blade.php' => ['is_build_server'],
|
||||
];
|
||||
|
||||
foreach ($views as $path => $ids) {
|
||||
$view = file_get_contents(resource_path('views/'.$path));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
expect($view)
|
||||
->toContain('<x-forms.listbox')
|
||||
->toContain('id="'.$id.'"')
|
||||
->not->toMatch('/<x-forms\.select[^>]*id="'.preg_quote($id, '/').'"/s');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,13 @@ it('renders the resource terminal shell while containers are discovered', functi
|
||||
->not->toContain('<x-loading text="Loading containers" />');
|
||||
});
|
||||
|
||||
it('marks stopped services as loaded before the terminal page first renders', function () {
|
||||
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toMatch('/elseif \(data_get\(\$this->parameters, \'service_uuid\'\)\).*?if \(! \$this->resource->isRunning\(\)\) \{\s*\$this->containersLoaded = true;\s*\}/s');
|
||||
});
|
||||
|
||||
it('provides opt-in diagnostics for connected terminal theme changes', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ test('server creation keeps private key actions together and advanced options co
|
||||
expect($view)
|
||||
->toContain('class="flex items-end gap-3"')
|
||||
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
|
||||
->toContain('<x-forms.select id="is_build_server"')
|
||||
->toContain('<x-forms.listbox id="is_build_server"')
|
||||
->toContain('label="Use as a dedicated build server"')
|
||||
->toContain('<option value="0">No</option>')
|
||||
->toContain('<option value="1">Yes</option>')
|
||||
->toContain("['value' => false, 'label' => 'No']")
|
||||
->toContain("['value' => true, 'label' => 'Yes']")
|
||||
->not->toContain('<x-forms.checkbox id="is_build_server"')
|
||||
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ beforeEach(function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups configured domains and shows redirect settings in the edit modal', function () {
|
||||
it('groups configured domains and shows redirect settings in the table', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://admin.example.com',
|
||||
]);
|
||||
@@ -100,13 +100,14 @@ it('groups configured domains and shows redirect settings in the edit modal', fu
|
||||
->assertSee('API')
|
||||
->assertSee('https://api.example.com')
|
||||
->assertSee('https://admin.example.com')
|
||||
->call('startEdit', 0)
|
||||
->html();
|
||||
|
||||
expect($html)
|
||||
->toContain("service-domain-group-{$this->apiApp->id}")
|
||||
->toContain("id=\"edit-service-domain-redirect-{$this->apiApp->id}-trigger\"")
|
||||
->toContain("serviceRedirects.{$this->apiApp->id}")
|
||||
->toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain('src="https://api.example.com/favicon.ico"')
|
||||
->toContain('x-on:error="$el.remove()"')
|
||||
->toContain('class="listbox-trigger"')
|
||||
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
|
||||
->toContain('dark:bg-white/[0.04]')
|
||||
@@ -119,6 +120,27 @@ it('groups configured domains and shows redirect settings in the edit modal', fu
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
it('shows one redirect control for each www and non-www pair', function () {
|
||||
$this->apiApp->update([
|
||||
'fqdn' => 'https://api.example.com,https://www.api.example.com,https://admin.example.com,https://www.admin.example.com',
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSuccessful()
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('uses segmented fields when adding and editing service domains', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-forms.domain-input id="newDomain"')
|
||||
->toContain('<x-forms.domain-input id="editingDomainLocal"')
|
||||
->not->toContain('placeholder="https://app.example.com"');
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSuccessful()
|
||||
@@ -366,13 +388,11 @@ it('prunes the previous dns status when a service domain is renamed', function (
|
||||
->and($this->apiApp->domain_dns_statuses['https://renamed.example.com']['status'])->toBe('skipped');
|
||||
});
|
||||
|
||||
it('only shows the domain notification when redirect changes through edit domain', function () {
|
||||
it('updates redirect independently from editing a domain', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('startEdit', 0)
|
||||
->set('editingDirection', 'www')
|
||||
->call('updateDomain')
|
||||
->assertDispatched('success', 'Domain updated.')
|
||||
->assertNotDispatched('success', 'Redirect updated.');
|
||||
->call('updateServiceRedirect', $this->apiApp->id, 'www')
|
||||
->assertDispatched('success', 'Redirect updated.')
|
||||
->assertNotDispatched('success', 'Domain updated.');
|
||||
});
|
||||
|
||||
it('does not restore stale dns status when a removed service domain is re-added', function () {
|
||||
@@ -532,9 +552,12 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Noindex')
|
||||
->assertSee('Indexable')
|
||||
->assertSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('@change="$wire.toggleNoindexDomain', false)
|
||||
->assertDontSee('@change="$wire.updateServiceRedirect', false)
|
||||
->assertSee('Search engine indexing')
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Support\DomainUrlParts;
|
||||
|
||||
it('composes a domain URL from segmented fields', function () {
|
||||
expect(DomainUrlParts::compose('https', 'app.example.com', '3000', 'api/v3'))
|
||||
->toBe('https://app.example.com:3000/api/v3');
|
||||
});
|
||||
|
||||
it('splits a domain URL while preserving its path query and fragment', function () {
|
||||
expect(DomainUrlParts::split('http://app.example.com:8080/api?v=1#docs'))->toBe([
|
||||
'scheme' => 'http',
|
||||
'host' => 'app.example.com',
|
||||
'port' => '8080',
|
||||
'path' => '/api?v=1#docs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults empty values for an invalid URL', function () {
|
||||
expect(DomainUrlParts::split(''))->toBe([
|
||||
'scheme' => 'https',
|
||||
'host' => '',
|
||||
'port' => '',
|
||||
'path' => '',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an explicitly configured default port', function () {
|
||||
expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443');
|
||||
});
|
||||
Reference in New Issue
Block a user