Skip to content

Commit fd6dbd5

Browse files
committed
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.
1 parent 64d73b6 commit fd6dbd5

12 files changed

Lines changed: 208 additions & 65 deletions

File tree

app/Livewire/Project/Shared/ConfigurationChecker.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class ConfigurationChecker extends Component
2121

2222
public array $configurationDiff = [];
2323

24+
public int $missingRequiredEnvironmentVariableCount = 0;
25+
26+
public array $missingRequiredEnvironmentVariableNames = [];
27+
2428
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
2529

2630
public function getListeners(): array
@@ -30,6 +34,7 @@ public function getListeners(): array
3034
return [
3135
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
3236
'configurationChanged' => 'configurationChanged',
37+
'envsUpdated' => 'configurationChanged',
3338
];
3439
}
3540

@@ -83,6 +88,12 @@ private function loadConfigurationState(): void
8388
{
8489
$this->resource->refresh();
8590

91+
if ($this->resource instanceof Service) {
92+
$missingVariables = $this->resource->missingRequiredEnvironmentVariables();
93+
$this->missingRequiredEnvironmentVariableCount = $missingVariables->count();
94+
$this->missingRequiredEnvironmentVariableNames = $missingVariables->pluck('key')->all();
95+
}
96+
8697
if ($this->resource instanceof Application) {
8798
$diff = $this->resource->pendingDeploymentConfigurationDiff();
8899
$this->isConfigurationChanged = $diff->isChanged();

app/Livewire/Project/Shared/EnvironmentVariable/All.php

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ public function nextEnvironmentVariablePage(): void
456456

457457
/**
458458
* Ordered segments used for pagination: production managed → production hardcoded
459-
* → preview managed → preview hardcoded (matching the historical table order).
459+
* → preview managed → preview hardcoded.
460460
*
461461
* @return list<array{kind: string, is_preview: bool, count: int}>
462462
*/
@@ -469,6 +469,12 @@ private function environmentVariableSegments(): array
469469
$segments = [];
470470

471471
if ($includeProduction) {
472+
$segments[] = [
473+
'kind' => 'managed',
474+
'is_preview' => false,
475+
'count' => $this->countManagedEnvironmentVariables(false),
476+
];
477+
472478
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
473479
$segments[] = [
474480
'kind' => 'hardcoded',
@@ -477,14 +483,15 @@ private function environmentVariableSegments(): array
477483
];
478484
}
479485

486+
}
487+
488+
if ($includePreview) {
480489
$segments[] = [
481490
'kind' => 'managed',
482-
'is_preview' => false,
483-
'count' => $this->countManagedEnvironmentVariables(false),
491+
'is_preview' => true,
492+
'count' => $this->countManagedEnvironmentVariables(true),
484493
];
485-
}
486494

487-
if ($includePreview) {
488495
if ($this->includesHardcodedVariables() && $this->showsHardcodedEnvironmentVariables()) {
489496
$segments[] = [
490497
'kind' => 'hardcoded',
@@ -493,11 +500,6 @@ private function environmentVariableSegments(): array
493500
];
494501
}
495502

496-
$segments[] = [
497-
'kind' => 'managed',
498-
'is_preview' => true,
499-
'count' => $this->countManagedEnvironmentVariables(true),
500-
];
501503
}
502504

503505
return $segments;
@@ -514,9 +516,13 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
514516
$query->whereRaw('1 = 0');
515517
}
516518

517-
$query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END");
519+
$missingRequiredIds = $this->missingRequiredEnvironmentVariableIds($isPreview);
520+
if ($missingRequiredIds !== []) {
521+
$placeholders = implode(', ', array_fill(0, count($missingRequiredIds), '?'));
522+
$query->orderByRaw("CASE WHEN id IN ({$placeholders}) THEN 0 ELSE 1 END", $missingRequiredIds);
523+
}
518524

519-
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
525+
$query->orderByRaw("CASE WHEN key LIKE 'SERVICE_FQDN%' OR key LIKE 'SERVICE_URL%' OR key LIKE 'SERVICE_NAME%' THEN 0 ELSE 1 END");
520526

521527
if ($this->searchTerm() !== '') {
522528
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
@@ -551,6 +557,22 @@ private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
551557
return $query;
552558
}
553559

