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:
parent
61a2b183d3
commit
32bf1860d4
30 changed files with 693 additions and 292 deletions
7
.ai/lessons.md
Normal file
7
.ai/lessons.md
Normal file
|
|
@ -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 @@ protected function rules(): array
|
|||
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 @@ public function toggleNoindexDomain(string $domain, string|bool $indexing): void
|
|||
$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 @@ public function startEdit(int $index): void
|
|||
$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 @@ public function cancelEdit(): void
|
|||
$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 @@ public function updateDomain(): void
|
|||
$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 @@ public function updateDomain(): void
|
|||
}
|
||||
|
||||
$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 @@ public function setRedirect(): void
|
|||
}
|
||||
}
|
||||
|
||||
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\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 @@ protected function rules(): array
|
|||
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 @@ public function startEdit(int $index): void
|
|||
$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 @@ public function cancelEdit(): void
|
|||
$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 @@ public function updateDomain(): void
|
|||
$newUrl = $this->splitDomains($normalized)[0];
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured for this service.");
|
||||
|
|
@ -1000,18 +991,12 @@ public function updateDomain(): void
|
|||
}
|
||||
|
||||
$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 @@ public function generateDomain(): void
|
|||
$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 @@ public function mount(): void
|
|||
$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);
|
||||
}
|
||||
|
|
|
|||
56
app/Support/DomainUrlParts.php
Normal file
56
app/Support/DomainUrlParts.php
Normal file
|
|
@ -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' => ''];
|
||||
}
|
||||
}
|
||||
|
|
@ -2114,17 +2114,21 @@ .env-managed-desktop {
|
|||
}
|
||||
|
||||
.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 @@ @media (max-width: 900px) {
|
|||
}
|
||||
|
||||
.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 @@ @media (max-width: 900px) {
|
|||
}
|
||||
}
|
||||
|
||||
@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 @@ @media (max-width: 640px) {
|
|||
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 @@ @media (max-width: 640px) {
|
|||
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 @@ @media (max-width: 640px) {
|
|||
}
|
||||
|
||||
.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 @@ @media (max-width: 640px) {
|
|||
}
|
||||
|
||||
.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 @@ .domains-service-mobile {
|
|||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 768px) {
|
||||
.domains-service-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
76
resources/views/components/forms/domain-input.blade.php
Normal file
76
resources/views/components/forms/domain-input.blade.php
Normal file
|
|
@ -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 @@ class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100
|
|||
</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 @@ class="mt-0.5 text-[11px] leading-4 text-neutral-600 dark:text-fg-dim">
|
|||
</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 @@ function applyDockedStyles(root) {
|
|||
|
||||
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 @@ function paint() {
|
|||
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 @@ class="button button-highlighted">
|
|||
</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 @@ class="data-table w-full">
|
|||
<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 @@ class="px-4 py-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 @@ class="icon-button shrink-0" aria-label="Close">
|
|||
<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 @@ class="env-table-item">
|
|||
'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 @@ class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
|
|||
{{ $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 @@ class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline
|
|||
{{ $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 @@ class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
|||
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 @@ class="button button-highlighted">
|
|||
</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 @@ class="application-settings-form application-settings-section relative flex max-
|
|||
</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 @@ class="env-table-item">
|
|||
'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 @@ class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
|
|||
{{ $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 @@ class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline
|
|||
{{ $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 @@ class="h-7! shrink-0 px-2.5! text-[12px]!">
|
|||
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 @@ class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs trans
|
|||
: 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 @@ class="listbox-option justify-start! gap-2.5!" role="menuitem">
|
|||
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 @@ class="inline-flex w-fit items-center gap-1 font-medium text-black underline-off
|
|||
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 @@
|
|||
->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('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 @@
|
|||
->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 @@
|
|||
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 @@
|
|||
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 @@
|
|||
|
||||
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 @@
|
|||
->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('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 @@
|
|||
->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 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
|
|||
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 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
|
|||
->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 () {
|
||||
|
|
|
|||
21
tests/Feature/NativeSelectMigrationTest.php
Normal file
21
tests/Feature/NativeSelectMigrationTest.php
Normal file
|
|
@ -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 @@
|
|||
->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 @@
|
|||
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 @@
|
|||
]);
|
||||
});
|
||||
|
||||
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 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
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')
|
||||
|
|
|
|||
30
tests/Unit/DomainUrlPartsTest.php
Normal file
30
tests/Unit/DomainUrlPartsTest.php
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue