From a7a06aa6c802fb3cb976de96b8c13b987223d9ab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:50:11 +0200 Subject: [PATCH] feat(ui): polish domains, storage, env vars and resource nav Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests. --- .../Project/Application/Backup/Create.php | 27 +- .../Project/Application/Configuration.php | 23 +- app/Livewire/Project/Application/Domains.php | 199 +++++++---- app/Livewire/Project/Application/Heading.php | 18 +- app/Livewire/Project/Resource/Index.php | 16 +- app/Livewire/Project/Service/Domains.php | 150 ++++++--- app/Livewire/Project/Service/Storage.php | 129 +++++++- .../Project/Shared/ConfigurationChecker.php | 37 ++- .../Shared/EnvironmentVariable/All.php | 150 ++++++++- .../Shared/EnvironmentVariable/Show.php | 2 + .../EnvironmentVariable/ShowHardcoded.php | 2 + app/Livewire/Project/Shared/Storages/All.php | 276 ++++++++++++++- app/Livewire/Project/Shared/Storages/Show.php | 36 +- app/Support/DnsRecordHints.php | 39 ++- bootstrap/helpers/shared.php | 5 +- config/constants.php | 2 - docker-compose-maxio.dev.yml | 1 - docker-compose.dev.yml | 1 - resources/css/app.css | 313 +++++++++++++++--- resources/css/utilities.css | 15 +- resources/js/app.js | 107 ++++++ .../views/components/forms/listbox.blade.php | 7 +- resources/views/components/helper.blade.php | 4 +- .../views/components/project/navbar.blade.php | 3 +- resources/views/layouts/base.blade.php | 14 +- .../project/application/advanced.blade.php | 9 +- .../application/backup/index.blade.php | 2 +- .../project/application/backup/show.blade.php | 2 +- .../application/configuration.blade.php | 13 +- .../application/deployment/index.blade.php | 2 +- .../application/deployment/show.blade.php | 2 +- .../project/application/domains.blade.php | 213 +++++++----- .../project/application/general.blade.php | 2 +- .../project/application/heading.blade.php | 128 +------ .../application/partials/domain-row.blade.php | 47 ++- .../project/database/heading.blade.php | 6 +- .../project/service/domains.blade.php | 210 +++++++----- .../project/service/file-storage.blade.php | 4 +- .../project/service/heading.blade.php | 6 +- .../service/partials/domain-table.blade.php | 47 ++- .../project/service/storage.blade.php | 210 +++++------- .../shared/cloudflare-autoconfigure.blade.php | 287 ++++++++-------- .../shared/environment-variable/all.blade.php | 191 ++++++++--- .../show-hardcoded.blade.php | 49 ++- .../environment-variable/show.blade.php | 35 +- .../execute-container-command.blade.php | 2 +- .../livewire/project/shared/logs.blade.php | 2 +- .../project/shared/resource-limits.blade.php | 13 +- .../shared/resource-operations.blade.php | 3 +- .../project/shared/storages/all.blade.php | 233 ++++++++++++- .../project/shared/storages/show.blade.php | 312 ++++++++--------- .../views/livewire/server/navbar.blade.php | 6 +- resources/views/v5/app.blade.php | 12 +- tests/Feature/ApplicationDomainsTest.php | 163 ++++++++- .../ApplicationHeadingActiveTabTest.php | 173 ++++++++++ tests/Feature/DnsRecordHintsTest.php | 9 +- .../EnvironmentVariablePaginationTest.php | 11 + .../EnvironmentVariableTableLayoutTest.php | 99 ++++++ tests/Feature/FileStorageMountPathTest.php | 21 +- tests/Feature/FormControlHeightTest.php | 50 +++ tests/Feature/HelperInfoButtonTest.php | 1 + .../Feature/ListboxTriggerTruncationTest.php | 22 ++ .../Livewire/ConfigurationCheckerTest.php | 83 +++-- .../AdvancedContainerNamingTest.php | 106 ++++++ tests/Feature/MenuItemActiveNoBorderTest.php | 26 ++ tests/Feature/MenuItemContrastTest.php | 30 ++ .../PersistentStoragePerformanceTest.php | 189 +++++++++++ .../PersistentStorageVolumesLayoutTest.php | 203 ++++++++++++ .../ResourceIndexDescriptionSubtitleTest.php | 11 + tests/Feature/ResourceIndexTypeLabelTest.php | 119 +++++++ .../ResourceLimitsCpuDocsHeaderTest.php | 16 + tests/Feature/ServiceDomainsTest.php | 52 ++- .../Feature/SettingsSectionHighlightTest.php | 46 +++ tests/Feature/VolumeBackupTest.php | 22 +- tests/Unit/DnsRecordTypeHelpersTest.php | 6 +- 75 files changed, 3909 insertions(+), 1173 deletions(-) create mode 100644 tests/Feature/ApplicationHeadingActiveTabTest.php create mode 100644 tests/Feature/EnvironmentVariableTableLayoutTest.php create mode 100644 tests/Feature/FormControlHeightTest.php create mode 100644 tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php create mode 100644 tests/Feature/MenuItemActiveNoBorderTest.php create mode 100644 tests/Feature/MenuItemContrastTest.php create mode 100644 tests/Feature/PersistentStoragePerformanceTest.php create mode 100644 tests/Feature/PersistentStorageVolumesLayoutTest.php create mode 100644 tests/Feature/ResourceIndexDescriptionSubtitleTest.php create mode 100644 tests/Feature/ResourceIndexTypeLabelTest.php create mode 100644 tests/Feature/ResourceLimitsCpuDocsHeaderTest.php create mode 100644 tests/Feature/SettingsSectionHighlightTest.php diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 4e46ba779..68115e775 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -38,6 +38,32 @@ class Create extends Component public function mount(): void { $this->authorize('view', $this->application); + $this->targetLocked = $this->selectedTargetKey !== null; + $this->targetKey = $this->selectedTargetKey; + + // When opened from a volume row the target is fixed — skip listing every volume/directory. + if ($this->targetLocked && is_string($this->selectedTargetKey)) { + $target = $this->selectedTarget(); + if ($target instanceof LocalPersistentVolume) { + $this->targets = collect([[ + 'key' => 'volume:'.$target->id, + 'type' => 'Volume', + 'name' => $target->name, + ]]); + } elseif ($target instanceof LocalFileVolume) { + $this->targets = collect([[ + 'key' => 'directory:'.$target->id, + 'type' => 'Directory', + 'name' => $target->fs_path, + ]]); + } else { + $this->targets = collect(); + } + $this->loadSelectedBackup(); + + return; + } + $volumes = $this->application->persistentStorages() ->orderBy('name') ->get() @@ -57,7 +83,6 @@ class Create extends Component 'name' => $directory->fs_path, ]); $this->targets = $volumes->concat($directories)->values(); - $this->targetLocked = $this->selectedTargetKey !== null; $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->loadSelectedBackup(); } diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index fb069f65b..ec032c3c9 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -24,7 +24,7 @@ class Configuration extends Component public function mount() { - $this->currentRoute = request()->route()->getName(); + $this->syncCurrentRoute(); $project = currentTeam() ->projects() @@ -36,10 +36,14 @@ class Configuration extends Component ->where('uuid', request()->route('environment_uuid')) ->firstOrFail(); $application = $environment->applications() - ->with(['destination']) + ->with(['destination.server', 'environment.project']) ->where('uuid', request()->route('application_uuid')) ->firstOrFail(); + // Parent page already resolved these; keep them on the model for nested components. + $application->setRelation('environment', $environment); + $environment->setRelation('project', $project); + $this->project = $project; $this->environment = $environment; $this->application = $application; @@ -49,8 +53,23 @@ class Configuration extends Component } } + /** + * Keep sidebar active state in sync on full-page navigations. + * Ignore Livewire update requests so poll/refresh does not clear it. + */ + protected function syncCurrentRoute(): void + { + $routeName = request()->route()?->getName(); + + if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) { + $this->currentRoute = $routeName; + } + } + public function render() { + $this->syncCurrentRoute(); + return view('livewire.project.application.configuration'); } } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 26a9642ea..cfef84ad7 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -200,6 +200,10 @@ class Domains extends Component } } + // Do not auto-promote www/non-www pairs here: load/refresh also runs after + // removeDomain, and re-adding counterparts would undo intentional deletes. + // Pairs are still ensured on setRedirect, addDomain, and generateDomain. + $this->domainRows = $this->buildDomainRows(); } @@ -269,14 +273,26 @@ class Domains extends Component } } - return $rows; + return $this->sortDomainRowsByDnsStatus($rows); } foreach ($this->splitDomains($this->application->fqdn) as $url) { $rows[] = $this->domainRowFromStored($url, null, $stored); } - return array_merge($rows, $this->buildSuggestedWwwRows($rows, $stored)); + return $this->sortDomainRowsByDnsStatus(array_merge($rows, $this->buildSuggestedWwwRows($rows, $stored))); + } + + /** + * @param array> $rows + * @return array> + */ + protected function sortDomainRowsByDnsStatus(array $rows): array + { + return collect($rows) + ->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1) + ->values() + ->all(); } /** @@ -327,19 +343,10 @@ class Domains extends Component $base['is_suggested'] = true; $base['suggested_for'] = $url; - $base['suggestion_label'] = $meta['label']; + $base['suggestion_label'] = null; $base['suggestion_role'] = $meta['role']; $base['needs_force_add'] = false; - - // Always show role-specific guidance for suggested rows (even after DNS checks). - if (($base['dns_status'] ?? 'pending') === 'pending') { - $base['dns_message'] = $meta['pending_message']; - } elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) { - // Keep stored DNS result message, but append role context when redirect is set. - if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) { - $base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']); - } - } + $base['dns_message'] = $meta['pending_message']; $suggested[] = $base; } @@ -354,41 +361,40 @@ class Domains extends Component */ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array { - $pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); - + $pendingMessage = 'Not configured yet.'; $redirect = $redirectOverride ?? ($this->redirect ?: 'both'); return match ($redirect) { 'www' => $suggestedIsWww ? [ - 'label' => 'Canonical www', + 'label' => 'Not added · canonical www', 'role' => 'canonical', - 'pending_message' => "Required as the redirect target (www). {$pointDns}", - 'dns_suffix' => 'This is the canonical www host traffic should land on.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ] : [ - 'label' => 'Redirect source', + 'label' => 'Not added · redirect source', 'role' => 'redirect_source', - 'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}", - 'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ], 'non-www' => $suggestedIsWww ? [ - 'label' => 'Redirect source', + 'label' => 'Not added · redirect source', 'role' => 'redirect_source', - 'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}", - 'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ] : [ - 'label' => 'Canonical non-www', + 'label' => 'Not added · canonical non-www', 'role' => 'canonical', - 'pending_message' => "Required as the redirect target (non-www). {$pointDns}", - 'dns_suffix' => 'This is the canonical non-www host traffic should land on.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ], default => [ - 'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www', + 'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www', 'role' => 'pair', - 'pending_message' => "Also add this host so both www and non-www work. {$pointDns}", + 'pending_message' => $pendingMessage, 'dns_suffix' => '', ], }; @@ -546,7 +552,7 @@ class Domains extends Component $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; } - // Clarify purpose for redirect-source / canonical suggested hosts. + // Keep suggested-row copy short after DNS checks (no role badge). if ($this->domainRows[$index]['is_suggested'] ?? false) { $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); $serviceName = $this->domainRows[$index]['service'] ?? null; @@ -554,10 +560,8 @@ class Domains extends Component $isWww, $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) ); - if ($meta['dns_suffix'] !== '') { - $this->domainRows[$index]['dns_message'] = trim($this->domainRows[$index]['dns_message'].' '.$meta['dns_suffix']); - } - $this->domainRows[$index]['suggestion_label'] = $meta['label']; + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; $this->domainRows[$index]['suggestion_role'] = $meta['role']; } @@ -727,6 +731,11 @@ class Domains extends Component } $newUrls = $this->splitDomains($normalized); + $pairedUrls = collect($newUrls) + ->map(fn (string $url) => $this->wwwCounterpartUrl($url)) + ->filter() + ->values() + ->all(); $current = $this->currentDomainList($this->newDomainService); foreach ($newUrls as $url) { @@ -747,10 +756,9 @@ class Domains extends Component } } - $merged = $current->merge($newUrls)->unique()->values(); + $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; - // DNS was already validated (or overridden) in the modal; skip save-time toast noise. - if (! $this->saveDomainList($merged, $this->newDomainService, checkDns: false)) { + if (! $this->saveDomainList($merged, $this->newDomainService)) { return; } @@ -761,7 +769,7 @@ class Domains extends Component $this->dispatch('close-modal'); $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns($newUrls, $serviceForCheck); + $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); } catch (\Throwable $e) { handleError($e, $this); } @@ -931,7 +939,6 @@ class Domains extends Component $this->forceAddSuggestedIndex = $index; $this->editingIndex = $index; $this->persistDomainDnsStatuses(); - $this->dispatch('error', 'DNS validation failed.', $dnsFailure); return; } @@ -940,7 +947,7 @@ class Domains extends Component $merged = $current->merge($newUrls)->unique()->values(); $this->pendingAction = 'suggested'; $this->editingIndex = $index; - if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) { + if (! $this->saveDomainList($merged, $serviceName)) { return; } @@ -1017,6 +1024,7 @@ class Domains extends Component if ($dnsFailure !== null) { $this->editDomainDnsFailed = true; $this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure); + $this->showEditDomainModal = true; return; } @@ -1024,7 +1032,7 @@ class Domains extends Component $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $this->pendingAction = 'update'; - if (! $this->saveDomainList($updated, $service, checkDns: false)) { + if (! $this->saveDomainList($updated, $service)) { return; } @@ -1058,7 +1066,7 @@ class Domains extends Component $service = $this->domainRows[$index]['service']; $updated = $this->currentDomainList($service)->reject(fn (string $item) => $item === $url)->values(); - if (! $this->saveDomainList($updated, $service, checkConflicts: false, checkDns: false)) { + if (! $this->saveDomainList($updated, $service, checkConflicts: false)) { return; } @@ -1104,26 +1112,32 @@ class Domains extends Component $current = $this->currentDomainList($serviceName); $merged = $current->push($domain)->unique()->values(); - if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false, checkDns: false)) { + if (! $this->saveDomainList($merged, $serviceName, checkConflicts: false)) { return; } + $pairedUrls = $this->syncRedirectDomainPairs($serviceName); $this->resetAddDomainForm(); $this->dispatch('close-modal'); $this->dispatch('success', 'Domain generated.'); $this->refreshDomains(); + $this->checkUrlsDns(array_values(array_unique(array_merge([$domain], $pairedUrls))), $serviceName); return; } $fqdn = generateUrl(server: $server, random: $this->application->uuid); - $this->application->fqdn = $fqdn; - $this->application->save(); - $this->resetDefaultLabels(); + $merged = $this->currentDomainList()->push($fqdn)->unique()->values(); + if (! $this->saveDomainList($merged, null, checkConflicts: false)) { + return; + } + + $pairedUrls = $this->syncRedirectDomainPairs(null); $this->resetAddDomainForm(); $this->dispatch('close-modal'); $this->dispatch('success', 'Domain generated.'); $this->refreshDomains(); + $this->checkUrlsDns(array_values(array_unique(array_merge([$fqdn], $pairedUrls)))); } catch (\Throwable $e) { handleError($e, $this); } @@ -1298,6 +1312,74 @@ class Domains extends Component return true; } + /** + * When saved redirect is www/non-www, ensure missing counterparts exist as real domains + * (not suggestion rows the user must click Add domain for). + * + * @return array newly added domain URLs + */ + protected function syncRedirectDomainPairs(?string $serviceName = null): array + { + if ($this->labelsAreWritable) { + return []; + } + + $user = auth()->user(); + if ($user === null || ! $user->can('update', $this->application)) { + return []; + } + + if ($this->isCompose && $serviceName === null) { + $added = []; + $serviceNames = $this->composeServices; + $domains = $this->application->docker_compose_domains + ? json_decode($this->application->docker_compose_domains, true) + : []; + if (is_array($domains)) { + foreach (array_keys($domains) as $name) { + if (! in_array($name, $serviceNames, true)) { + $serviceNames[] = $name; + } + } + } + foreach ($serviceNames as $name) { + $added = array_merge($added, $this->syncRedirectDomainPairs($name)); + } + + return array_values(array_unique($added)); + } + + $redirect = $this->savedRedirectForService($serviceName); + if (! in_array($redirect, ['www', 'non-www'], true)) { + return []; + } + + $before = $this->currentDomainList($serviceName)->all(); + if (! $this->ensureWwwNonWwwPairsConfigured($serviceName)) { + return []; + } + + $this->application->refresh(); + $after = $this->currentDomainList($serviceName); + + return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all(); + } + + protected function savedRedirectForService(?string $serviceName): string + { + if ($this->isCompose && filled($serviceName)) { + $domains = $this->application->docker_compose_domains + ? json_decode($this->application->docker_compose_domains, true) + : []; + $entry = is_array($domains) ? ($domains[$serviceName] ?? null) : null; + $stored = is_array($entry) ? ($entry['redirect'] ?? null) : null; + + return $this->normalizeRedirect(is_string($stored) ? $stored : null); + } + + return $this->normalizeRedirect($this->application->redirect ?? null); + } + /** * Persist missing www/non-www counterparts as normal domains (not suggestions). * @@ -1346,10 +1428,13 @@ class Domains extends Component $this->pendingRedirectService = $serviceName; // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainList($merged, $serviceName, checkDns: false)) { + if (! $this->saveDomainList($merged, $serviceName)) { return false; } + $this->pendingAction = null; + $this->pendingRedirectService = null; + return true; } @@ -1446,7 +1531,6 @@ class Domains extends Component Collection $domains, ?string $serviceName = null, bool $checkConflicts = true, - bool $checkDns = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1488,25 +1572,6 @@ class Domains extends Component $this->application->fqdn = $domainString; } - if ($checkDns && $domainString && $this->application->additional_servers->count() === 0) { - $server = $this->application->destination?->server; - if ($server) { - foreach ($this->splitDomains($domainString) as $domain) { - if (! validateDNSEntry($domain, $server)) { - $guidance = dnsMismatchGuidanceMessage( - $this->dnsTargetLabel() ?? serverDnsTargetIp($server) ?? $server->ip, - $this->serverIp ?? serverDnsTargetIp($server) ?? $server->ip, - ); - $this->dispatch( - 'error', - 'Validating DNS failed.', - "{$guidance}

Check this documentation for further help." - ); - } - } - } - } - if ($checkConflicts && ! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); if ($result['hasConflicts']) { diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index cafaac910..658f20277 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -40,7 +40,7 @@ class Heading extends Component public function mount() { - $this->activeRouteName = request()->route()?->getName() ?? ''; + $this->syncActiveRouteName(); $this->parameters = [ 'project_uuid' => $this->application->project()->uuid, 'environment_uuid' => $this->application->environment->uuid, @@ -51,6 +51,20 @@ class Heading extends Component $this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit')); } + /** + * Keep the active tab in sync with the real page route. + * Only update when the request is a full page route (not livewire.update), + * so wire:poll re-renders do not wipe the highlighted tab. + */ + protected function syncActiveRouteName(): void + { + $routeName = request()->route()?->getName(); + + if (is_string($routeName) && str_starts_with($routeName, 'project.application.')) { + $this->activeRouteName = $routeName; + } + } + public function checkStatus() { if ($this->application->destination->server->isFunctional()) { @@ -188,6 +202,8 @@ class Heading extends Component public function render() { + $this->syncActiveRouteName(); + return view('livewire.project.application.heading', [ 'checkboxes' => [ ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 0eaa0eb56..9a0fe9cd1 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -189,14 +189,14 @@ class Index extends Component 'clickhouses' => $this->clickhouses, 'services' => $this->services, 'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'), - 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'), - 'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'), - 'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'), - 'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'), - 'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'), - 'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'), - 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'), - 'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'), + 'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'Database'), + 'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Database'), + 'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'Database'), + 'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'Database'), + 'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'Database'), + 'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'Database'), + 'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Database'), + 'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'Database'), 'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'), ]); } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 761ba8ed2..0bb6d9240 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -158,6 +158,10 @@ class Domains extends Component $this->newServiceApplicationId = $this->serviceApps[0]['id']; } + // Do not auto-promote www/non-www pairs here: load/refresh also runs after + // removeDomain, and re-adding counterparts would undo intentional deletes. + // Pairs are still ensured on setServiceRedirect, addDomain, etc. + $this->domainRows = $this->buildDomainRows(); } @@ -197,7 +201,10 @@ class Domains extends Component } } - return $rows; + return collect($rows) + ->sortBy(fn (array $row): int => ($row['dns_status'] ?? null) === 'failed' ? 0 : 1) + ->values() + ->all(); } /** @@ -282,17 +289,10 @@ class Domains extends Component $base['is_suggested'] = true; $base['suggested_for'] = $url; - $base['suggestion_label'] = $meta['label']; + $base['suggestion_label'] = null; $base['suggestion_role'] = $meta['role']; $base['needs_force_add'] = false; - - if (($base['dns_status'] ?? 'pending') === 'pending') { - $base['dns_message'] = $meta['pending_message']; - } elseif (in_array($base['dns_status'], ['ok', 'failed', 'skipped'], true)) { - if ($meta['role'] !== 'pair' && ! str_contains((string) $base['dns_message'], 'redirect')) { - $base['dns_message'] = trim((string) $base['dns_message'].' '.$meta['dns_suffix']); - } - } + $base['dns_message'] = $meta['pending_message']; $suggested[] = $base; } @@ -381,6 +381,7 @@ class Domains extends Component $this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_message'] = 'DNS check skipped.'; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + $this->decorateSuggestedDomainAfterDnsCheck($index); $this->persistAllDomainDnsStatuses(); return; @@ -412,6 +413,24 @@ class Domains extends Component $this->domainRows[$index]['expected_ip'] = $this->serverIp; $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + $this->decorateSuggestedDomainAfterDnsCheck($index); + } + + /** + * Keep suggested-row copy short after a DNS check (no role badge). + */ + protected function decorateSuggestedDomainAfterDnsCheck(int $index): void + { + if (! ($this->domainRows[$index]['is_suggested'] ?? false)) { + return; + } + + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $appId = (int) ($this->domainRows[$index]['service_application_id'] ?? 0); + $meta = $this->suggestedDomainMeta($isWww, $this->serviceRedirectFor($appId > 0 ? $appId : null)); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } protected function persistAllDomainDnsStatuses(): void @@ -525,40 +544,40 @@ class Domains extends Component */ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOverride = null): array { - $pointDns = dnsMismatchGuidanceMessage($this->dnsTargetLabel(), $this->serverIp); + $pendingMessage = 'Not configured yet.'; $redirect = $this->normalizeRedirect($redirectOverride); return match ($redirect) { 'www' => $suggestedIsWww ? [ - 'label' => 'Canonical www', + 'label' => 'Not added · canonical www', 'role' => 'canonical', - 'pending_message' => "Required as the redirect target (www). {$pointDns}", - 'dns_suffix' => 'This is the canonical www host traffic should land on.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ] : [ - 'label' => 'Redirect source', + 'label' => 'Not added · redirect source', 'role' => 'redirect_source', - 'pending_message' => "Needed so Coolify can redirect non-www to www. {$pointDns}", - 'dns_suffix' => 'Used only so Coolify can redirect this host to www. Still needs DNS to the server, not a provider URL-redirect record.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ], 'non-www' => $suggestedIsWww ? [ - 'label' => 'Redirect source', + 'label' => 'Not added · redirect source', 'role' => 'redirect_source', - 'pending_message' => "Needed so Coolify can redirect www to non-www. {$pointDns}", - 'dns_suffix' => 'Used only so Coolify can redirect this host to non-www. Still needs DNS to the server, not a provider URL-redirect record.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ] : [ - 'label' => 'Canonical non-www', + 'label' => 'Not added · canonical non-www', 'role' => 'canonical', - 'pending_message' => "Required as the redirect target (non-www). {$pointDns}", - 'dns_suffix' => 'This is the canonical non-www host traffic should land on.', + 'pending_message' => $pendingMessage, + 'dns_suffix' => '', ], default => [ - 'label' => $suggestedIsWww ? 'Suggested www' : 'Suggested non-www', + 'label' => $suggestedIsWww ? 'Not added · www' : 'Not added · non-www', 'role' => 'pair', - 'pending_message' => "Also add this host so both www and non-www work. {$pointDns}", + 'pending_message' => $pendingMessage, 'dns_suffix' => '', ], }; @@ -663,6 +682,43 @@ class Domains extends Component return true; } + /** + * When saved redirect is www/non-www, ensure missing counterparts exist as real domains. + * + * @return array newly added domain URLs + */ + protected function syncRedirectDomainPairs(?ServiceApplication $app = null): array + { + $user = auth()->user(); + if ($user === null || ! $user->can('update', $this->service)) { + return []; + } + + if ($app === null) { + $added = []; + foreach ($this->service->applications as $serviceApp) { + $added = array_merge($added, $this->syncRedirectDomainPairs($serviceApp)); + } + + return array_values(array_unique($added)); + } + + $redirect = $this->normalizeRedirect($app->redirect ?? null); + if (! in_array($redirect, ['www', 'non-www'], true)) { + return []; + } + + $before = collect($this->splitDomains($app->fqdn))->all(); + if (! $this->ensureWwwNonWwwPairsConfigured($app)) { + return []; + } + + $app->refresh(); + $after = collect($this->splitDomains($app->fqdn)); + + return $after->reject(fn (string $url) => in_array($url, $before, true))->values()->all(); + } + /** * Persist missing www/non-www counterparts as normal domains (not suggestions). * @@ -710,10 +766,13 @@ class Domains extends Component $this->pendingRedirectServiceApplicationId = $app->id; // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { + if (! $this->saveDomainListForApp($app, $merged)) { return false; } + $this->pendingAction = null; + $this->pendingRedirectServiceApplicationId = null; + return true; } @@ -766,6 +825,11 @@ class Domains extends Component } $newUrls = $this->splitDomains($normalized); + $pairedUrls = collect($newUrls) + ->map(fn (string $url) => $this->wwwCounterpartUrl($url)) + ->filter() + ->values() + ->all(); $current = collect($this->splitDomains($app->fqdn)); foreach ($newUrls as $url) { if ($current->contains($url)) { @@ -785,10 +849,10 @@ class Domains extends Component } } - $merged = $current->merge($newUrls)->unique()->values(); + $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; - if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { + if (! $this->saveDomainListForApp($app, $merged)) { return; } @@ -802,7 +866,7 @@ class Domains extends Component $this->dispatch('close-modal'); $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns($newUrls, (int) $app->id); + $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); } catch (\Throwable $e) { handleError($e, $this); } @@ -874,6 +938,7 @@ class Domains extends Component if ($dnsFailure !== null) { $this->editDomainDnsFailed = true; $this->editDomainDnsMessage = $dnsFailure; + $this->showEditDomainModal = true; return; } @@ -882,7 +947,7 @@ class Domains extends Component $updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values(); $this->pendingAction = 'update'; - if (! $this->saveDomainListForApp($app, $updated, checkDns: false)) { + if (! $this->saveDomainListForApp($app, $updated)) { return; } @@ -917,7 +982,7 @@ class Domains extends Component $this->forceSaveDomains = true; $this->forceRemovePort = true; - if (! $this->saveDomainListForApp($app, $updated, checkDns: false, checkConflicts: false)) { + if (! $this->saveDomainListForApp($app, $updated, checkConflicts: false)) { return; } @@ -970,7 +1035,6 @@ class Domains extends Component $this->forceAddSuggestedIndex = $index; $this->editingIndex = $index; $this->persistAllDomainDnsStatuses(); - $this->dispatch('error', 'DNS validation failed.', $dnsFailure); return; } @@ -980,7 +1044,7 @@ class Domains extends Component $this->pendingAction = 'suggested'; $this->editingIndex = $index; - if (! $this->saveDomainListForApp($app, $merged, checkDns: false)) { + if (! $this->saveDomainListForApp($app, $merged)) { return; } @@ -1035,7 +1099,6 @@ class Domains extends Component protected function saveDomainListForApp( ServiceApplication $app, Collection $domains, - bool $checkDns = true, bool $checkConflicts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); @@ -1078,25 +1141,6 @@ class Domains extends Component } } - if ($checkDns && $domainString && $this->shouldValidateDns()) { - $server = $this->service->server; - if ($server) { - foreach ($this->splitDomains($domainString) as $domain) { - if (! validateDNSEntry($domain, $server)) { - $guidance = dnsMismatchGuidanceMessage( - $this->dnsTargetLabel() ?? serverDnsTargetIp($server) ?? $server->ip, - $this->serverIp ?? serverDnsTargetIp($server) ?? $server->ip, - ); - $this->dispatch( - 'error', - 'Validating DNS failed.', - $guidance - ); - } - } - } - } - $warning = sslipDomainWarning($domainString ?? ''); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 05be7eb8a..ce278522b 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -37,6 +37,14 @@ class Storage extends Component public string $file_storage_directory_destination = ''; + public string $activeTab = 'volumes'; + + public int $cachedVolumeCount = 0; + + public int $cachedFileCount = 0; + + public int $cachedDirectoryCount = 0; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -57,12 +65,18 @@ class Storage extends Component } if ($this->resource->getMorphClass() === Application::class) { - if ($this->resource->destination->server->isSwarm()) { + $this->resource->loadMissing('destination.server', 'environment.project'); + if ($this->resource->destination?->server?->isSwarm()) { $this->isSwarm = true; } } - $this->refreshStorages(); + // Counts only on mount — child All (volumes) / file list load their own payloads. + $this->loadVolumeCount(); + $this->loadFileStorageMetaCounts(); + $this->activeTab = $this->resolveDefaultTab(); + $this->fileStorage = collect(); + $this->loadFileStorageForActiveTab(); } public function refreshStoragesFromEvent() @@ -73,37 +87,110 @@ class Storage extends Component public function refreshStorages() { - $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) { + // Avoid loading full volume models onto this parent (child All owns that snapshot). + $this->resource->unsetRelation('persistentStorages'); + $this->loadVolumeCount(); + $this->loadFileStorageMetaCounts(); + $this->loadFileStorageForActiveTab(); + } + + public function setActiveTab(string $tab): void + { + if (! in_array($tab, ['volumes', 'files', 'directories'], true)) { + return; + } + + $this->activeTab = $tab; + $this->loadFileStorageForActiveTab(); + } + + private function resolveDefaultTab(): string + { + if ($this->volumeCount > 0) { + return 'volumes'; + } + + if ($this->fileCount > 0) { + return 'files'; + } + + if ($this->directoryCount > 0) { + return 'directories'; + } + + return 'volumes'; + } + + private function loadVolumeCount(): void + { + $this->cachedVolumeCount = $this->resource->persistentStorages()->count(); + } + + /** + * Counts only — avoids loading file contents into the Livewire snapshot on the volumes tab. + */ + private function loadFileStorageMetaCounts(): void + { + $this->cachedFileCount = $this->resource->fileStorages()->where('is_directory', false)->count(); + $this->cachedDirectoryCount = $this->resource->fileStorages()->where('is_directory', true)->count(); + } + + /** + * Load full file/directory mounts only for the active tab (content only on files). + */ + private function loadFileStorageForActiveTab(): void + { + if ($this->activeTab === 'volumes') { + // Keep snapshot small while the volumes tab is shown. + $this->fileStorage = collect(); + + return; + } + + $query = $this->resource->fileStorages(); + + if ($this->activeTab === 'files') { + $query->where('is_directory', false); + } else { + $query->where('is_directory', true); + } + + $this->fileStorage = $query->get()->each(function (LocalFileVolume $fs): void { + if ($this->activeTab !== 'files') { + $fs->content = null; + + return; + } + if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) { $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; } }); - $this->resource->load('persistentStorages.resource'); } public function getFilesProperty() { - return $this->fileStorage->where('is_directory', false); + return collect($this->fileStorage)->where('is_directory', false); } public function getDirectoriesProperty() { - return $this->fileStorage->where('is_directory', true); + return collect($this->fileStorage)->where('is_directory', true); } public function getVolumeCountProperty() { - return $this->resource->persistentStorages()->count(); + return $this->cachedVolumeCount; } public function getFileCountProperty() { - return $this->files->count(); + return $this->cachedFileCount; } public function getDirectoryCountProperty() { - return $this->directories->count(); + return $this->cachedDirectoryCount; } public function submitPersistentVolume() @@ -130,12 +217,12 @@ class Storage extends Component 'resource_id' => $this->resource->id, 'resource_type' => $this->resource->getMorphClass(), ]); - $this->resource->refresh(); + $this->clearForm(); + $this->activeTab = 'volumes'; + $this->refreshStorages(); $this->dispatch('configurationChanged'); $this->dispatch('success', 'Volume added successfully'); $this->dispatch('closeStorageModal', 'volume'); - $this->clearForm(); - $this->refreshStorages(); $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); @@ -165,11 +252,13 @@ class Storage extends Component 'resource_type' => get_class($this->resource), ]); + $this->clearForm(); + $this->activeTab = 'files'; + $this->refreshStorages(); $this->dispatch('configurationChanged'); $this->dispatch('success', 'File mount added successfully'); $this->dispatch('closeStorageModal', 'file'); - $this->clearForm(); - $this->refreshStorages(); + $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -198,11 +287,13 @@ class Storage extends Component 'resource_type' => get_class($this->resource), ]); + $this->clearForm(); + $this->activeTab = 'files'; + $this->refreshStorages(); $this->dispatch('configurationChanged'); $this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('closeStorageModal', 'host-file'); - $this->clearForm(); - $this->refreshStorages(); + $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -235,11 +326,13 @@ class Storage extends Component 'resource_type' => get_class($this->resource), ]); + $this->clearForm(); + $this->activeTab = 'directories'; + $this->refreshStorages(); $this->dispatch('configurationChanged'); $this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('closeStorageModal', 'directory'); - $this->clearForm(); - $this->refreshStorages(); + $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index 43bf3140b..2d8b3188f 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -43,11 +43,6 @@ class ConfigurationChecker extends Component return view('livewire.project.shared.configuration-checker'); } - public function refreshConfigurationChanges(): void - { - $this->configurationChanged(); - } - /** * Members must never see environment variable values, so redact every * environment-section change before it is serialized to the browser. @@ -80,18 +75,42 @@ class ConfigurationChecker extends Component } public function configurationChanged(): void + { + // Banner only needs a lightweight summary in the Livewire snapshot. + $this->loadConfigurationState(includeChanges: false); + } + + public function refreshConfigurationChanges(): void + { + // Full change list is only needed when the user opens "View changes". + $this->loadConfigurationState(includeChanges: true); + } + + /** + * @param bool $includeChanges When false, only summary keys are stored (smaller HTML/snapshots). + */ + private function loadConfigurationState(bool $includeChanges = false): void { $this->resource->refresh(); if ($this->resource instanceof Application) { $diff = $this->resource->pendingDeploymentConfigurationDiff(); - // Fail closed: only owners/admins may see unlocked env values. - $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + $this->isConfigurationChanged = $diff->isChanged(); $array = $diff->toArray(); - $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment); - $this->isConfigurationChanged = $diff->isChanged(); + if (! $includeChanges) { + $this->configurationDiff = [ + 'count' => data_get($array, 'count', 0), + 'requires_build' => (bool) data_get($array, 'requires_build', false), + ]; + + return; + } + + // Fail closed: only owners/admins may see unlocked env values. + $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment); $this->configurationDiff = $array; return; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 63f5ecf3a..d2c0a405a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -32,6 +32,14 @@ class All extends Component public string $environmentFilter = 'all'; + /** @var list */ + public array $variableFilters = []; + + /** @var list */ + public array $serviceFilters = []; + + public string $tableSort = 'default'; + public int $page = 1; public int $perPage = 10; @@ -79,7 +87,8 @@ class All extends Component $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); - if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { + $hasGitRepository = filled(data_get($this->resource, 'git_repository')); + if (str($this->resourceClass)->contains($resourceWithPreviews) && $hasGitRepository && ! $simpleDockerfile) { $this->showPreview = true; } // Intentionally skip loading env vars / developer-view bulk text here. @@ -357,6 +366,78 @@ class All extends Component $this->clearEnvironmentVariableCaches(); } + public function toggleVariableFilter(string $filter): void + { + if (! in_array($filter, ['all', 'managed', 'user', 'buildtime', 'runtime', 'multiline', 'literal'], true)) { + return; + } + + if ($filter === 'all') { + $this->variableFilters = []; + } elseif (in_array($filter, $this->variableFilters, true)) { + $this->variableFilters = array_values(array_diff($this->variableFilters, [$filter])); + } else { + if ($filter === 'managed') { + $this->variableFilters = array_values(array_diff($this->variableFilters, ['user'])); + } elseif ($filter === 'user') { + $this->variableFilters = array_values(array_diff($this->variableFilters, ['managed'])); + } + $this->variableFilters[] = $filter; + } + + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + + public function setTableSort(string $sort): void + { + if (! in_array($sort, ['default', 'name_asc', 'name_desc'], true)) { + return; + } + + $this->tableSort = $sort; + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + + public function toggleServiceFilter(string $service): void + { + if (! in_array($service, $this->serviceFilterOptions, true)) { + return; + } + + $this->serviceFilters = in_array($service, $this->serviceFilters, true) + ? array_values(array_diff($this->serviceFilters, [$service])) + : [...$this->serviceFilters, $service]; + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + + public function clearFilters(): void + { + $this->variableFilters = []; + $this->serviceFilters = []; + $this->environmentFilter = 'all'; + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + + public function getServiceFilterOptionsProperty(): array + { + $compose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + if (blank($compose)) { + return []; + } + + return extractHardcodedEnvironmentVariables($compose) + ->pluck('service_name') + ->filter() + ->unique() + ->sort() + ->values() + ->all(); + } + public function setEnvironmentVariablePage(int $page): void { $this->page = max(1, min($page, $this->environmentVariableLastPage)); @@ -388,35 +469,35 @@ class All extends Component $segments = []; if ($includeProduction) { - $segments[] = [ - 'kind' => 'managed', - 'is_preview' => false, - 'count' => $this->countManagedEnvironmentVariables(false), - ]; - - if ($this->showsHardcodedEnvironmentVariables()) { + if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) { $segments[] = [ 'kind' => 'hardcoded', 'is_preview' => false, 'count' => $this->hardcodedEnvironmentVariables->count(), ]; } + + $segments[] = [ + 'kind' => 'managed', + 'is_preview' => false, + 'count' => $this->countManagedEnvironmentVariables(false), + ]; } if ($includePreview) { - $segments[] = [ - 'kind' => 'managed', - 'is_preview' => true, - 'count' => $this->countManagedEnvironmentVariables(true), - ]; - - if ($this->showsHardcodedEnvironmentVariables()) { + if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) { $segments[] = [ 'kind' => 'hardcoded', 'is_preview' => true, 'count' => $this->hardcodedEnvironmentVariablesPreview->count(), ]; } + + $segments[] = [ + 'kind' => 'managed', + 'is_preview' => true, + 'count' => $this->countManagedEnvironmentVariables(true), + ]; } return $segments; @@ -429,6 +510,12 @@ class All extends Component ->where('resourceable_id', $this->resource->id) ->where('is_preview', $isPreview); + if ($this->serviceFilters !== []) { + $query->whereRaw('1 = 0'); + } + + $query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END"); + $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); if ($this->searchTerm() !== '') { @@ -436,7 +523,26 @@ class All extends Component $query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']); } - if ($this->is_env_sorting_enabled) { + if (in_array('managed', $this->variableFilters, true) || in_array('user', $this->variableFilters, true)) { + $method = in_array('managed', $this->variableFilters, true) ? 'where' : 'whereNot'; + $query->{$method}(function (Builder $query): void { + $query->where('key', 'like', 'SERVICE_FQDN%') + ->orWhere('key', 'like', 'SERVICE_URL%') + ->orWhere('key', 'like', 'SERVICE_NAME%'); + }); + } + + foreach (['buildtime', 'runtime', 'multiline', 'literal'] as $filter) { + if (in_array($filter, $this->variableFilters, true)) { + $query->where('is_'.$filter, true); + } + } + + if ($this->tableSort === 'name_asc') { + $query->orderBy('key'); + } elseif ($this->tableSort === 'name_desc') { + $query->orderByDesc('key'); + } elseif ($this->is_env_sorting_enabled) { $query->orderBy('key'); } else { $query->orderBy('order')->orderBy('id'); @@ -553,6 +659,12 @@ class All extends Component return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose'; } + private function includesHardcodedVariables(): bool + { + return ! in_array('user', $this->variableFilters, true) + && collect($this->variableFilters)->intersect(['buildtime', 'runtime', 'multiline', 'literal'])->isEmpty(); + } + protected function getHardcodedVariables(bool $isPreview) { if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) { @@ -600,6 +712,12 @@ class All extends Component }); } + if ($this->serviceFilters !== []) { + $hardcodedVars = $hardcodedVars->filter( + fn ($var) => in_array($var['service_name'] ?? '', $this->serviceFilters, true) + ); + } + // Apply sorting based on is_env_sorting_enabled if ($this->is_env_sorting_enabled) { $hardcodedVars = $hardcodedVars->sortBy('key')->values(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 106eb8e02..7f37b1fc4 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -19,6 +19,8 @@ use Livewire\Component; class Show extends Component { + public bool $showEnvironmentType = true; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; public $parameters; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index 44cd6343e..da55dee19 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -6,6 +6,8 @@ use Livewire\Component; class ShowHardcoded extends Component { + public bool $showEnvironmentType = true; + public array $env; public string $key; diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 63fc06a36..0d80aef02 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -2,22 +2,284 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Models\Application; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; +use App\Models\ScheduledVolumeBackup; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class All extends Component { + use AuthorizesRequests; + public $resource; - protected $listeners = ['refreshStorages' => '$refresh']; + /** + * Editable form state keyed by storage id. + * + * @var array + */ + public array $forms = []; - public function getFirstStorageIdProperty() + /** + * Precomputed per-volume backup badge/link data. + * + * @var array + */ + public array $volumeBackupMeta = []; + + public bool $supportsPreviewSuffix = false; + + public bool $showActionsColumn = false; + + public bool $isComposeOrService = false; + + public bool $canUpdate = false; + + /** Storage id for the single shared backup modal (null = closed / unmounted). */ + public ?int $backupModalStorageId = null; + + protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; + + public function mount(): void { - if ($this->resource->persistentStorages->isEmpty()) { - return null; + $this->canUpdate = (bool) auth()->user()?->can('update', $this->resource); + $this->supportsPreviewSuffix = $this->resource instanceof Application + && $this->resource->git_based(); + $this->showActionsColumn = $this->resource instanceof Application; + $this->isComposeOrService = $this->resource->type() === 'service' + || data_get($this->resource, 'build_pack') === 'dockercompose'; + + $this->refreshList(); + } + + public function refreshList(): void + { + $this->resource->refresh(); + $this->resource->unsetRelation('persistentStorages'); + $this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]); + + foreach ($this->resource->persistentStorages as $storage) { + $storage->setRelation('resource', $this->resource); } - // Use the storage with the smallest ID as the "first" one - // This ensures stability even when storages are deleted - return $this->resource->persistentStorages->sortBy('id')->first()->id; + if ($this->resource instanceof Application) { + $this->resource->loadMissing('environment.project'); + } + + $this->rebuildForms(); + $this->rebuildVolumeBackupMeta(); + } + + public function submit(int $storageId): void + { + $this->authorize('update', $this->resource); + $this->validateStorage($storageId); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $form = $this->forms[$storageId]; + $storage->name = $form['name']; + $storage->mount_path = $form['mountPath']; + $storage->host_path = $form['hostPath'] ?: null; + $storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled']; + $storage->save(); + + $this->dispatch('success', 'Storage updated successfully'); + } + + public function instantSave(int $storageId): void + { + $this->submit($storageId); + } + + /** + * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. + */ + public function updatedForms($value, string $key): void + { + if (! str_ends_with($key, '.isPreviewSuffixEnabled')) { + return; + } + + $storageId = (int) explode('.', $key)[0]; + if ($storageId > 0 && isset($this->forms[$storageId]) && ! $this->forms[$storageId]['isReadOnly']) { + $this->instantSave($storageId); + } + } + + public function delete(int $storageId, $password = '', $selectedActions = []) + { + $this->authorize('update', $this->resource); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $storage = $this->findStorageOrFail($storageId); + + if ($storage->scheduledBackups()->exists()) { + $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); + + return false; + } + + $storage->delete(); + $this->backupModalStorageId = null; + $this->refreshList(); + $this->dispatch('refreshStorages'); + $this->dispatch('configurationChanged'); + + return true; + } + + public function openBackupModal(int $storageId): void + { + $this->authorize('update', $this->resource); + $this->backupModalStorageId = $storageId; + } + + public function closeBackupModal(): void + { + $this->backupModalStorageId = null; + } + + public function render() + { + return view('livewire.project.shared.storages.all'); + } + + /** + * @return array + */ + public function getStoragesProperty(): array + { + return $this->resource->persistentStorages + ->sortBy('id') + ->values() + ->all(); + } + + private function rebuildForms(): void + { + $forms = []; + foreach ($this->resource->persistentStorages->sortBy('id') as $storage) { + $forms[$storage->id] = [ + 'name' => $storage->name, + 'mountPath' => $storage->mount_path, + 'hostPath' => $storage->host_path, + 'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true), + 'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate, + ]; + } + $this->forms = $forms; + } + + private function rebuildVolumeBackupMeta(): void + { + $this->volumeBackupMeta = []; + + if (! $this->resource instanceof Application) { + return; + } + + $storages = $this->resource->persistentStorages; + if ($storages->isEmpty()) { + return; + } + + $volumeMorph = (new LocalPersistentVolume)->getMorphClass(); + $directoryMorph = (new LocalFileVolume)->getMorphClass(); + $volumeIds = $storages->pluck('id'); + + $volumeBackups = ScheduledVolumeBackup::query() + ->where('backupable_type', $volumeMorph) + ->whereIn('backupable_id', $volumeIds) + ->get() + ->keyBy('backupable_id'); + + $directoryIds = LocalFileVolume::query() + ->where('resource_id', $this->resource->id) + ->where('resource_type', $this->resource->getMorphClass()) + ->where('is_directory', true) + ->where('is_host_file', false) + ->pluck('id'); + + $totalApplicationBackups = ScheduledVolumeBackup::query() + ->where(function ($query) use ($volumeMorph, $volumeIds, $directoryMorph, $directoryIds): void { + $query->where(function ($query) use ($volumeMorph, $volumeIds): void { + $query->where('backupable_type', $volumeMorph) + ->whereIn('backupable_id', $volumeIds); + })->orWhere(function ($query) use ($directoryMorph, $directoryIds): void { + $query->where('backupable_type', $directoryMorph) + ->whereIn('backupable_id', $directoryIds); + }); + }) + ->count(); + + $parameters = [ + 'project_uuid' => $this->resource->project()->uuid, + 'environment_uuid' => $this->resource->environment->uuid, + 'application_uuid' => $this->resource->uuid, + ]; + + foreach ($storages as $storage) { + $backup = $volumeBackups->get($storage->id); + $enabled = (bool) ($backup?->enabled); + $url = null; + + if ($enabled && $backup) { + $url = $totalApplicationBackups > 1 + ? route('project.application.backup.index', [...$parameters, 'search' => $storage->name]) + : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); + } + + $this->volumeBackupMeta[(int) $storage->id] = [ + 'enabled' => $enabled, + 'url' => $url, + ]; + } + } + + private function validateStorage(int $storageId): void + { + $this->validate([ + "forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(), + "forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + "forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + "forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean', + ], array_merge( + ValidationPatterns::volumeNameMessages(), + [ + "forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.', + "forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.', + ] + ), [ + "forms.{$storageId}.name" => 'name', + "forms.{$storageId}.mountPath" => 'mount', + "forms.{$storageId}.hostPath" => 'host', + ]); + } + + private function findStorageOrFail(int $storageId): LocalPersistentVolume + { + $storage = $this->resource->persistentStorages->firstWhere('id', $storageId); + if (! $storage) { + $storage = LocalPersistentVolume::query() + ->whereKey($storageId) + ->where('resource_id', $this->resource->id) + ->where('resource_type', $this->resource->getMorphClass()) + ->firstOrFail(); + $storage->setRelation('resource', $this->resource); + } + + return $storage; } } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 242660eec..db155d3e5 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -26,6 +26,8 @@ class Show extends Component public ?string $startedAt = null; + public bool $supportsPreviewSuffix = false; + // Explicit properties public string $name; @@ -39,6 +41,14 @@ class Show extends Component public ?string $backupUrl = null; + /** + * When true, parent already batched badge/url data — skip per-row queries on mount. + */ + public bool $backupMetaHydrated = false; + + /** When true, the Backup Configure Livewire modal is mounted (lazy). */ + public bool $showBackupModal = false; + protected $validationAttributes = [ 'name' => 'name', 'mountPath' => 'mount', @@ -92,7 +102,14 @@ class Show extends Component { $this->syncData(false); $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); - $this->refreshBackupStatus(); + // PR deployment volume suffixes only apply to git-based applications. + $this->supportsPreviewSuffix = $this->resource instanceof Application + && $this->resource->git_based() + && ! $this->isService; + // Parent All batches badge/url; isolated embeds still hydrate themselves. + if (! $this->backupMetaHydrated) { + $this->refreshBackupStatus(); + } } #[On('refreshVolumeBackups')] @@ -107,6 +124,8 @@ class Show extends Component return; } + $this->resource->loadMissing('environment.project'); + $parameters = [ 'project_uuid' => $this->resource->project()->uuid, 'environment_uuid' => $this->resource->environment->uuid, @@ -122,6 +141,21 @@ class Show extends Component : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } + public function openBackupModal(): void + { + $this->authorize('update', $this->resource); + $this->showBackupModal = true; + } + + #[On('modalClosed')] + public function onModalClosed(): void + { + // Drop the nested Create component from the DOM after close to free snapshot weight. + if ($this->showBackupModal) { + $this->showBackupModal = false; + } + } + public function instantSave(): void { $this->authorize('update', $this->resource); diff --git a/app/Support/DnsRecordHints.php b/app/Support/DnsRecordHints.php index c6695bfd0..9e02d7b9e 100644 --- a/app/Support/DnsRecordHints.php +++ b/app/Support/DnsRecordHints.php @@ -113,7 +113,11 @@ class DnsRecordHints } /** - * Plain-text block suitable for clipboard (type / name / value). + * BIND-compatible zone snippet for clipboard (absolute names with trailing dots). + * + * Example: + * asd.hu. IN A 172.16.0.2 + * www.asd.hu. IN A 172.16.0.2 * * @param array $records */ @@ -123,11 +127,38 @@ class DnsRecordHints return ''; } - $lines = ["Type\tName\tValue"]; + $lines = []; + $nameWidth = 0; + foreach ($records as $record) { - $lines[] = "{$record['type']}\t{$record['name']}\t{$record['value']}"; + $name = self::bindAbsoluteName((string) $record['name']); + $nameWidth = max($nameWidth, strlen($name)); } - return implode("\n", $lines); + foreach ($records as $record) { + $name = self::bindAbsoluteName((string) $record['name']); + $type = strtoupper((string) $record['type']); + $value = (string) $record['value']; + // AAAA values may be IPv6; leave as-is (no quotes needed for A/AAAA). + $format = '%-'.$nameWidth.'s IN %-5s %s'; + $lines[] = sprintf($format, $name, $type, $value); + } + + return implode("\n", $lines)."\n"; + } + + /** + * Absolute BIND name (trailing dot). Leaves @ as-is. + */ + public static function bindAbsoluteName(string $name): string + { + $name = trim($name); + if ($name === '' || $name === '@') { + return '@'; + } + + $name = rtrim($name, '.'); + + return $name.'.'; } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cb933f17a..90e452612 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2029,7 +2029,8 @@ function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string /** * User-facing guidance when a hostname does not resolve to the server. - * Format: "A record → 1.2.3.4" or "AAAA record → 2001:db8::1". + * Format: "Required DNS record type A pointing to 1.2.3.4" + * or "Required DNS record type AAAA pointing to 2001:db8::1". * * @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback. * @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel). @@ -2045,7 +2046,7 @@ function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordTy $recordType = dnsRecordTypeForIp($address); - return "{$recordType} record → {$address}"; + return "Required DNS record type {$recordType} pointing to {$address}"; } function validateDNSEntry(string $fqdn, Server $server) diff --git a/config/constants.php b/config/constants.php index 64d8afcc1..051cc6833 100644 --- a/config/constants.php +++ b/config/constants.php @@ -42,8 +42,6 @@ return [ 'host' => env('PUSHER_HOST'), 'port' => env('PUSHER_PORT'), 'app_key' => env('PUSHER_APP_KEY'), - 'scheme' => env('PUSHER_SCHEME', 'http'), - 'force_ws' => filter_var(env('PUSHER_FORCE_WS', false), FILTER_VALIDATE_BOOLEAN), ], 'migration' => [ diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index 08c7fa20f..0e59e158c 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -21,7 +21,6 @@ services: PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" - PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 063e66f4a..7519a599b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -29,7 +29,6 @@ services: PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: "${PUSHER_PORT:-}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" - PUSHER_FORCE_WS: "${PUSHER_FORCE_WS:-false}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" diff --git a/resources/css/app.css b/resources/css/app.css index dcebc2354..d57223cd2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -75,40 +75,13 @@ @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; } - /* Accent rail on the active rounded pill (sits flush on the left edge) */ - .menu-item-active::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 3px; - background: var(--color-accent); - border-radius: 0.375rem 0 0 0.375rem; - pointer-events: none; - } - .menu-subitem-active::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - z-index: 1; - width: 3px; - border-radius: 0.375rem 0 0 0.375rem; - background: var(--color-accent); - pointer-events: none; - } - .sidebar-collapsed .menu-item-active::before { - display: none; - } - /* active icon picks up full-strength foreground */ + /* Active state is a solid fill only (no accent rail / border). */ .menu-item-active .menu-item-icon, .menu-subitem-active .menu-item-icon { opacity: 1; } - /* Kill any legacy accent wash; fill is solid via menu-item-active utility. */ + /* Kill any legacy accent wash or rail; fill is solid via menu-item-active utility. */ .menu-item-active, .menu-subitem-active, .dark .menu-item-active, @@ -116,6 +89,12 @@ background-image: none; } + .menu-item-active::before, + .menu-subitem-active::before { + content: none; + display: none; + } + /* vertical connector line for a nested nav group */ .nav-children { position: relative; @@ -971,6 +950,40 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too scroll-margin-top: 7rem; } +/* Brief accent ring when a settings nav sub-item scrolls a section into view. + Use a real border on ::after (not animated multi-layer box-shadow) so the + ring is the same weight on every side — box-shadow rings look thicker on + the header edge next to the elevated strip. */ +@keyframes application-settings-section-highlight { + 0% { + opacity: 0; + } + + 15%, + 60% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.application-settings-section.is-section-highlight { + position: relative; +} + +.application-settings-section.is-section-highlight::after { + content: ''; + position: absolute; + inset: 0; + z-index: 5; + border-radius: inherit; + border: 0.5px solid var(--color-accent); + pointer-events: none; + animation: application-settings-section-highlight 500ms ease-out forwards; +} + /* Modals reuse the layer-card shell but size to content on large screens */ @media (min-width: 1024px) { .application-settings-section.application-settings-form { @@ -1038,6 +1051,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too top: 6.5rem; align-self: start; max-height: calc(100dvh - 7.25rem); + /* Inset content so the default ring-2 + ring-offset-2 focus ring is not + clipped by overflow-x on the right edge of this narrow column. */ + padding-right: 0.375rem; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; @@ -1111,9 +1127,11 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too .application-settings-workspace .button, .application-settings-form .button { height: 2rem; + min-height: 2rem; border-radius: 8px; padding-left: 0.75rem; padding-right: 0.75rem; + white-space: nowrap; } .application-settings-workspace .form-control, @@ -1292,6 +1310,38 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too color: var(--color-fg); } +/* + * Active primary tab styles. + * The base rules above set background/color/box-shadow with higher specificity + * than Tailwind utilities (bg-warning/15, text-warning, ring-*), so active + * tabs need an explicit override or they look identical to inactive ones. + */ +.application-heading-actions .app-tab[aria-current='page'], +.application-heading-actions .app-tab.app-tab-active { + background: color-mix(in srgb, var(--color-coollabs) 10%, transparent); + color: var(--color-coollabs); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-coollabs) 25%, transparent); +} + +.application-heading-actions .app-tab[aria-current='page']:hover, +.application-heading-actions .app-tab.app-tab-active:hover { + background: color-mix(in srgb, var(--color-coollabs) 15%, transparent); + color: var(--color-coollabs); +} + +.dark .application-heading-actions .app-tab[aria-current='page'], +.dark .application-heading-actions .app-tab.app-tab-active { + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 25%, transparent); +} + +.dark .application-heading-actions .app-tab[aria-current='page']:hover, +.dark .application-heading-actions .app-tab.app-tab-active:hover { + background: color-mix(in srgb, var(--color-warning) 20%, transparent); + color: var(--color-warning); +} + .application-heading-actions .relative > button[x-ref='trigger'] { padding-right: 0.625rem; } @@ -1583,7 +1633,11 @@ input[type="search"]::-webkit-search-results-decoration { } .env-table-grid { - grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4rem 4.5rem 4.8rem 4.2rem 3rem; + grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem; +} + +.env-table-grid.env-table-grid-no-type { + grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } /* Shared variables only store value shape (multiline), not per-resource flags. */ @@ -1594,7 +1648,7 @@ input[type="search"]::-webkit-search-results-decoration { /* Env vars: collapse flag columns on tablet, card layout on phone */ @media (max-width: 1100px) { .env-table-grid { - grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem; + grid-template-columns: minmax(0, 1.4fr) 4.8rem 6rem 3rem; gap: 0.75rem; } @@ -1619,12 +1673,7 @@ input[type="search"]::-webkit-search-results-decoration { @media (max-width: 900px) { .env-table-grid { - grid-template-columns: minmax(0, 1fr) 6rem 3rem; - } - - /* Also hide Comment (3) */ - .env-table-grid > :nth-child(3) { - display: none; + grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem; } .env-table-grid-shared { @@ -1670,14 +1719,14 @@ input[type="search"]::-webkit-search-results-decoration { word-break: break-word; } - /* Type desktop column → hide; type shows as mobile badge on name row */ + /* Managed and Type desktop columns */ .data-table-row.env-table-grid > :nth-child(2), + .data-table-row.env-table-grid > :nth-child(3), .data-table-row.env-table-grid-shared > :nth-child(2) { display: none !important; } /* Comment / flags already hidden; keep meta area for optional second line */ - .data-table-row.env-table-grid > :nth-child(3), .data-table-row.env-table-grid > :nth-child(4), .data-table-row.env-table-grid > :nth-child(5), .data-table-row.env-table-grid > :nth-child(6), @@ -1695,13 +1744,10 @@ input[type="search"]::-webkit-search-results-decoration { justify-self: end; } - .env-type-mobile { - display: inline-flex !important; - } } -.env-type-mobile { - display: none; +.env-managed-desktop { + display: flex; } @media (max-width: 640px) { @@ -1835,6 +1881,154 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem; } +/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */ +.volumes-table-grid-readonly { + grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr); +} + +.volumes-table-grid { + grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(10.5rem, auto); +} + +.volumes-table-grid-with-pr { + grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem minmax(10.5rem, auto); +} + +.volumes-mobile-label { + display: none; +} + +/* + * Same tokens as .application-settings-form label (13px / medium / subtle). + * Do not use text-sm (14px) — settings labels override Tailwind to 13px. + */ +.volumes-mobile-label.is-visible, +.volumes-field-label { + font-size: 13px; + font-weight: 500; + line-height: 1rem; + color: var(--coollabs-subtle); +} + +/* Compact inputs inside volume table rows (desktop) */ +.data-table-row.volumes-table-grid .input, +.data-table-row.volumes-table-grid-with-pr .input, +.data-table-row.volumes-table-grid .listbox-trigger, +.data-table-row.volumes-table-grid-with-pr .listbox-trigger { + min-height: 2rem; + height: 2rem; + font-size: 12px; +} + +.data-table-row.volumes-table-grid .listbox-trigger, +.data-table-row.volumes-table-grid-with-pr .listbox-trigger { + padding-inline: 0.5rem; +} + +@media (max-width: 1100px) { + .volumes-table-grid { + grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) minmax(9rem, auto); + } + + .volumes-table-grid > .volumes-col-source, + .data-table-header.volumes-table-grid > .volumes-col-source { + display: none; + } + + .volumes-table-grid-with-pr { + grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem minmax(9rem, auto); + } + + .volumes-table-grid-with-pr > .volumes-col-source, + .data-table-header.volumes-table-grid-with-pr > .volumes-col-source { + display: none; + } + + .volumes-table-grid-readonly { + grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr); + } + + .volumes-table-grid-readonly > .volumes-col-source, + .data-table-header.volumes-table-grid-readonly > .volumes-col-source { + display: none; + } +} + +/* Phone: stacked card rows with per-field labels (table headers hidden) */ +@media (max-width: 768px) { + .data-table-header.volumes-table-grid, + .data-table-header.volumes-table-grid-with-pr, + .data-table-header.volumes-table-grid-readonly { + display: none; + } + + .data-table-row.volumes-table-grid, + .data-table-row.volumes-table-grid-with-pr, + .data-table-row.volumes-table-grid-readonly { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.625rem; + padding: 0.875rem 1rem; + min-height: 0; + } + + .data-table-row.volumes-table-grid > .volumes-col-source, + .data-table-row.volumes-table-grid-with-pr > .volumes-col-source, + .data-table-row.volumes-table-grid-with-pr > .volumes-col-pr, + .data-table-row.volumes-table-grid-readonly > .volumes-col-source { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + width: 100%; + } + + .data-table-row.volumes-table-grid > *, + .data-table-row.volumes-table-grid-with-pr > *, + .data-table-row.volumes-table-grid-readonly > * { + width: 100%; + min-width: 0; + } + + /* Match .application-settings-form label (13px), not Tailwind text-sm (14px) */ + .volumes-mobile-label { + display: block; + font-size: 13px; + font-weight: 500; + line-height: 1rem; + color: var(--coollabs-subtle); + } + + .volumes-cell-name, + .volumes-cell-dest, + .volumes-cell-actions { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + width: 100%; + } + + .volumes-cell-actions { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding-top: 0.25rem; + } + + .data-table-row.volumes-table-grid .input, + .data-table-row.volumes-table-grid-with-pr .input, + .data-table-row.volumes-table-grid .listbox-trigger, + .data-table-row.volumes-table-grid-with-pr .listbox-trigger { + min-height: 2.25rem; + height: 2.25rem; + font-size: 13px; + } +} + .deployment-table-grid { grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr); } @@ -2587,6 +2781,37 @@ input[type="search"]::-webkit-search-results-decoration { color: #f87171; } +.table-badge-warning { + background: rgba(245, 158, 11, 0.16); + color: #b45309; +} + +.dark .table-badge-warning { + background: rgba(245, 158, 11, 0.14); + color: #fbbf24; +} + +.table-badge-success { + background: rgba(16, 185, 129, 0.14); + color: #047857; +} + +.dark .table-badge-success { + background: rgba(16, 185, 129, 0.16); + color: #34d399; +} + +/* Suggested / not-yet-configured domain rows — distinct from real FQDNs */ +.domains-row-suggested { + background: rgba(245, 158, 11, 0.05); + box-shadow: inset 3px 0 0 0 rgba(245, 158, 11, 0.55); +} + +.dark .domains-row-suggested { + background: rgba(245, 158, 11, 0.07); + box-shadow: inset 3px 0 0 0 rgba(251, 191, 36, 0.5); +} + /* Chip/tag input (comma-free multi-value entry, e.g. Domains) */ .chip-input { display: flex; diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d47d4c0c6..74ab1c4e0 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -126,7 +126,8 @@ } @utility button { - @apply inline-flex gap-1.5 justify-center items-center px-2.5 h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; + /* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */ + @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; } /* Compact icon-only control (gear, chevrons, etc.) */ @@ -144,6 +145,11 @@ @apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors; } +/* Active resource tab (used with aria-current="page") */ +@utility app-tab-active { + @apply bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20; +} + @utility auth-tooltip { @apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; } @@ -216,7 +222,7 @@ } @utility menu-item-active { - /* Solid selected pill + accent rail (app.css ::before). No accent gradient. */ + /* Solid selected pill only — no accent rail / border. */ @apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06]; } @@ -227,10 +233,11 @@ /* Indented child rows in a collapsible nav group */ @utility menu-subitem { - @apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg; + /* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */ + @apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg; } @utility menu-subitem-active { - @apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06]; + @apply rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06]; } @utility sub-menu-wrapper { diff --git a/resources/js/app.js b/resources/js/app.js index 64c1db60f..bb41b7f04 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -12,3 +12,110 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); + +/** + * Smooth-scroll a settings section into view, then flash its border for 500ms + * after the scroll has settled. Starting the flash immediately makes long + * jumps (top → bottom) finish scrolling after the animation has already ended. + * + * @param {string} id + */ +window.scrollToSettingsSection = function scrollToSettingsSection(id) { + const el = document.getElementById(id); + if (!el) { + return; + } + + if (typeof el._sectionHighlightCleanup === 'function') { + el._sectionHighlightCleanup(); + } + + const runHighlight = () => { + el.classList.remove('is-section-highlight'); + // Force reflow so the 500ms highlight can re-run on repeated clicks. + void el.offsetWidth; + el.classList.add('is-section-highlight'); + el._sectionHighlightTimer = window.setTimeout(() => { + el.classList.remove('is-section-highlight'); + }, 500); + }; + + let finished = false; + let rafId = 0; + let scrollEndHandler = null; + + const cleanup = () => { + if (rafId) { + window.cancelAnimationFrame(rafId); + rafId = 0; + } + if (scrollEndHandler) { + window.removeEventListener('scrollend', scrollEndHandler); + scrollEndHandler = null; + } + if (el._sectionHighlightTimer) { + window.clearTimeout(el._sectionHighlightTimer); + el._sectionHighlightTimer = null; + } + }; + + const finish = () => { + if (finished) { + return; + } + finished = true; + cleanup(); + runHighlight(); + }; + + el._sectionHighlightCleanup = () => { + finished = true; + cleanup(); + el.classList.remove('is-section-highlight'); + el._sectionHighlightCleanup = null; + }; + + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Prefer the native scrollend event when the browser fires it. + scrollEndHandler = () => finish(); + window.addEventListener('scrollend', scrollEndHandler, { once: true }); + + // Fallback: wait until the target's Y position is stable for a few frames + // (covers browsers without scrollend, and no-op scrolls when already in view). + let lastTop = null; + let stableFrames = 0; + let frames = 0; + const maxFrames = 180; // ~3s safety cap + + const tick = () => { + if (finished) { + return; + } + + frames += 1; + const top = el.getBoundingClientRect().top; + + if (lastTop !== null && Math.abs(top - lastTop) < 0.5) { + stableFrames += 1; + } else { + stableFrames = 0; + } + lastTop = top; + + // Skip the first couple frames so we don't flash before smooth scroll starts. + if (frames > 4 && stableFrames >= 4) { + finish(); + return; + } + + if (frames >= maxFrames) { + finish(); + return; + } + + rafId = window.requestAnimationFrame(tick); + }; + + rafId = window.requestAnimationFrame(tick); +}; diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index bc14b52d1..eb32de577 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -5,6 +5,7 @@ 'required' => false, 'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool] 'placeholder' => 'Select…', + 'emptyText' => 'No options available.', 'live' => false, 'onChange' => null, // optional $wire method to call after a selection 'wire' => true, // false = purely client-side value (no Livewire binding) @@ -51,7 +52,7 @@ {{ $attributes->whereStartsWith('x-effect') }} @click.outside="open = false" @keydown.escape="open = false">
+
+ {{ $emptyText }} +