From 28f44d3aa008c5b1b9d77f9751b5802a779d10c5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:44:34 +0100 Subject: [PATCH] fix(hetzner): show out-of-stock server types as disabled in the server form Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Server/New/ByHetzner.php | 20 ++++++- app/Services/HetznerService.php | 8 +-- .../livewire/server/new/by-hetzner.blade.php | 4 +- .../Server/HetznerServerCreationTest.php | 53 +++++++++++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index fbc6d93a9b..3205daf96c 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -323,7 +323,7 @@ class ByHetzner extends Component $hetznerService = new HetznerService($token); $this->locations = $hetznerService->getLocations(); - $this->serverTypes = $hetznerService->getServerTypes(); + $this->serverTypes = $hetznerService->getServerTypes(includeOutOfStock: true); // Get images and sort by name $images = $hetznerService->getImages(); @@ -385,7 +385,7 @@ class ByHetzner extends Component public function getAvailableServerTypesProperty() { if (! $this->selected_location) { - return $this->serverTypes; + return []; } $filtered = collect($this->serverTypes) @@ -400,15 +400,24 @@ class ByHetzner extends Component }) ->map(function ($serverType) { $serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType); + $serverType['out_of_stock'] = $this->isServerTypeOutOfStock($serverType); return $serverType; }) + ->sortBy('out_of_stock') ->values() ->toArray(); return $filtered; } + private function isServerTypeOutOfStock(array $serverType): bool + { + $typeLocation = collect($serverType['locations'] ?? [])->firstWhere('name', $this->selected_location); + + return ($typeLocation['available'] ?? true) === false; + } + public function getAvailableImagesProperty() { @@ -670,6 +679,13 @@ class ByHetzner extends Component { $this->validate(); + $selectedServerType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + if ($selectedServerType && $this->isServerTypeOutOfStock($selectedServerType)) { + $this->addError('selected_server_type', 'This server type is out of stock in the selected location. Select a different server type or location.'); + + return null; + } + if (! $this->enable_ipv4 && ! $this->enable_ipv6) { $this->addError('enable_ipv4', 'Enable at least one public IP protocol.'); $this->addError('enable_ipv6', 'Enable at least one public IP protocol.'); diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 88aaef118e..10e8ed3ffd 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -121,16 +121,18 @@ class HetznerService /** * Server types that can be ordered now. Each type keeps only the locations * where it is in stock and not deprecated, and types with no such location are removed. + * With $includeOutOfStock, locations where the type is out of stock are kept. */ - public function getServerTypes(?string $location = null): array + public function getServerTypes(?string $location = null, bool $includeOutOfStock = false): array { $types = $this->requestPaginated('get', '/server_types', 'server_types'); return collect($types) ->reject(fn (array $type) => ($type['deprecated'] ?? false) === true) - ->map(function (array $type) { + ->map(function (array $type) use ($includeOutOfStock) { $type['locations'] = collect($type['locations'] ?? []) - ->filter(fn (array $typeLocation) => ($typeLocation['available'] ?? true) === true && empty($typeLocation['deprecation'])) + ->filter(fn (array $typeLocation) => empty($typeLocation['deprecation']) + && ($includeOutOfStock || ($typeLocation['available'] ?? true) === true)) ->values() ->all(); diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 70b4502e1f..9a4ae25b42 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -42,9 +42,11 @@ . ' · ' . ($type['cores'] ?? '?') . ' vCPU' . ' · ' . ($type['memory'] ?? '?') . ' GB RAM' . ' · ' . ($type['disk'] ?? '?') . ' GB'; + $label = $price ? $label . ' · €' . number_format($price, 2) . '/mo' : $label; return [ 'value' => $type['name'], - 'label' => $price ? $label . ' · €' . number_format($price, 2) . '/mo' : $label, + 'label' => $type['out_of_stock'] ? $label . ' · Out of stock' : $label, + 'disabled' => $type['out_of_stock'], ]; })->values()->all(); $imageOptions = collect($this->availableImages)->map(fn ($image) => [ diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 2ccb23c643..349a48e61f 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -363,4 +363,57 @@ describe('Hetzner data loading', function () { Http::assertNothingSent(); }); + test('lists server types that are out of stock in the selected location as out of stock', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx22', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => false, 'deprecation' => null], + ['id' => 2, 'name' => 'fsn1', 'available' => true, 'deprecation' => null], + ]], + ['id' => 2, 'name' => 'cx32', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null], + ]], + ['id' => 3, 'name' => 'cx11', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => ['announced' => '2025-09-24T00:00:00+00:00', 'unavailable_after' => '2025-12-31T00:00:00+00:00']], + ]], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/*' => Http::response(['locations' => [], 'images' => [], 'ssh_keys' => [], 'firewalls' => [], 'networks' => [], 'meta' => ['pagination' => ['next_page' => null]]], 200), + ]); + + $component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) + ->call('loadHetznerData') + ->set('selected_location', 'nbg1'); + + expect(collect($component->instance()->availableServerTypes)->mapWithKeys(fn ($type) => [$type['name'] => $type['out_of_stock']])->all()) + ->toBe(['cx32' => false, 'cx22' => true]); + }); + + test('does not create a server with a server type that is out of stock in the selected location', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx22', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => false, 'deprecation' => null], + ]], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/*' => Http::response(['locations' => [], 'images' => [], 'ssh_keys' => [], 'firewalls' => [], 'networks' => [], 'meta' => ['pagination' => ['next_page' => null]]], 200), + ]); + + Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid]) + ->call('loadHetznerData') + ->set('server_name', 'test-server') + ->set('selected_location', 'nbg1') + ->set('selected_server_type', 'cx22') + ->set('selected_image', 15512617) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors(['selected_server_type']); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); + }); });