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 @@ class ConfigurationChecker extends Component return [ "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', 'configurationChanged' => 'configurationChanged', + 'envsUpdated' => 'configurationChanged', ]; } @@ -83,6 +88,12 @@ class ConfigurationChecker extends Component { $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 @@ class All extends Component /** * 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 @@ class All extends Component $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 @@ class All extends Component ]; } - $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 @@ class All extends Component ]; } - $segments[] = [ - 'kind' => 'managed', - 'is_preview' => true, - 'count' => $this->countManagedEnvironmentVariables(true), - ]; } return $segments; @@ -514,9 +516,13 @@ class All extends Component $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 @@ class All extends Component 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 @@ class Service extends BaseModel 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 @@ input[type="search"]::-webkit-search-results-decoration { 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 @@ input[type="search"]::-webkit-search-results-decoration { 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 @@ input[type="search"]::-webkit-search-results-decoration { } @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 @@ input[type="search"]::-webkit-search-results-decoration { } @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 @@ input[type="search"]::-webkit-search-results-decoration { } /* 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 @@ input[type="search"]::-webkit-search-results-decoration { 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 @@ 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 @@ href="{{ route($menuItem['route'], $serviceRouteParameters) }}"> {{ $menuItem['label'] }} + @if ($menuItem['hasWarning'] ?? false) + + @endif @if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())