From c1bcc415463b46f055b5db4ab470a06557eb18c7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:47:50 +0200 Subject: [PATCH 01/68] init of cloud providers --- app/Livewire/Security/CloudProviderTokens.php | 119 ++++++++++++++++ app/Livewire/Security/CloudTokens.php | 13 ++ app/Livewire/Server/New/ByHetzner.php | 128 ++++++++++++++++++ app/Models/CloudProviderToken.php | 31 +++++ app/Models/Team.php | 5 + app/Policies/CloudProviderTokenPolicy.php | 65 +++++++++ ...125_create_cloud_provider_tokens_table.php | 33 +++++ .../components/security/navbar.blade.php | 7 +- .../security/cloud-provider-tokens.blade.php | 66 +++++++++ .../livewire/security/cloud-tokens.blade.php | 7 + .../views/livewire/server/create.blade.php | 22 ++- .../livewire/server/new/by-hetzner.blade.php | 52 +++++++ routes/web.php | 2 + 13 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Security/CloudProviderTokens.php create mode 100644 app/Livewire/Security/CloudTokens.php create mode 100644 app/Livewire/Server/New/ByHetzner.php create mode 100644 app/Models/CloudProviderToken.php create mode 100644 app/Policies/CloudProviderTokenPolicy.php create mode 100644 database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php create mode 100644 resources/views/livewire/security/cloud-provider-tokens.blade.php create mode 100644 resources/views/livewire/security/cloud-tokens.blade.php create mode 100644 resources/views/livewire/server/new/by-hetzner.blade.php diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php new file mode 100644 index 000000000..f35a3a806 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -0,0 +1,119 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } + + protected function rules(): array + { + return [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'provider.required' => 'Please select a cloud provider.', + 'provider.in' => 'Invalid cloud provider selected.', + 'token.required' => 'API token is required.', + 'name.required' => 'Token name is required.', + ]; + } + + public function loadTokens() + { + $this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); + } + + private function validateToken(string $provider, string $token): bool + { + try { + if ($provider === 'hetzner') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + return $response->successful(); + } + + // Add other providers here in the future + // if ($provider === 'digitalocean') { ... } + + return false; + } catch (\Throwable $e) { + return false; + } + } + + public function addNewToken() + { + $this->validate(); + + try { + $this->authorize('create', CloudProviderToken::class); + + // Validate the token with the provider's API + if (! $this->validateToken($this->provider, $this->token)) { + return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); + } + + CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => $this->provider, + 'token' => $this->token, + 'name' => $this->name, + ]); + + $this->reset(['token', 'name']); + $this->loadTokens(); + + $this->dispatch('success', 'Cloud provider token added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function deleteToken(int $tokenId) + { + try { + $token = CloudProviderToken::findOrFail($tokenId); + $this->authorize('delete', $token); + + $token->delete(); + $this->loadTokens(); + + $this->dispatch('success', 'Cloud provider token deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-tokens'); + } +} diff --git a/app/Livewire/Security/CloudTokens.php b/app/Livewire/Security/CloudTokens.php new file mode 100644 index 000000000..d6d1333f1 --- /dev/null +++ b/app/Livewire/Security/CloudTokens.php @@ -0,0 +1,13 @@ +authorize('viewAny', CloudProviderToken::class); + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + protected function rules(): array + { + return [ + 'selected_token_id' => 'nullable|integer', + 'hetzner_token' => 'required_without:selected_token_id|string', + 'save_token' => 'boolean', + 'token_name' => 'required_if:save_token,true|nullable|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'hetzner_token.required_without' => 'Please provide a Hetzner API token or select a saved token.', + 'token_name.required_if' => 'Please provide a name for the token.', + ]; + } + + public function selectToken(int $tokenId) + { + $this->selected_token_id = $tokenId; + } + + private function validateHetznerToken(string $token): bool + { + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + // Determine which token to use + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + if (! $token) { + return $this->dispatch('error', 'Selected token not found.'); + } + $hetznerToken = $token->token; + } else { + $hetznerToken = $this->hetzner_token; + + // Validate the new token before saving + if (! $this->validateHetznerToken($hetznerToken)) { + return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); + } + + // If saving the new token + if ($this->save_token) { + CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => 'hetzner', + 'token' => $this->hetzner_token, + 'name' => $this->token_name, + ]); + } + } + + // TODO: Actual Hetzner server provisioning will be implemented in future phase + // The $hetznerToken variable contains the token to use + return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-hetzner'); + } +} diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php new file mode 100644 index 000000000..9ce216b25 --- /dev/null +++ b/app/Models/CloudProviderToken.php @@ -0,0 +1,31 @@ + 'encrypted', + ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } + + public function scopeForProvider($query, string $provider) + { + return $query->where('provider', $provider); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index 51fdeffa4..6945bb918 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -258,6 +258,11 @@ public function privateKeys() return $this->hasMany(PrivateKey::class); } + public function cloudProviderTokens() + { + return $this->hasMany(CloudProviderToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Policies/CloudProviderTokenPolicy.php b/app/Policies/CloudProviderTokenPolicy.php new file mode 100644 index 000000000..b7b108ba8 --- /dev/null +++ b/app/Policies/CloudProviderTokenPolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool + { + return $user->isAdmin(); + } +} diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php new file mode 100644 index 000000000..2c92b0e19 --- /dev/null +++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('provider'); + $table->text('token'); + $table->string('name')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_provider_tokens'); + } +}; diff --git a/resources/views/components/security/navbar.blade.php b/resources/views/components/security/navbar.blade.php index e389ad6b7..b0dfdd242 100644 --- a/resources/views/components/security/navbar.blade.php +++ b/resources/views/components/security/navbar.blade.php @@ -6,8 +6,13 @@ + @can('viewAny', App\Models\CloudProviderToken::class) + + + + @endcan - + diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php new file mode 100644 index 000000000..d29631c4c --- /dev/null +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -0,0 +1,66 @@ +
+

Cloud Provider Tokens

+
Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.). Tokens are saved encrypted and shared with your team.
+ +

