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:
Andras Bacsai committed 2026-08-11 00:06:55 +02:00
1 parent 61a2b183d3
commit 32bf1860d4
30 files changed
+693 -292

No files matched your search

+61 -12
View File
@@ -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"