From 7236cb82287fa3fdc71a96039b40af093a16521b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:20:56 +0200 Subject: [PATCH] feat(server): add dedicated server creation flow --- app/Livewire/GlobalSearch.php | 6 + app/Livewire/Server/Create.php | 11 +- app/Livewire/Server/CreatePage.php | 55 +++++ app/Livewire/Server/New/ByDigitalOcean.php | 62 +++++- app/Livewire/Server/New/ByHetzner.php | 62 +++++- app/Livewire/Server/New/ByVultr.php | 85 +++++++- resources/css/app.css | 84 ++++++++ .../components/digital-ocean-icon.blade.php | 8 +- resources/views/livewire/dashboard.blade.php | 27 +-- .../views/livewire/global-search.blade.php | 52 +---- ...ub-private-repository-deploy-key.blade.php | 12 +- .../new/github-private-repository.blade.php | 5 +- .../livewire/project/new/select.blade.php | 4 +- .../cloud-provider-token-form.blade.php | 22 -- .../livewire/server/create-page.blade.php | 29 +++ .../views/livewire/server/create.blade.php | 161 ++++++++------ .../views/livewire/server/index.blade.php | 6 +- .../server/new/by-digital-ocean.blade.php | 90 ++++---- .../livewire/server/new/by-hetzner.blade.php | 82 ++++--- .../livewire/server/new/by-vultr.blade.php | 200 ++++++++++-------- routes/web.php | 6 +- .../DashboardAuthorizationTest.php | 4 +- .../Authorization/ServerAuthorizationTest.php | 141 ++++++++++++ .../DigitalOceanServerCreationTest.php | 36 ++++ tests/Feature/GithubPrivateRepositoryTest.php | 7 + .../Server/HetznerServerCreationTest.php | 7 +- .../Server/NewServerProviderTokenUrlTest.php | 170 +++++++++++++++ .../ServiceTemplatesLastUpdatedHintTest.php | 13 ++ tests/Feature/VultrServerCreationTest.php | 23 +- tests/Unit/CloudProviderTokenFormViewTest.php | 17 +- .../GlobalSearchNewImageQuickActionTest.php | 23 ++ 31 files changed, 1158 insertions(+), 352 deletions(-) create mode 100644 app/Livewire/Server/CreatePage.php create mode 100644 resources/views/livewire/server/create-page.blade.php create mode 100644 tests/Feature/DigitalOceanServerCreationTest.php create mode 100644 tests/Feature/Server/NewServerProviderTokenUrlTest.php diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 4d9b1af35..bf64ee8e9 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1134,6 +1134,12 @@ private function loadCreatableItems() public function navigateToResource($type) { + if ($type === 'server') { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, 'server.create'); + } + // Find the item by type - check regular items first, then services $item = collect($this->creatableItems)->firstWhere('type', $type); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 5fd2ea4f7..7edfcaf72 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -11,12 +11,21 @@ class Create extends Component { public $private_keys = []; + public ?string $selectedType = null; + + public ?string $selectedTokenUuid = null; + public bool $limit_reached = false; public bool $has_hetzner_tokens = false; - public function mount() + public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + ? $selectedType + : null; + $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php new file mode 100644 index 000000000..158f28351 --- /dev/null +++ b/app/Livewire/Server/CreatePage.php @@ -0,0 +1,55 @@ +type = $type; + $this->token_uuid = $token_uuid; + $this->tokenProvider = match ($type) { + 'hetzner' => 'hetzner', + 'vultr' => 'vultr', + 'digital-ocean' => 'digitalocean', + default => null, + }; + $this->tokenProviderName = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + default => null, + }; + $this->hasProviderTokens = $this->tokenProvider + ? CloudProviderToken::ownedByCurrentTeam()->where('provider', $this->tokenProvider)->exists() + : false; + $this->title = match ($type) { + 'hetzner' => 'Hetzner', + 'vultr' => 'Vultr', + 'digital-ocean' => 'DigitalOcean', + 'manual' => 'Manual', + default => 'New Server', + }; + } + + public function render(): View + { + return view('livewire.server.create-page'); + } +} diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 6abb6759f..c131e096a 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -33,6 +33,8 @@ class ByDigitalOcean extends Component public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + public array $regions = []; public array $images = []; @@ -74,11 +76,12 @@ class ByDigitalOcean extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -86,6 +89,11 @@ public function mount() if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -174,9 +182,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId): void + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function getDigitalOceanToken(): string @@ -197,26 +223,46 @@ public function nextStep() ]); try { - $digitalOceanToken = $this->getDigitalOceanToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $digitalOceanToken) { - return $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'digital-ocean', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - $this->loadDigitalOceanData($digitalOceanToken); $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } } - public function previousStep(): void + public function previousStep(): mixed { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true); + } + $this->current_step = 1; + + return null; } - private function loadDigitalOceanData(string $token): void + public function loadDigitalOceanData(): void { + $token = $this->getDigitalOceanToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + + return; + } + $this->loading_data = true; try { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index e1191d79b..31cd89dae 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -37,6 +37,8 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + // Step 2: Server configuration public array $locations = []; @@ -89,11 +91,12 @@ class ByHetzner extends Component public bool $from_onboarding = false; - public function mount() + public function mount(?string $selectedTokenUuid = null) { try { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -101,6 +104,11 @@ public function mount() if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } catch (\Throwable $e) { return handleError($e, $this); } @@ -207,9 +215,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId) + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } private function validateHetznerToken(string $token): bool @@ -244,17 +270,20 @@ public function nextStep() ]); try { - $hetznerToken = $this->getHetznerToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $hetznerToken) { - return $this->dispatch('error', 'Please select a valid Hetzner token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hetzner', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - // Load Hetzner data - $this->loadHetznerData($hetznerToken); - - // Move to step 2 + // Move to step 2; provider data is loaded after initial render via wire:init. $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -262,11 +291,24 @@ public function nextStep() public function previousStep() { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true); + } + $this->current_step = 1; } - private function loadHetznerData(string $token) + public function loadHetznerData(): void { + $token = $this->getHetznerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hetzner token.'); + + return; + } + $this->loading_data = true; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index bc0b936d1..124644845 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -33,6 +33,8 @@ class ByVultr extends Component public ?int $selected_token_id = null; + public ?string $selectedTokenUuid = null; + public array $regions = []; public array $plans = []; @@ -72,10 +74,11 @@ class ByVultr extends Component public bool $from_onboarding = false; - public function mount(): void + public function mount(?string $selectedTokenUuid = null): void { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); @@ -83,6 +86,11 @@ public function mount(): void if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } } public function getListeners(): array @@ -165,9 +173,27 @@ protected function messages(): array ]; } - public function selectToken(int $tokenId): void + public function selectToken(int $tokenId): mixed { $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if (! $token) { + return; + } + + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; } public function nextStep(): mixed @@ -177,14 +203,19 @@ public function nextStep(): mixed ]); try { - $vultrToken = $this->getVultrToken(); + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $vultrToken) { - return $this->dispatch('error', 'Please select a valid Vultr token.'); + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'vultr', + 'token_uuid' => $token->uuid, + ], navigate: true); + } } - $this->loadVultrData($vultrToken); $this->current_step = 2; + $this->loading_data = true; } catch (\Throwable $e) { return handleError($e, $this); } @@ -192,9 +223,15 @@ public function nextStep(): mixed return null; } - public function previousStep(): void + public function previousStep(): mixed { + if ($this->selectedTokenUuid) { + return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true); + } + $this->current_step = 1; + + return null; } public function updatedSelectedRegion(): void @@ -251,6 +288,29 @@ public function getSelectedServerPriceProperty(): ?string return '$'.number_format((float) $monthlyCost, 2); } + public function getAdvancedVultrOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedVultrSshKeyIds) > 0) { + $summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv6 disabled'; + } + + if ($this->disable_public_ipv4) { + $summary[] = 'Public IPv4 disabled'; + } + + if (! empty($this->cloud_init_script)) { + $summary[] = 'cloud-init'; + } + + return $summary; + } + private function getVultrToken(): string { if ($this->selected_token_id) { @@ -262,8 +322,17 @@ private function getVultrToken(): string return ''; } - private function loadVultrData(string $token): void + public function loadVultrData(): void { + $token = $this->getVultrToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Vultr token.'); + + return; + } + $this->loading_data = true; try { diff --git a/resources/css/app.css b/resources/css/app.css index de92bf0c9..c354c6c53 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -97,6 +97,90 @@ @keyframes lds-heart { } } +@keyframes coolbox-border-track { + 0% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 33% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 35% 2px, 0 0, 0 0, 0 0; + } + + 34% { + background-position: 100% 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 50% { + background-position: 100% 0, 100% 100%, 100% 100%, 0 100%; + background-size: 0 0, 2px 35%, 0 0, 0 0; + } + + 51% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 84% { + background-position: 0 0, 100% 0, 0 100%, 0 100%; + background-size: 0 0, 0 0, 35% 2px, 0 0; + } + + 85% { + background-position: 0 0, 100% 0, 100% 100%, 0 100%; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } + + 100% { + background-position: 0 0, 100% 0, 100% 100%, 0 0; + background-size: 0 0, 0 0, 0 0, 2px 35%; + } +} + +@layer components { + .coolbox-loading { + @apply overflow-hidden border-transparent; + isolation: isolate; + } + + .coolbox-loading::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)), + linear-gradient(var(--color-warning), var(--color-warning)); + background-repeat: no-repeat; + animation: coolbox-border-track 2400ms linear infinite; + pointer-events: none; + z-index: 0; + } + + .coolbox-loading::after { + content: ""; + position: absolute; + inset: 2px; + border-radius: calc(0.25rem - 2px); + background: white; + pointer-events: none; + z-index: 1; + } + + .dark .coolbox-loading::after { + background: var(--color-coolgray-100); + } + + .coolbox-loading > * { + position: relative; + z-index: 2; + } +} + /* * Base styles */ diff --git a/resources/views/components/digital-ocean-icon.blade.php b/resources/views/components/digital-ocean-icon.blade.php index f3d15c6e2..5652730d6 100644 --- a/resources/views/components/digital-ocean-icon.blade.php +++ b/resources/views/components/digital-ocean-icon.blade.php @@ -1,4 +1,10 @@ -merge(['class' => 'w-6 h-6 flex-shrink-0']) }} fill="#0080FF" role="img" viewBox="0 0 24 24" +@php + $iconAttributes = $attributes->has('class') + ? $attributes->class('shrink-0') + : $attributes->merge(['class' => 'size-6 shrink-0']); +@endphp + + DigitalOcean - - - - - + + + + + @endif @endcan @@ -154,9 +149,9 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde
No servers found.
@can('create', App\Models\Server::class)
- - - your first server + + Add + your first server or go to the { this.$dispatch('open-create-modal-' + value); @@ -705,9 +709,9 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100
@else
+ class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center"> @@ -821,9 +825,9 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100
+ class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center"> @@ -937,46 +941,6 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
-
- -
-