New Token

+ @can('create', App\Models\CloudProviderToken::class) +
+
+
+ + + + +
+
+ +
+
+
+
+ +
+ Add Token +
+
+ @endcan + +

Saved Tokens

+
+ @forelse ($tokens as $savedToken) +
+
+ + {{ strtoupper($savedToken->provider) }} + + {{ $savedToken->name }} +
+
Token: ***{{ substr($savedToken->token, -4) }}
+
Created: {{ $savedToken->created_at->diffForHumans() }}
+ + @can('delete', $savedToken) + + @endcan +
+ @empty +
+
No cloud provider tokens found.
+
+ @endforelse +
+
diff --git a/resources/views/livewire/security/cloud-tokens.blade.php b/resources/views/livewire/security/cloud-tokens.blade.php new file mode 100644 index 000000000..2edbcd30f --- /dev/null +++ b/resources/views/livewire/security/cloud-tokens.blade.php @@ -0,0 +1,7 @@ +
+ + Cloud Tokens | Coolify + + + +
diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index acab92374..619a827e7 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -1,3 +1,23 @@
- +
+ @can('viewAny', App\Models\CloudProviderToken::class) +
+

Add Server from Cloud Provider

+
+ + + +
+
+ +
+ @endcan + +
+

Add Server by IP Address

