From fd6dbd586346ecc5da2ca2fe42e969b497695809 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:24:09 +0200 Subject: [PATCH] feat(service): warn when required environment variables are missing Surface unset required service env vars in the configuration checker popup and sidebar, refresh on env updates, and keep the env table horizontally scrollable with correct managed/hardcoded pagination order. --- .../Project/Shared/ConfigurationChecker.php | 11 +++++ .../Shared/EnvironmentVariable/All.php | 48 ++++++++++++++----- app/Models/Service.php | 20 ++++---- resources/css/app.css | 48 +++++-------------- .../views/components/popup-small.blade.php | 6 ++- .../service/configuration-sidebar.blade.php | 6 ++- .../project/service/configuration.blade.php | 5 +- .../shared/configuration-checker.blade.php | 28 +++++++++++ .../shared/environment-variable/all.blade.php | 2 +- .../Feature/EnvironmentVariableSearchTest.php | 42 ++++++++++++++++ .../EnvironmentVariableTableLayoutTest.php | 28 ++++++++++- .../Livewire/ConfigurationCheckerTest.php | 31 +++++++++++- 12 files changed, 209 insertions(+), 66 deletions(-) diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index d4b939975..fb3360c18 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -21,6 +21,10 @@ class ConfigurationChecker extends Component public array $configurationDiff = []; + public int $missingRequiredEnvironmentVariableCount = 0; + + public array $missingRequiredEnvironmentVariableNames = []; + public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public function getListeners(): array @@ -30,6 +34,7 @@ public function getListeners(): array return [ "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', 'configurationChanged' => 'configurationChanged', + 'envsUpdated' => 'configurationChanged', ]; } @@ -83,6 +88,12 @@ private function loadConfigurationState(): void { $this->resource->refresh(); + if ($this->resource instanceof Service) { + $missingVariables = $this->resource->missingRequiredEnvironmentVariables(); + $this->missingRequiredEnvironmentVariableCount = $missingVariables->count(); + $this->missingRequiredEnvironmentVariableNames = $missingVariables->pluck('key')->all(); + } + if ($this->resource instanceof Application) { $diff = $this->resource->pendingDeploymentConfigurationDiff(); $this->isConfigurationChanged = $diff->isChanged(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index d2c0a405a..4c4907200 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -456,7 +456,7 @@ public function nextEnvironmentVariablePage(): void /** * Ordered segments used for pagination: production managed → production hardcoded - * → preview managed → preview hardcoded (matching the historical table order). + * → preview managed → preview hardcoded. * * @return list */ @@ -469,6 +469,12 @@ private function environmentVariableSegments(): array $segments = []; if ($includeProduction) { + $segments[] = [ + 'kind' => 'managed', + 'is_preview' => false, + 'count' => $this->countManagedEnvironmentVariables(false), + ]; + if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) { $segments[] = [ 'kind' => 'hardcoded', @@ -477,14 +483,15 @@ private function environmentVariableSegments(): array ]; } - $segments[] = [ - 'kind' => 'managed', - 'is_preview' => false, - 'count' => $this->countManagedEnvironmentVariables(false), - ]; } if ($includePreview) { + $segments[] = [ + 'kind' => 'managed', + 'is_preview' => true, + 'count' => $this->countManagedEnvironmentVariables(true), + ]; + if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) { $segments[] = [ 'kind' => 'hardcoded', @@ -493,11 +500,6 @@ private function environmentVariableSegments(): array ]; } - $segments[] = [ - 'kind' => 'managed', - 'is_preview' => true, - 'count' => $this->countManagedEnvironmentVariables(true), - ]; } return $segments; @@ -514,9 +516,13 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder $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"); + $missingRequiredIds = $this->missingRequiredEnvironmentVariableIds($isPreview); + if ($missingRequiredIds !== []) { + $placeholders = implode(', ', array_fill(0, count($missingRequiredIds), '?')); + $query->orderByRaw("CASE WHEN id IN ({$placeholders}) THEN 0 ELSE 1 END", $missingRequiredIds); + } - $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END"); + $query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END"); if ($this->searchTerm() !== '') { $escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\'); @@ -551,6 +557,22 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder return $query; } + /** @return list */ + private function missingRequiredEnvironmentVariableIds(bool $isPreview): array + { + return EnvironmentVariable::query() + ->where('resourceable_type', $this->resource->getMorphClass()) + ->where('resourceable_id', $this->resource->id) + ->where('is_preview', $isPreview) + ->where('is_required', true) + ->get() + ->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required) + ->pluck('id') + ->map(fn (int|string $id): int => (int) $id) + ->values() + ->all(); + } + private function countManagedEnvironmentVariables(bool $isPreview): int { if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 89438053d..2c90681f8 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1640,16 +1640,16 @@ public function networks() protected function isDeployable(): Attribute { return Attribute::make( - get: function () { - $envs = $this->environment_variables()->where('is_required', true)->get(); - foreach ($envs as $env) { - if ($env->is_really_required) { - return false; - } - } - - return true; - } + get: fn (): bool => $this->missingRequiredEnvironmentVariables()->isEmpty() ); } + + public function missingRequiredEnvironmentVariables(): Collection + { + return $this->environment_variables() + ->where('is_required', true) + ->get() + ->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required) + ->values(); + } } diff --git a/resources/css/app.css b/resources/css/app.css index ae7b31d6d..81f63b1b9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1931,6 +1931,16 @@ .env-table-grid { grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } +.environment-table-scroll { + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.environment-table-scroll .env-table-grid { + min-width: 53rem; +} + .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; } @@ -1940,21 +1950,8 @@ .env-table-grid-shared { grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4.5rem 3rem; } -/* Env vars: collapse flag columns on tablet, card layout on phone */ +/* Shared env vars collapse secondary columns on tablet and use cards on phone. */ @media (max-width: 1100px) { - .env-table-grid { - grid-template-columns: minmax(0, 1.4fr) 4.8rem 6rem 3rem; - gap: 0.75rem; - } - - /* Hide Literal / Multiline / Buildtime / Runtime (4–7 of 8) */ - .env-table-grid > :nth-child(4), - .env-table-grid > :nth-child(5), - .env-table-grid > :nth-child(6), - .env-table-grid > :nth-child(7) { - display: none; - } - .env-table-grid-shared { grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem; gap: 0.75rem; @@ -1967,10 +1964,6 @@ @media (max-width: 1100px) { } @media (max-width: 900px) { - .env-table-grid { - grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem; - } - .env-table-grid-shared { grid-template-columns: minmax(0, 1fr) 6rem 3rem; } @@ -1981,12 +1974,10 @@ @media (max-width: 900px) { } @media (max-width: 640px) { - .data-table-header.env-table-grid, .data-table-header.env-table-grid-shared { display: none; } - .data-table-row.env-table-grid, .data-table-row.env-table-grid-shared { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2000,39 +1991,30 @@ @media (max-width: 640px) { } /* Name */ - .data-table-row.env-table-grid > :nth-child(1), .data-table-row.env-table-grid-shared > :nth-child(1) { grid-area: name; min-width: 0; flex-wrap: wrap; } - .data-table-row.env-table-grid > :nth-child(1) .env-key-label, .data-table-row.env-table-grid-shared > :nth-child(1) .env-key-label { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } - /* Managed and Type desktop columns */ - .data-table-row.env-table-grid > :nth-child(2), - .data-table-row.env-table-grid > :nth-child(3), + /* Type column */ .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(4), - .data-table-row.env-table-grid > :nth-child(5), - .data-table-row.env-table-grid > :nth-child(6), - .data-table-row.env-table-grid > :nth-child(7), .data-table-row.env-table-grid-shared > :nth-child(3), .data-table-row.env-table-grid-shared > :nth-child(4) { display: none !important; } /* Actions */ - .data-table-row.env-table-grid > :nth-child(8), .data-table-row.env-table-grid-shared > :nth-child(5) { grid-area: actions; align-self: center; @@ -2045,12 +2027,6 @@ .env-managed-desktop { display: flex; } -@media (max-width: 640px) { - .env-type-desktop { - display: none !important; - } -} - .domains-table-grid { grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem; } diff --git a/resources/views/components/popup-small.blade.php b/resources/views/components/popup-small.blade.php index 3dc031eab..329222e35 100644 --- a/resources/views/components/popup-small.blade.php +++ b/resources/views/components/popup-small.blade.php @@ -51,7 +51,11 @@ x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-3 opacity-0" class="fixed bottom-4 right-4 z-999" - :class="compact ? 'w-auto max-w-[calc(100%-2rem)]' : 'w-[calc(100%-2rem)] max-w-sm'"> + :class="iconOnly + ? 'w-auto max-w-[calc(100%-2rem)]' + : (compact + ? 'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]' + : 'w-[calc(100%-2rem)] max-w-sm')">
diff --git a/resources/views/components/service/configuration-sidebar.blade.php b/resources/views/components/service/configuration-sidebar.blade.php index 4ce7db65c..00752c64c 100644 --- a/resources/views/components/service/configuration-sidebar.blade.php +++ b/resources/views/components/service/configuration-sidebar.blade.php @@ -10,7 +10,7 @@ $configurationItems = collect([ ['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'], ['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'], - ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'], + ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], ['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], @@ -56,9 +56,11 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 href="{{ route($menuItem['route'], $serviceRouteParameters) }}"> {{ $menuItem['label'] }} + @if ($menuItem['hasWarning'] ?? false) + + @endif @endforeach @endforeach - diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 6299ef496..51f3dfe87 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -15,7 +15,7 @@ $configurationItems = collect([ ['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'], ['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'], - ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'], + ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], ['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], ['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], @@ -71,6 +71,9 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 href="{{ route($menuItem['route'], $serviceRouteParameters) }}"> {{ $menuItem['label'] }} + @if ($menuItem['hasWarning'] ?? false) + + @endif @if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())