560+
/** @return list<int> */
561+
private function missingRequiredEnvironmentVariableIds(bool $isPreview): array
562+
{
563+
return EnvironmentVariable::query()
564+
->where('resourceable_type', $this->resource->getMorphClass())
565+
->where('resourceable_id', $this->resource->id)
566+
->where('is_preview', $isPreview)
567+
->where('is_required', true)
568+
->get()
569+
->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)
570+
->pluck('id')
571+
->map(fn (int|string $id): int => (int) $id)
572+
->values()
573+
->all();
574+
}
575+
554576
private function countManagedEnvironmentVariables(bool $isPreview): int
555577
{
556578
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {

app/Models/Service.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,16 +1640,16 @@ public function networks()
16401640
protected function isDeployable(): Attribute
16411641
{
16421642
return Attribute::make(
1643-
get: function () {
1644-
$envs = $this->environment_variables()->where('is_required', true)->get();
1645-
foreach ($envs as $env) {
1646-
if ($env->is_really_required) {
1647-
return false;
1648-
}
1649-
}
1650-
1651-
return true;
1652-
}
1643+
get: fn (): bool => $this->missingRequiredEnvironmentVariables()->isEmpty()
16531644
);
16541645
}
1646+
1647+
public function missingRequiredEnvironmentVariables(): Collection
1648+
{
1649+
return $this->environment_variables()
1650+
->where('is_required', true)
1651+
->get()
1652+
->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)
1653+
->values();
1654+
}
16551655
}

resources/css/app.css

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,6 +1931,16 @@ input[type="search"]::-webkit-search-results-decoration {
19311931
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
19321932
}
19331933

1934+
.environment-table-scroll {
1935+
overflow-x: auto;
1936+
overscroll-behavior-x: contain;
1937+
-webkit-overflow-scrolling: touch;
1938+
}
1939+
1940+
.environment-table-scroll .env-table-grid {
1941+
min-width: 53rem;
1942+
}
1943+
19341944
.env-table-grid.env-table-grid-no-type {
19351945
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
19361946
}
@@ -1940,21 +1950,8 @@ input[type="search"]::-webkit-search-results-decoration {
19401950
grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4.5rem 3rem;
19411951
}
19421952