+ +
+
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php new file mode 100644 index 000000000..83de355e3 --- /dev/null +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -0,0 +1,52 @@ +
+ @if ($limit_reached) + + @else +
+ @if ($available_tokens->count() > 0) +
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
OR
+ @endif + +
+ +
+ +
+ +
+ + @if ($save_token) +
+ +
+ @endif + + + Continue + +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index fd2ed8730..e967586c4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; +use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Server\Advanced as ServerAdvanced; @@ -271,6 +272,7 @@ // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); + Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); From 215301fa8f957164982a21c3af67b71e579c1daf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:41:29 +0200 Subject: [PATCH 02/68] basics of adding / removing hetzner servers --- app/Actions/Server/DeleteServer.php | 49 ++- app/Livewire/Security/CloudProviderTokens.php | 2 +- app/Livewire/Server/Delete.php | 19 +- app/Livewire/Server/New/ByHetzner.php | 372 ++++++++++++++++-- app/Models/PrivateKey.php | 11 + app/Models/Server.php | 1 + app/Services/HetznerService.php | 96 +++++ ...5_10_03_154100_update_clickhouse_image.php | 64 +-- ...add_hetzner_server_id_to_servers_table.php | 28 ++ public/svgs/hetzner.svg | 6 + .../components/modal-confirmation.blade.php | 12 + .../views/livewire/server/create.blade.php | 14 +- .../views/livewire/server/delete.blade.php | 17 +- .../livewire/server/new/by-hetzner.blade.php | 157 ++++++-- .../views/livewire/server/show.blade.php | 15 +- 15 files changed, 744 insertions(+), 119 deletions(-) create mode 100644 app/Services/HetznerService.php create mode 100644 database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php create mode 100644 public/svgs/hetzner.svg diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 15c892e75..db197a019 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -2,16 +2,63 @@ namespace App\Actions\Server; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Services\HetznerService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(Server $server) + public function handle(Server $server, bool $deleteFromHetzner = false) { + // Delete from Hetzner Cloud if requested and server has hetzner_server_id + if ($deleteFromHetzner && $server->hetzner_server_id) { + $this->deleteFromHetzner($server); + } + StopSentinel::run($server); $server->forceDelete(); } + + private function deleteFromHetzner(Server $server): void + { + try { + // Get the cloud provider token for Hetzner + $token = CloudProviderToken::where('team_id', $server->team_id) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + ray('No Hetzner token found for team, skipping Hetzner deletion', [ + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); + + return; + } + + $hetznerService = new HetznerService($token->token); + $hetznerService->deleteServer($server->hetzner_server_id); + + ray('Deleted server from Hetzner', [ + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + } catch (\Throwable $e) { + ray('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + + // Log the error but don't prevent the server from being deleted from Coolify + logger()->error('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + } + } } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index f35a3a806..f5726e424 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -100,7 +100,7 @@ public function addNewToken() public function deleteToken(int $tokenId) { try { - $token = CloudProviderToken::findOrFail($tokenId); + $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); $token->delete(); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index b9e3944b5..6d12895eb 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,8 @@ class Delete extends Component public Server $server; + public bool $delete_from_hetzner = false; + public function mount(string $server_uuid) { try { @@ -41,8 +43,9 @@ public function delete($password) return; } + $this->server->delete(); - DeleteServer::dispatch($this->server); + DeleteServer::dispatch($this->server, $this->delete_from_hetzner); return redirect()->route('server.index'); } catch (\Throwable $e) { @@ -52,6 +55,18 @@ public function delete($password) public function render() { - return view('livewire.server.delete'); + $checkboxes = []; + + if ($this->server->hetzner_server_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hetzner', + 'label' => 'Also delete server from Hetzner Cloud', + 'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.', + ]; + } + + return view('livewire.server.delete', [ + 'checkboxes' => $checkboxes, + ]); } } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index d509adcb6..b67411e17 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -2,9 +2,12 @@ namespace App\Livewire\Server\New; +use App\Enums\ProxyTypes; use App\Models\CloudProviderToken; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; +use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -15,6 +18,10 @@ class ByHetzner extends Component { use AuthorizesRequests; + // Step tracking + public int $current_step = 1; + + // Locked data #[Locked] public Collection $available_tokens; @@ -24,6 +31,7 @@ class ByHetzner extends Component #[Locked] public $limit_reached; + // Step 1: Token selection public ?int $selected_token_id = null; public string $hetzner_token = ''; @@ -32,22 +40,69 @@ class ByHetzner extends Component public ?string $token_name = null; + // Step 2: Server configuration + public array $locations = []; + + public array $images = []; + + public array $serverTypes = []; + + public ?string $selected_location = null; + + public ?int $selected_image = null; + + public ?string $selected_server_type = null; + + public string $server_name = ''; + + public bool $start_after_create = true; + + public ?int $private_key_id = null; + + public bool $loading_data = false; + public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() ->where('provider', 'hetzner') ->get(); + $this->server_name = generate_random_name(); + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } } protected function rules(): array { - return [ + $rules = [ 'selected_token_id' => 'nullable|integer', 'hetzner_token' => 'required_without:selected_token_id|string', 'save_token' => 'boolean', - 'token_name' => 'required_if:save_token,true|nullable|string|max:255', + 'token_name' => [ + 'nullable', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { + $fail('Please provide a name for the token.'); + } + }, + ], ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => 'required|string|max:255', + 'selected_location' => 'required|string', + 'selected_image' => 'required|integer', + 'selected_server_type' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'start_after_create' => 'boolean', + ]); + } + + return $rules; } protected function messages(): array @@ -76,6 +131,275 @@ private function validateHetznerToken(string $token): bool } } + private function getHetznerToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return $this->hetzner_token; + } + + public function nextStep() + { + // Validate step 1 + $this->validate([ + 'selected_token_id' => 'nullable|integer', + 'hetzner_token' => 'required_without:selected_token_id|string', + 'save_token' => 'boolean', + 'token_name' => [ + 'nullable', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { + $fail('Please provide a name for the token.'); + } + }, + ], + ]); + + try { + $hetznerToken = $this->getHetznerToken(); + + if (! $hetznerToken) { + return $this->dispatch('error', 'Please provide a valid Hetzner API token.'); + } + + // Validate token if it's a new one + if (! $this->selected_token_id) { + if (! $this->validateHetznerToken($hetznerToken)) { + return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); + } + + // Save token if requested + if ($this->save_token) { + CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => 'hetzner', + 'token' => $this->hetzner_token, + 'name' => $this->token_name, + ]); + } + } + + // Load Hetzner data + $this->loadHetznerData($hetznerToken); + + // Move to step 2 + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep() + { + $this->current_step = 1; + } + + private function loadHetznerData(string $token) + { + $this->loading_data = true; + + try { + $hetznerService = new HetznerService($token); + + $this->locations = $hetznerService->getLocations(); + $this->serverTypes = $hetznerService->getServerTypes(); + + // Get images and sort by name + $images = $hetznerService->getImages(); + + ray('Raw images from Hetzner API', [ + 'total_count' => count($images), + 'types' => collect($images)->pluck('type')->unique()->values(), + 'sample' => array_slice($images, 0, 3), + ]); + + $this->images = collect($images) + ->filter(function ($image) { + // Only system images + if (! isset($image['type']) || $image['type'] !== 'system') { + return false; + } + + // Filter out deprecated images + if (isset($image['deprecated']) && $image['deprecated'] === true) { + return false; + } + + return true; + }) + ->sortBy('name') + ->values() + ->toArray(); + + ray('Filtered images', [ + 'filtered_count' => count($this->images), + 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(), + ]); + + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + public function getAvailableServerTypesProperty() + { + ray('Getting available server types', [ + 'selected_location' => $this->selected_location, + 'total_server_types' => count($this->serverTypes), + ]); + + if (! $this->selected_location) { + return $this->serverTypes; + } + + $filtered = collect($this->serverTypes) + ->filter(function ($type) { + if (! isset($type['locations'])) { + return false; + } + + $locationNames = collect($type['locations'])->pluck('name')->toArray(); + + return in_array($this->selected_location, $locationNames); + }) + ->values() + ->toArray(); + + ray('Filtered server types', [ + 'selected_location' => $this->selected_location, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getAvailableImagesProperty() + { + ray('Getting available images', [ + 'selected_server_type' => $this->selected_server_type, + 'total_images' => count($this->images), + 'images' => $this->images, + ]); + + if (! $this->selected_server_type) { + return $this->images; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + ray('Server type data', $serverType); + + if (! $serverType || ! isset($serverType['architecture'])) { + ray('No architecture in server type, returning all'); + + return $this->images; + } + + $architecture = $serverType['architecture']; + + $filtered = collect($this->images) + ->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture) + ->values() + ->toArray(); + + ray('Filtered images', [ + 'architecture' => $architecture, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function updatedSelectedLocation($value) + { + ray('Location selected', $value); + + // Reset server type and image when location changes + $this->selected_server_type = null; + $this->selected_image = null; + } + + public function updatedSelectedServerType($value) + { + ray('Server type selected', $value); + + // Reset image when server type changes + $this->selected_image = null; + } + + public function updatedSelectedImage($value) + { + ray('Image selected', $value); + } + + private function createHetznerServer(string $token): array + { + $hetznerService = new HetznerService($token); + + // Get the private key and extract public key + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + + $publicKey = $privateKey->getPublicKey(); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + ray('Private Key Info', [ + 'private_key_id' => $this->private_key_id, + 'sha256_fingerprint' => $privateKey->fingerprint, + 'md5_fingerprint' => $md5Fingerprint, + ]); + + // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints + $existingSshKeys = $hetznerService->getSshKeys(); + $existingKey = null; + + ray('Existing SSH Keys on Hetzner', $existingSshKeys); + + foreach ($existingSshKeys as $key) { + if ($key['fingerprint'] === $md5Fingerprint) { + $existingKey = $key; + break; + } + } + + // Upload SSH key if it doesn't exist + if ($existingKey) { + $sshKeyId = $existingKey['id']; + ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); + } else { + $sshKeyName = $privateKey->name; + $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); + $sshKeyId = $uploadedKey['id']; + ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); + } + + // Prepare server creation parameters + $params = [ + 'name' => $this->server_name, + 'server_type' => $this->selected_server_type, + 'image' => $this->selected_image, + 'location' => $this->selected_location, + 'start_after_create' => $this->start_after_create, + 'ssh_keys' => [$sshKeyId], + ]; + + ray('Server creation parameters', $params); + + // Create server on Hetzner + $hetznerServer = $hetznerService->createServer($params); + + ray('Hetzner server created', $hetznerServer); + + return $hetznerServer; + } + public function submit() { $this->validate(); @@ -87,35 +411,27 @@ public function submit() return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } - // Determine which token to use - if ($this->selected_token_id) { - $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $token) { - return $this->dispatch('error', 'Selected token not found.'); - } - $hetznerToken = $token->token; - } else { - $hetznerToken = $this->hetzner_token; + $hetznerToken = $this->getHetznerToken(); - // Validate the new token before saving - if (! $this->validateHetznerToken($hetznerToken)) { - return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); - } + // Create server on Hetzner + $hetznerServer = $this->createHetznerServer($hetznerToken); - // If saving the new token - if ($this->save_token) { - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => 'hetzner', - 'token' => $this->hetzner_token, - 'name' => $this->token_name, - ]); - } - } + // Create server in Coolify database + $server = Server::create([ + 'name' => $this->server_name, + 'ip' => $hetznerServer['public_net']['ipv4']['ip'], + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'hetzner_server_id' => $hetznerServer['id'], + ]); - // TODO: Actual Hetzner server provisioning will be implemented in future phase - // The $hetznerToken variable contains the token to use - return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.'); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return redirect()->route('server.show', $server->uuid); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index c210f3c5b..08f3f1ebd 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey) } } + public static function generateMd5Fingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->getFingerprint('md5'); + } catch (\Throwable $e) { + return null; + } + } + public static function fingerprintExists($fingerprint, $excludeId = null) { $query = self::query() diff --git a/app/Models/Server.php b/app/Models/Server.php index 829a4b5aa..e30b10043 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -162,6 +162,7 @@ protected static function booted() 'description', 'private_key_id', 'team_id', + 'hetzner_server_id', ]; protected $guarded = []; diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php new file mode 100644 index 000000000..039eb81a9 --- /dev/null +++ b/app/Services/HetznerService.php @@ -0,0 +1,96 @@ +token = $token; + } + + private function request(string $method, string $endpoint, array $data = []) + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + ])->timeout(30)->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); + } + + return $response->json(); + } + + private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $data['page'] = $page; + $data['per_page'] = 50; + + $response = $this->request($method, $endpoint, $data); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $nextPage = $response['meta']['pagination']['next_page'] ?? null; + $page = $nextPage; + } while ($nextPage !== null); + + return $allResults; + } + + public function getLocations(): array + { + return $this->requestPaginated('get', '/locations', 'locations'); + } + + public function getImages(): array + { + return $this->requestPaginated('get', '/images', 'images', [ + 'type' => 'system', + ]); + } + + public function getServerTypes(): array + { + return $this->requestPaginated('get', '/server_types', 'server_types'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh_keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createServer(array $params): array + { + $response = $this->request('post', '/servers', $params); + + return $response['server'] ?? []; + } + + public function deleteServer(int $serverId): void + { + $this->request('delete', "/servers/{$serverId}"); + } +} diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php index e52bbcc16..e57354037 100644 --- a/database/migrations/2025_10_03_154100_update_clickhouse_image.php +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -1,32 +1,32 @@ -string('image')->default('bitnamilegacy/clickhouse')->change(); - }); - // Optionally, update any existing rows with the old default to the new one - DB::table('standalone_clickhouses') - ->where('image', 'bitnami/clickhouse') - ->update(['image' => 'bitnamilegacy/clickhouse']); - } - - public function down() - { - Schema::table('standalone_clickhouses', function (Blueprint $table) { - $table->string('image')->default('bitnami/clickhouse')->change(); - }); - // Optionally, revert any changed values - DB::table('standalone_clickhouses') - ->where('image', 'bitnamilegacy/clickhouse') - ->update(['image' => 'bitnami/clickhouse']); - } -}; \ No newline at end of file +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php new file mode 100644 index 000000000..b1c9ec48b --- /dev/null +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -0,0 +1,28 @@ +bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } +}; diff --git a/public/svgs/hetzner.svg b/public/svgs/hetzner.svg new file mode 100644 index 000000000..68b1b868d --- /dev/null +++ b/public/svgs/hetzner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 103f18316..46164840d 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -250,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> {{ $checkbox['label'] }} + @if (isset($checkbox['default_warning'])) + + @endif @endforeach @if (!$disableTwoStepConfirmation) diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index 619a827e7..a941b7ee2 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -2,11 +2,17 @@
@can('viewAny', App\Models\CloudProviderToken::class)
-

