diff --git a/DESIGN.md b/DESIGN.md index a7b26bb66..b27ed68f2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ ### Unsaved changes Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5d..a9e1c0be2 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ private function syncData(bool $toModel = false): void $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ private function syncData(bool $toModel = false): void $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 80eb9bf0a..906cc147a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ public function mount(): void public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ public function loadDomainState(): void $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ public function loadDomainState(): void } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ public function loadDomainState(): void $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -973,7 +988,13 @@ public function updatedShowDomainConflictModal(bool $value): void return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index fa0272bd6..832a0d55c 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,29 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4ac2f40b2..b4f76d12d 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ public function mount(): void public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ public function loadDomainState(): void ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -924,6 +937,7 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -940,6 +954,11 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -948,12 +967,13 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -980,11 +1000,24 @@ public function confirmRemovePort(): void return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1421,6 +1454,7 @@ protected function saveDomainListForApp( ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1447,7 +1481,7 @@ protected function saveDomainListForApp( } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/resources/css/app.css b/resources/css/app.css index 65bce6327..e3e20cf75 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4396,3 +4396,63 @@ .command-palette-arch-badge { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .service-domain-detail { + justify-content: space-between; + gap: 0.5rem; + } + + .service-domain-detail-label { + display: inline; + color: var(--coollabs-fg-dim); + } +} diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php index 8d029c2fb..cbc137e07 100644 --- a/resources/views/components/unsaved-bar.blade.php +++ b/resources/views/components/unsaved-bar.blade.php @@ -5,6 +5,8 @@ // appears when those fields differ from the last server snapshot — not on // incidental component state (e.g. $wire.set from x-init, display-only props). 'targets' => null, + // Optional Alpine expression for drafts that survive unrelated server requests. + 'dirty' => null, ]) {{-- Floating "unsaved changes" pill (bottom center). Reveals itself via @@ -40,7 +42,8 @@ window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset); window.removeEventListener('resize', this.updateKeyboardInset); }, -}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty" +}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" + @if ($dirty) x-bind:class="{ 'is-dirty': {{ $dirty }} }" @else wire:dirty.class="is-dirty" @endif wire:loading.class="is-saving" @keydown.enter.window=" if ($el.classList.contains('is-dirty') && diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 429689ce7..c6113808c 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -55,7 +55,7 @@ @if ($application->git_based()) + helper="Automatic deployments from Git webhooks.">
true, 'label' => 'Deploy on push (webhooks)'], ['value' => false, 'label' => 'Manual deployments only'], ]" :disabled="! $canUpdate" /> - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index b80085b9d..93edafde5 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -6,26 +6,31 @@ $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); - $helperText = $isCompose - ? 'Manage domains for every service in this Docker Compose application.' - : 'Manage domains for this application.'; $hasHttpsDomains = collect($domainRows)->contains( fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://') ); @endphp - @endif - - @can('update', $application) - - - - Recheck DNS - - - @endcan + @if ($labelsAreWritable) + + Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. + + @endif - @if ($labelsAreWritable) - - Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. - - @endif + @if ($isCompose && count($composeServices) === 0) + + No non-database services found in the Docker Compose file. Domains can only be assigned to application + services. + + @endif - @if ($isCompose && count($composeServices) === 0) - - No non-database services found in the Docker Compose file. Domains can only be assigned to application - services. - - @endif - - @cannot('update', $application) - - You don't have permission to manage domains. Contact your team administrator for access. - - @endcannot - -

- {{ $helperText }} -

- - @if ($hasHttpsDomains && ! $labelsAreWritable) -
- -
- @endif - -
+ @cannot('update', $application) + + You don't have permission to manage domains. Contact your team administrator for access. + + @endcannot {{-- Toolbar --}} -
+
+

Domains

{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} @if ($suggestedCount > 0) @@ -98,7 +76,7 @@

- @if ($isCompose && $composeDomainGroups->isNotEmpty()) + @if ($hasRows)
@@ -107,6 +85,10 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
@endif @can('update', $application) + + + Check all DNS +
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -118,7 +100,7 @@ class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or dom
@@ -168,20 +150,46 @@ class="button button-highlighted">
+ @if ($hasHttpsDomains && ! $labelsAreWritable) +
+ + +
+ +
+
+ @endif + {{-- Table / empty --}}
+ @if ($hasRows) +
+ Domain + Protocol redirect + Domain redirect + Internal port + Search indexing + DNS status + Actions +
+ @endif @if ($isCompose && count($composeServices) === 0 && ! $hasRows) @elseif ($isCompose && $composeDomainGroups->isEmpty()) @elseif (! $hasRows) @elseif ($isCompose) @php @@ -213,36 +221,10 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10"> {{ $serviceName }} -
- - @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) - - @else - - {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { - 'www' => 'Redirect to www', - 'non-www' => 'Redirect to non-www', - default => 'Allow both', - } }} - - @endif -
-
-
- Domain - DNS Check - Search engine indexing - -
@foreach ($rows as $row) @php $index = collect($domainRows)->search( @@ -256,9 +238,7 @@ class="data-table w-full"> 'row' => $row, 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, - 'isCompose' => false, - 'showDirectionControl' => false, - 'domainGridClass' => 'domains-table-grid-service', + 'isCompose' => true, ]) @endforeach
@@ -273,13 +253,6 @@ class="px-4 py-8">
@else
-
- Domain - DNS Check - Search engine indexing - Direction - -
@foreach ($domainRows as $index => $row) @include('livewire.project.application.partials.domain-row', [ 'index' => $index, @@ -290,10 +263,15 @@ class="px-4 py-8"> ]) @endforeach
+
+ +
@endif - {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}} + {{-- One dialog for address edits and automatically saved domain settings. --}}