1943-
/* Env vars: collapse flag columns on tablet, card layout on phone */
1953+
/* Shared env vars collapse secondary columns on tablet and use cards on phone. */
19441954
@media (max-width: 1100px) {
1945-
.env-table-grid {
1946-
grid-template-columns: minmax(0, 1.4fr) 4.8rem 6rem 3rem;
1947-
gap: 0.75rem;
1948-
}
1949-
1950-
/* Hide Literal / Multiline / Buildtime / Runtime (4–7 of 8) */
1951-
.env-table-grid > :nth-child(4),
1952-
.env-table-grid > :nth-child(5),
1953-
.env-table-grid > :nth-child(6),
1954-
.env-table-grid > :nth-child(7) {
1955-
display: none;
1956-
}
1957-
19581955
.env-table-grid-shared {
19591956
grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem;
19601957
gap: 0.75rem;
@@ -1967,10 +1964,6 @@ input[type="search"]::-webkit-search-results-decoration {
19671964
}
19681965

19691966
@media (max-width: 900px) {
1970-
.env-table-grid {
1971-
grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem;
1972-
}
1973-
19741967
.env-table-grid-shared {
19751968
grid-template-columns: minmax(0, 1fr) 6rem 3rem;
19761969
}
@@ -1981,12 +1974,10 @@ input[type="search"]::-webkit-search-results-decoration {
19811974
}
19821975

19831976
@media (max-width: 640px) {
1984-
.data-table-header.env-table-grid,
19851977
.data-table-header.env-table-grid-shared {
19861978
display: none;
19871979
}
19881980

1989-
.data-table-row.env-table-grid,
19901981
.data-table-row.env-table-grid-shared {
19911982
display: grid;
19921983
grid-template-columns: minmax(0, 1fr) auto;
@@ -2000,39 +1991,30 @@ input[type="search"]::-webkit-search-results-decoration {
20001991
}
20011992

20021993
/* Name */
2003-
.data-table-row.env-table-grid > :nth-child(1),
20041994
.data-table-row.env-table-grid-shared > :nth-child(1) {
20051995
grid-area: name;
20061996
min-width: 0;
20071997
flex-wrap: wrap;
20081998
}
20091999

2010-
.data-table-row.env-table-grid > :nth-child(1) .env-key-label,
20112000
.data-table-row.env-table-grid-shared > :nth-child(1) .env-key-label {
20122001
white-space: normal;
20132002
overflow-wrap: anywhere;
20142003
word-break: break-word;
20152004
}
20162005

2017-
/* Managed and Type desktop columns */
2018-
.data-table-row.env-table-grid > :nth-child(2),
2019-
.data-table-row.env-table-grid > :nth-child(3),
2006+
/* Type column */
20202007
.data-table-row.env-table-grid-shared > :nth-child(2) {
20212008
display: none !important;
20222009
}
20232010

20242011
/* Comment / flags already hidden; keep meta area for optional second line */
2025-
.data-table-row.env-table-grid > :nth-child(4),
2026-
.data-table-row.env-table-grid > :nth-child(5),
2027-
.data-table-row.env-table-grid > :nth-child(6),
2028-
.data-table-row.env-table-grid > :nth-child(7),
20292012
.data-table-row.env-table-grid-shared > :nth-child(3),
20302013
.data-table-row.env-table-grid-shared > :nth-child(4) {
20312014
display: none !important;
20322015
}
20332016

20342017
/* Actions */
2035-
.data-table-row.env-table-grid > :nth-child(8),
20362018
.data-table-row.env-table-grid-shared > :nth-child(5) {
20372019
grid-area: actions;
20382020
align-self: center;
@@ -2045,12 +2027,6 @@ input[type="search"]::-webkit-search-results-decoration {
20452027
display: flex;
20462028
}
20472029

2048-
@media (max-width: 640px) {
2049-
.env-type-desktop {
2050-
display: none !important;
2051-
}
2052-
}
2053-
20542030
.domains-table-grid {
20552031
grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 6.5rem;
20562032
}

resources/views/components/popup-small.blade.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@
5151
x-transition:leave-start="translate-y-0 opacity-100"
5252
x-transition:leave-end="translate-y-3 opacity-0"
5353
class="fixed bottom-4 right-4 z-999"
54-
:class="compact ? 'w-auto max-w-[calc(100%-2rem)]' : 'w-[calc(100%-2rem)] max-w-sm'">
54+
:class="iconOnly
55+
? 'w-auto max-w-[calc(100%-2rem)]'
56+
: (compact
57+
? 'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]'
58+
: 'w-[calc(100%-2rem)] max-w-sm')">
5559
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
5660
:class="compact ? (iconOnly ? 'cursor-pointer p-2! pr-2!' : 'cursor-pointer') : ''" @click="restore()"
5761
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">

resources/views/components/service/configuration-sidebar.blade.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
$configurationItems = collect([
1111
['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'],
1212
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
13-
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
13+
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
1414
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
1515
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
1616
['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
5656
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
5757
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
5858
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
59+
@if ($menuItem['hasWarning'] ?? false)
60+
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
61+
@endif
5962
</a>
6063
@endforeach
6164
@endforeach
6265
</nav>
6366
</aside>
64-

resources/views/livewire/project/service/configuration.blade.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
$configurationItems = collect([
1616
['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'],
1717
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
18-
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
18+
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
1919
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
2020
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
2121
['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
7171
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
7272
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
7373
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
74+
@if ($menuItem['hasWarning'] ?? false)
75+
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
76+
@endif
7477
</a>
7578
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
7679
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"

resources/views/livewire/project/shared/configuration-checker.blade.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,32 @@
11
<div>
2+
@if ($resource instanceof \App\Models\Service && $missingRequiredEnvironmentVariableCount > 0)
3+
@php
4+
$environmentVariablesUrl = route('project.service.environment-variables', [
5+
'project_uuid' => $resource->environment->project->uuid,
6+
'environment_uuid' => $resource->environment->uuid,
7+
'service_uuid' => $resource->uuid,
8+
]);
9+
@endphp
10+
<x-popup-small :compact-after="5000" compact-storage-key="required-environment-variables:{{ $resource->uuid }}">
11+
<x-slot:title>
12+
{{ $missingRequiredEnvironmentVariableCount === 1 ? 'Required environment variable missing' : 'Required environment variables missing' }}
13+
</x-slot:title>
14+
<x-slot:icon>
15+
<x-reicon name="alert-triangle" class="size-4" />
16+
</x-slot:icon>
17+
<x-slot:description>
18+
<span>
19+
{{ implode(', ', $missingRequiredEnvironmentVariableNames) }} must be set before this service can be deployed.
20+
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
21+
class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80">
22+
Open environment variables
23+
<x-reicon name="arrow-right" class="size-2.5" />
24+
</a>
25+
</span>
26+
</x-slot:description>
27+
</x-popup-small>
28+
@endif
29+
230
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
331
@php
432
$compactStoragePrefix = "configuration-warning:{$resource->uuid}:";

resources/views/livewire/project/shared/environment-variable/all.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ class="application-settings-section-body relative mt-1 scroll-mt-28 {{ $totalRow
196196
description="No variables match your search." />
197197
@elseif ($totalRows > 0)
198198
<div class="data-table w-full">
199-
<div class="relative">
199+
<div class="environment-table-scroll relative">
200200
<div class="transition-all"
201201
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
202202
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">

0 commit comments

Comments
 (0)