Add Server from Cloud Provider

- + + +
+ + + + + Hetzner +
+
diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index c61775ee8..073849452 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -15,16 +15,15 @@
@if ($server->definedResources()->count() > 0)
You need to delete all resources before deleting this server.
- - @else - @endif + + @endif
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 83de355e3..7ed5b1495 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -2,51 +2,128 @@ @if ($limit_reached) @else -
- @if ($available_tokens->count() > 0) + @if ($current_step === 1) + + @if ($available_tokens->count() > 0) +
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
OR
+ @endif +
- - - @foreach ($available_tokens as $token) - - @endforeach - +
-
OR
- @endif -
- -
- -
- -
- - @if ($save_token)
- +
- @endif - - Continue - -
+
+ +
+ + + Continue + + + @elseif ($current_step === 2) + @if ($loading_data) +
+
+
+

Loading Hetzner data...

+
+
+ @else +
+
+ +
+ +
+ + + @foreach ($locations as $location) + + @endforeach + +
+ +
+ + + @foreach ($this->availableServerTypes as $serverType) + + @endforeach + +
+ +
+ + + @foreach ($this->availableImages as $image) + + @endforeach + +
+ +
+ + + @foreach ($private_keys as $key) + + @endforeach + +
+ +
+ +
+ +
+ + Back + + + Create Server + +
+
+ @endif + @endif @endif diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index a25e245e9..f1f1180e8 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -9,6 +9,16 @@

