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())