Add Vultr cloud provider integration

This commit is contained in:
Cornelis Terblanche committed 2026-06-03 21:32:18 +02:00
1 parent 2833c68af9
commit 94abbe1590
33 files changed
+2978 -44

No files matched your search

@@ -9,10 +9,10 @@
<a class="sub-menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Private Key</span>
</a>
@if ($server->hetzner_server_id)
@if ($server->hetzner_server_id || $server->vultr_instance_id)
<a class="sub-menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
{{ wireNavigate() }}
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Hetzner Token</span>
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Cloud Token</span>
</a>
@endif
<a class="sub-menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
@@ -193,6 +193,30 @@
</x-slot:content>
<livewire:server.new.by-hetzner :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
<x-modal-input title="Connect a Vultr Server" isFullWidth>
<x-slot:content>
<div
class="group relative box-without-bg cursor-pointer hover:border-coollabs transition-all duration-200 p-6 h-full min-h-[210px]">
<div class="flex flex-col gap-4 text-left">
<div class="flex items-center justify-between">
<svg class="size-10" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z"
fill="white" />
</svg>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Vultr Cloud</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Vultr account.
</p>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-vultr :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
@endcan
</div>
@@ -745,4 +769,4 @@
</x-modal-input>
</div>
@endif
</section>
</section>
@@ -6,6 +6,7 @@
<x-forms.select required id="provider" label="Provider">
<option value="hetzner">Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
@else
<input type="hidden" wire:model="provider" />
@@ -20,7 +21,7 @@
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://my.vultr.com/settings/#settingsapi' : '#') }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
Project → Security → API Tokens.
@if ($provider === 'hetzner')
@@ -42,6 +43,7 @@
<x-forms.select required id="provider" label="Provider" disabled>
<option value="hetzner" selected>Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
</div>
<div class="flex-1 min-w-64">
@@ -1,17 +1,17 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify
{{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
<div class="w-full">
@if ($server->hetzner_server_id)
@if ($server->hetzner_server_id || $server->vultr_instance_id)
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>{{ $providerName }} Token</h2>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input buttonTitle="+ Add" title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
<x-modal-input buttonTitle="+ Add" title="Add {{ $providerName }} Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$provider" />
</x-modal-input>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted
@@ -19,7 +19,7 @@
Validate token
</x-forms.button>
</div>
<div class="pb-4">Change your server's Hetzner token.</div>
<div class="pb-4">Change your server's {{ $providerName }} token.</div>
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
@forelse ($cloudProviderTokens as $token)
<div
@@ -42,17 +42,17 @@
@endif
</div>
@empty
<div>No Hetzner tokens found. </div>
<div>No {{ $providerName }} tokens found. </div>
@endforelse
</div>
@else
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>Cloud Token</h2>
</div>
<div class="pb-4">This server was not created through Hetzner Cloud integration.</div>
<div class="pb-4">This server was not created through a supported cloud provider integration.</div>
<div class="p-4 border rounded-md dark:border-coolgray-300 dark:bg-coolgray-100">
<p class="dark:text-neutral-400">
Only servers created through Hetzner Cloud can have their tokens managed here.
Only servers created through a supported cloud provider can have their tokens managed here.
</p>
</div>
@endif
@@ -23,6 +23,29 @@
</x-modal-input>
</div>
<div>
<x-modal-input title="Connect a Vultr Server">
<x-slot:content>
<div class="relative gap-2 cursor-pointer coolbox group">
<div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a Vultr Server</div>
<div class="box-description">
Deploy servers directly from your Vultr account
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
@endcan
@@ -0,0 +1,192 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
@if ($available_tokens->count() > 0)
<div class="flex gap-2">
<div class="flex-1">
<x-forms.select label="Select Vultr Token" id="selected_token_id"
wire:change="selectToken($event.target.value)" required>
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'Vultr Token' }}
</option>
@endforeach
</x-forms.select>
</div>
<div class="flex items-end">
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
:disabled="!$selected_token_id">
Continue
</x-forms.button>
</div>
</div>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif
<x-modal-input isFullWidth
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Vultr Token' }}"
title="Add Vultr Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr" />
</x-modal-input>
</div>
@elseif ($current_step === 2)
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Vultr data...</p>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Region" id="selected_region" wire:model.live="selected_region" required>
<option value="">Select a region...</option>
@foreach ($regions as $region)
<option value="{{ $region['id'] }}">
{{ $region['city'] ?? $region['id'] }} - {{ $region['country'] ?? $region['id'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Plan" id="selected_plan" wire:model.live="selected_plan"
helper="Learn more about <a class='inline-block underline dark:text-white' href='https://www.vultr.com/products/cloud-compute/' target='_blank'>Vultr plans</a>"
required :disabled="!$selected_region">
<option value="">
{{ $selected_region ? 'Select a plan...' : 'Select a region first' }}
</option>
@foreach ($this->availablePlans as $plan)
<option value="{{ $plan['id'] }}">
{{ $plan['id'] }} -
{{ $plan['vcpu_count'] ?? '?' }} vCPU,
{{ isset($plan['ram']) ? number_format($plan['ram'] / 1024, 1) : '?' }}GB RAM,
{{ $plan['disk'] ?? '?' }}GB
@if (isset($plan['monthly_cost']))
- ${{ number_format((float) $plan['monthly_cost'], 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Operating System" id="selected_os_id" required>
<option value="">Select an operating system...</option>
@foreach ($operatingSystems as $operatingSystem)
<option value="{{ $operatingSystem['id'] }}">
{{ $operatingSystem['name'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key"
isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your Vultr account and used to access the
server.
</p>
@endif
</div>
<div>
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'">
@foreach ($vultrSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" />
<x-forms.checkbox id="disable_public_ipv4" label="Disable public IPv4"
helper="Disable the default public IPv4 address" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. Coolify sends it to Vultr as user data."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
</div>
</div>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
Back
</x-forms.button>
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class"
type="submit" :disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button>
</div>
</form>
@endif
@endif
@endif
</div>
+148 -2
View File
@@ -1,4 +1,5 @@
<div x-data x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus() @endif">
<div x-data
x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
</x-slot>
@@ -78,6 +79,74 @@
</x-forms.button>
@endif
@endif
@if ($server->vultr_instance_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if (in_array($vultrInstanceStatus, ['pending', 'starting'])) wire:poll.5s="checkVultrInstanceStatus" @endif>
<svg class="w-4 h-4" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
@if ($vultrInstanceStatus)
<span class="pl-1.5">
@if (in_array($vultrInstanceStatus, ['pending', 'starting']))
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@endif
<span @class([
'text-green-500' => $vultrInstanceStatus === 'active',
'text-red-500' => in_array($vultrInstanceStatus, ['stopped', 'suspended', 'deleted']),
])>
{{ ucfirst($vultrInstanceStatus) }}
</span>
</span>
@else
<span class="pl-1.5">
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span>Checking status...</span>
</span>
@endif
</div>
<button wire:loading.remove wire:target="checkVultrInstanceStatus" title="Refresh Status"
wire:click.prevent='checkVultrInstanceStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkVultrInstanceStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && !$server->isFunctional() && $vultrInstanceStatus === 'stopped')
<x-forms.button wire:click.prevent='startVultrInstance' isHighlighted canGate="update"
:canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@if ($isValidating)
<div
class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400">
@@ -134,7 +203,8 @@
(!$isReachable || !$isUsable) &&
$server->id !== 0 &&
!$isValidating &&
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']))
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']) &&
!in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted']))
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
<x-slot:content>
@@ -423,6 +493,82 @@
@endif
</div>
@endif
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Vultr</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Vultr instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedVultrTokenId" label="Vultr Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableVultrTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-72">
<x-forms.input wire:model="manualVultrInstanceId"
label="Instance ID"
placeholder="e.g., 6d4b..."
helper="Enter the Vultr Instance ID from your Vultr dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchVultrInstanceById"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstanceById">Search by ID</span>
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchVultrInstance"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
</x-forms.button>
</div>
@if ($vultrSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
</div>
@endif
@if ($vultrNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualVultrInstanceId)
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
@else
No Vultr instance found matching IP: {{ $server->ip }}
@endif
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token, enter the Instance ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedVultrInstance)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
</div>
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
</div>
</div>
</div>