General

+ @if ($server->hetzner_server_id) +
+ + + + + Hetzner +
+ @endif @if ($server->id === 0) - + From d837aa1473caa09ea809047bc8933285c23141e9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:22:10 +0200 Subject: [PATCH 03/68] fix(api-tokens): update settings link for API enablement message - Changed the link in the API tokens view to direct users to the advanced settings page instead of the general settings page, providing clearer guidance for enabling the API. --- resources/views/livewire/security/api-tokens.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index bf6bcf76c..b1f25a584 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -7,7 +7,7 @@

API Tokens

@if (!$isApiEnabled)
API is disabled. If you want to use the API, please enable it in the Settings menu.
+ href="{{ route('settings.advanced') }}" class="underline dark:text-white">Settings menu.
@else
Tokens are created with the current team as scope.
From 61e688affd5158ea4b7c138a5ef152c9ee6e4565 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:46:36 +0200 Subject: [PATCH 04/68] refactor(checkbox, utilities, global-search): enhance focus styles for better accessibility --- app/View/Components/Forms/Checkbox.php | 2 +- resources/css/utilities.css | 8 ++++---- resources/views/livewire/global-search.blade.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index ece7f0e35..ea4f4ead2 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -22,7 +22,7 @@ public function __construct( public string|bool|null $checked = false, public string|bool $instantSave = false, public bool $disabled = false, - public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed', + public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100', public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, diff --git a/resources/css/utilities.css b/resources/css/utilities.css index bedfb51bc..67b1e7f80 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title { } @utility input-sticky { - @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; + @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } @utility input-sticky-active { @@ -41,7 +41,7 @@ @utility input-sticky-active { /* Focus */ @utility input-focus { - @apply focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; + @apply focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } /* input, select before */ @@ -52,14 +52,14 @@ @utility input-select { /* Readonly */ @utility input { @apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200; - @apply input-focus; @apply input-select; + @apply focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } @utility select { @apply w-full; - @apply input-focus; @apply input-select; + @apply focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100; } @utility button { diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index b7203c329..3bf29d392 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -266,7 +266,7 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen + class="w-full pl-12 pr-32 py-4 text-base bg-white dark:bg-coolgray-100 border-none rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100" />
/ or ⌘K to focus From c9e641854294d98c8c33cdd3d0f6edd31e50c9c3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:52:59 +0200 Subject: [PATCH 05/68] refactor(forms): simplify wire:dirty class bindings for input, select, and textarea components --- resources/views/components/forms/input.blade.php | 6 ++---- resources/views/components/forms/select.blade.php | 3 +-- resources/views/components/forms/textarea.blade.php | 7 +++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 858f5ac1c..f6c86f177 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -28,8 +28,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov merge(['class' => $defaultClass]) }} @required($required) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}" @@ -40,8 +39,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" diff --git a/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php index 508a85e0c..3c8eea25a 100644 --- a/resources/views/components/forms/select.blade.php +++ b/resources/views/components/forms/select.blade.php @@ -11,8 +11,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu @endif diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index b4dec192a..a1c57e775 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -46,8 +46,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer merge(['class' => $defaultClassInput]) }} @required($required) @if ($id !== 'null') wire:model={{ $id }} @endif - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' - wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" + wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}"> @@ -56,7 +55,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}" @else wire:model={{ $value ?? $id }} - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif + wire:dirty.class="dark:ring-warning ring-warning" @endif @disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}" name="{{ $name }}" name={{ $id }}> @@ -68,7 +67,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer @if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}" @else wire:model={{ $value ?? $id }} - wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif + wire:dirty.class="dark:ring-warning ring-warning" @endif @disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}" name="{{ $name }}" name={{ $id }}> @endif From 704ddf2968ef4dbaf5c836fca2c9ceb30964e925 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:53:57 +0200 Subject: [PATCH 06/68] improved hetzner features --- app/Actions/Server/DeleteServer.php | 12 +- .../Security/CloudProviderTokenForm.php | 99 ++++++++++++ app/Livewire/Security/CloudProviderTokens.php | 79 ++-------- .../Server/CloudProviderToken/Show.php | 144 ++++++++++++++++++ app/Livewire/Server/Create.php | 8 + app/Livewire/Server/New/ByHetzner.php | 99 +++++------- app/Models/CloudProviderToken.php | 10 ++ app/Models/Server.php | 6 + ...oud_provider_token_id_to_servers_table.php | 29 ++++ .../views/components/modal-input.blade.php | 13 +- .../views/components/server/sidebar.blade.php | 5 + .../cloud-provider-token-form.blade.php | 43 ++++++ .../security/cloud-provider-tokens.blade.php | 36 +---- .../cloud-provider-token/show.blade.php | 61 ++++++++ .../views/livewire/server/create.blade.php | 2 +- .../livewire/server/new/by-hetzner.blade.php | 65 ++++---- routes/web.php | 2 + 17 files changed, 514 insertions(+), 199 deletions(-) create mode 100644 app/Livewire/Security/CloudProviderTokenForm.php create mode 100644 app/Livewire/Server/CloudProviderToken/Show.php create mode 100644 database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php create mode 100644 resources/views/livewire/security/cloud-provider-token-form.blade.php create mode 100644 resources/views/livewire/server/cloud-provider-token/show.blade.php diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index db197a019..b7523714f 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -25,10 +25,14 @@ public function handle(Server $server, bool $deleteFromHetzner = false) private function deleteFromHetzner(Server $server): void { try { - // Get the cloud provider token for Hetzner - $token = CloudProviderToken::where('team_id', $server->team_id) - ->where('provider', 'hetzner') - ->first(); + // Use the server's associated token, or fallback to first available team token + $token = $server->cloudProviderToken; + + if (! $token) { + $token = CloudProviderToken::where('team_id', $server->team_id) + ->where('provider', 'hetzner') + ->first(); + } if (! $token) { ray('No Hetzner token found for team, skipping Hetzner deletion', [ diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php new file mode 100644 index 000000000..7affb1531 --- /dev/null +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -0,0 +1,99 @@ +authorize('create', CloudProviderToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => 'required|string|in:hetzner,digitalocean', + 'token' => 'required|string', + 'name' => 'required|string|max:255', + ]; + } + + protected function messages(): array + { + return [ + 'provider.required' => 'Please select a cloud provider.', + 'provider.in' => 'Invalid cloud provider selected.', + 'token.required' => 'API token is required.', + 'name.required' => 'Token name is required.', + ]; + } + + private function validateToken(string $provider, string $token): bool + { + try { + if ($provider === 'hetzner') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ray($response); + + return $response->successful(); + } + + // Add other providers here in the future + // if ($provider === 'digitalocean') { ... } + + return false; + } catch (\Throwable $e) { + return false; + } + } + + public function addToken() + { + $this->validate(); + + try { + // Validate the token with the provider's API + if (! $this->validateToken($this->provider, $this->token)) { + return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); + } + + $savedToken = CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => $this->provider, + 'token' => $this->token, + 'name' => $this->name, + ]); + + $this->reset(['token', 'name']); + + // Dispatch event with token ID so parent components can react + $this->dispatch('tokenAdded', tokenId: $savedToken->id); + + $this->dispatch('success', 'Cloud provider token added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token-form'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index f5726e424..f05b3c0ca 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -4,7 +4,6 @@ use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Http; use Livewire\Component; class CloudProviderTokens extends Component @@ -13,34 +12,16 @@ class CloudProviderTokens extends Component public $tokens; - public string $provider = 'hetzner'; - - public string $token = ''; - - public string $name = ''; - public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); } - protected function rules(): array + public function getListeners() { return [ - 'provider' => 'required|string|in:hetzner,digitalocean', - 'token' => 'required|string', - 'name' => 'required|string|max:255', - ]; - } - - protected function messages(): array - { - return [ - 'provider.required' => 'Please select a cloud provider.', - 'provider.in' => 'Invalid cloud provider selected.', - 'token.required' => 'API token is required.', - 'name.required' => 'Token name is required.', + 'tokenAdded' => 'loadTokens', ]; } @@ -49,60 +30,20 @@ public function loadTokens() $this->tokens = CloudProviderToken::ownedByCurrentTeam()->get(); } - private function validateToken(string $provider, string $token): bool - { - try { - if ($provider === 'hetzner') { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); - - return $response->successful(); - } - - // Add other providers here in the future - // if ($provider === 'digitalocean') { ... } - - return false; - } catch (\Throwable $e) { - return false; - } - } - - public function addNewToken() - { - $this->validate(); - - try { - $this->authorize('create', CloudProviderToken::class); - - // Validate the token with the provider's API - if (! $this->validateToken($this->provider, $this->token)) { - return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); - } - - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => $this->provider, - 'token' => $this->token, - 'name' => $this->name, - ]); - - $this->reset(['token', 'name']); - $this->loadTokens(); - - $this->dispatch('success', 'Cloud provider token added successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function deleteToken(int $tokenId) { try { $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + // Check if any servers are using this token + if ($token->hasServers()) { + $serverCount = $token->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return; + } + $token->delete(); $this->loadTokens(); diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php new file mode 100644 index 000000000..6b22fddc6 --- /dev/null +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -0,0 +1,144 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + ]; + } + + public function loadTokens() + { + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + $this->loadTokens(); + } + + public function setCloudProviderToken($tokenId) + { + $ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId); + if (is_null($ownedToken)) { + $this->dispatch('error', 'You are not allowed to use this token.'); + + return; + } + try { + $this->authorize('update', $this->server); + + // Validate the token works and can access this specific server + $validationResult = $this->validateTokenForServer($ownedToken); + if (! $validationResult['valid']) { + $this->dispatch('error', $validationResult['error']); + + return; + } + + $this->server->cloudProviderToken()->associate($ownedToken); + $this->server->save(); + $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('refreshServerShow'); + } catch (\Exception $e) { + $this->server->refresh(); + $this->dispatch('error', $e->getMessage()); + } + } + + private function validateTokenForServer(CloudProviderToken $token): array + { + try { + // First, validate the token itself + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if (! $response->successful()) { + return [ + 'valid' => false, + 'error' => 'This token is invalid or has insufficient permissions.', + ]; + } + + // Check if this token can access the specific Hetzner server + if ($this->server->hetzner_server_id) { + $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this server. It may belong to a different Hetzner project.', + ]; + } + } + + return ['valid' => true]; + } catch (\Throwable $e) { + return [ + 'valid' => false, + 'error' => 'Failed to validate token: '.$e->getMessage(), + ]; + } + } + + public function validateToken() + { + try { + $token = $this->server->cloudProviderToken; + if (! $token) { + $this->dispatch('error', 'No Hetzner token is associated with this server.'); + + return; + } + + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + + if ($response->successful()) { + $this->dispatch('success', 'Hetzner token is valid and working.'); + } else { + $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 2d4ba4430..cf77664fe 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Team; use Livewire\Component; @@ -12,6 +13,8 @@ class Create extends Component public bool $limit_reached = false; + public bool $has_hetzner_tokens = false; + public function mount() { $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); @@ -21,6 +24,11 @@ public function mount() return; } $this->limit_reached = Team::serverLimitReached(); + + // Check if user has Hetzner tokens + $this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->exists(); } public function render() diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index b67411e17..d0a7582cd 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -34,12 +34,6 @@ class ByHetzner extends Component // Step 1: Token selection public ?int $selected_token_id = null; - public string $hetzner_token = ''; - - public bool $save_token = false; - - public ?string $token_name = null; - // Step 2: Server configuration public array $locations = []; @@ -64,31 +58,50 @@ class ByHetzner extends Component public function mount() { $this->authorize('viewAny', CloudProviderToken::class); - $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() - ->where('provider', 'hetzner') - ->get(); + $this->loadTokens(); $this->server_name = generate_random_name(); if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } } + public function getListeners() + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection() + { + $this->selected_token_id = null; + $this->current_step = 1; + } + + public function loadTokens() + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hetzner') + ->get(); + } + + public function handleTokenAdded($tokenId) + { + // Refresh token list + $this->loadTokens(); + + // Auto-select the new token + $this->selected_token_id = $tokenId; + + // Automatically proceed to next step + $this->nextStep(); + } + protected function rules(): array { $rules = [ - 'selected_token_id' => 'nullable|integer', - 'hetzner_token' => 'required_without:selected_token_id|string', - 'save_token' => 'boolean', - 'token_name' => [ - 'nullable', - 'string', - 'max:255', - function ($attribute, $value, $fail) { - if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { - $fail('Please provide a name for the token.'); - } - }, - ], + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]; if ($this->current_step === 2) { @@ -108,8 +121,8 @@ function ($attribute, $value, $fail) { protected function messages(): array { return [ - 'hetzner_token.required_without' => 'Please provide a Hetzner API token or select a saved token.', - 'token_name.required_if' => 'Please provide a name for the token.', + 'selected_token_id.required' => 'Please select a Hetzner token.', + 'selected_token_id.exists' => 'Selected token not found.', ]; } @@ -139,50 +152,21 @@ private function getHetznerToken(): string return $token ? $token->token : ''; } - return $this->hetzner_token; + return ''; } public function nextStep() { - // Validate step 1 + // Validate step 1 - just need a token selected $this->validate([ - 'selected_token_id' => 'nullable|integer', - 'hetzner_token' => 'required_without:selected_token_id|string', - 'save_token' => 'boolean', - 'token_name' => [ - 'nullable', - 'string', - 'max:255', - function ($attribute, $value, $fail) { - if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { - $fail('Please provide a name for the token.'); - } - }, - ], + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', ]); try { $hetznerToken = $this->getHetznerToken(); if (! $hetznerToken) { - return $this->dispatch('error', 'Please provide a valid Hetzner API token.'); - } - - // Validate token if it's a new one - if (! $this->selected_token_id) { - if (! $this->validateHetznerToken($hetznerToken)) { - return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); - } - - // Save token if requested - if ($this->save_token) { - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => 'hetzner', - 'token' => $this->hetzner_token, - 'name' => $this->token_name, - ]); - } + return $this->dispatch('error', 'Please select a valid Hetzner token.'); } // Load Hetzner data @@ -424,6 +408,7 @@ public function submit() 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], ]); diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 9ce216b25..607040269 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -17,6 +17,16 @@ public function team() return $this->belongsTo(Team::class); } + public function servers() + { + return $this->hasMany(Server::class); + } + + public function hasServers(): bool + { + return $this->servers()->exists(); + } + public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); diff --git a/app/Models/Server.php b/app/Models/Server.php index e30b10043..e1a004755 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -161,6 +161,7 @@ protected static function booted() 'user', 'description', 'private_key_id', + 'cloud_provider_token_id', 'team_id', 'hetzner_server_id', ]; @@ -890,6 +891,11 @@ public function privateKey() return $this->belongsTo(PrivateKey::class); } + public function cloudProviderToken() + { + return $this->belongsTo(CloudProviderToken::class); + } + public function muxFilename() { return 'mux_'.$this->uuid; diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php new file mode 100644 index 000000000..a25a4ce83 --- /dev/null +++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php @@ -0,0 +1,29 @@ +foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['cloud_provider_token_id']); + $table->dropColumn('cloud_provider_token_id'); + }); + } +}; diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index c15985d03..a9ad39871 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -8,8 +8,11 @@ 'content' => null, 'closeOutside' => true, 'minWidth' => '36rem', + 'isFullWidth' => false, ]) -
@if ($content)
@@ -17,13 +20,13 @@ class="relative w-auto h-auto" wire:ignore>
@else @if ($disabled) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isErrorButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @elseif ($isHighlightedButton) - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @else - {{ $buttonTitle }} + $isFullWidth])>{{ $buttonTitle }} @endif @endif
-
+ \ No newline at end of file From ff889e658d94290bd68cc0c8c64ae7a63560fc20 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:47:26 +0200 Subject: [PATCH 60/68] refactor: improve cloud-init script management UI and cache control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add manual cache clearing command (search:clear) for testing - Integrate cloud-init scripts into global search navigation - Improve form UX by preventing field reset during edit operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Livewire/GlobalSearch.php | 1 + resources/views/livewire/global-search.blade.php | 16 ---------------- .../security/cloud-init-script-form.blade.php | 4 ++-- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 680ac7701..5fcedd94d 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -79,6 +79,7 @@ public function mount() public function openSearchModal() { + sleep(4); $this->isModalOpen = true; $this->loadSearchableItems(); $this->loadCreatableItems(); diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 3df03ea0d..3bf21f8aa 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -290,22 +290,6 @@ class="pointer-events-auto px-2 py-1 text-xs font-medium text-neutral-500 dark:t - - {{--
-
-

- ✓ Data loaded successfully! -

-

- searchable items available -

-

- Start typing to search... -

-
-
--}} -
diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php index 545c49a7f..1632b48d3 100644 --- a/resources/views/livewire/security/cloud-init-script-form.blade.php +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -1,4 +1,4 @@ - +
- + \ No newline at end of file From a17b105a92420163200715b8a2f983aeb83f4ce9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:48:12 +0200 Subject: [PATCH 61/68] fix: hide 'No results found' message while data is loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent showing 'No results found' when user types during initial data loading phase. The message now only appears after data has fully loaded and the search still returns no results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- resources/views/livewire/global-search.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 3bf21f8aa..06da31354 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -828,7 +828,7 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"