basics of adding / removing hetzner servers

This commit is contained in:
Andras Bacsai 2025-10-09 10:41:29 +02:00
parent c1bcc41546
commit 215301fa8f
15 changed files with 744 additions and 119 deletions

View file

@ -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,
]);
}
}
}

View file

@ -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();

View file

@ -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,
]);
}
}

View file

@ -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);
}

View file

@ -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()

View file

@ -162,6 +162,7 @@ protected static function booted()
'description',
'private_key_id',
'team_id',
'hetzner_server_id',
];
protected $guarded = [];

View file

@ -0,0 +1,96 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class HetznerService
{
private string $token;
private string $baseUrl = 'https://api.hetzner.cloud/v1';
public function __construct(string $token)
{
$this->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}");
}
}

View file

@ -2,8 +2,8 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->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');
});
}
};

6
public/svgs/hetzner.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Hetzner red background -->
<rect width="200" height="200" fill="#D50C2D" rx="8"/>
<!-- Hetzner "H" logo in white -->
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View file

@ -250,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<span>{{ $checkbox['label'] }}</span>
</li>
</template>
@if (isset($checkbox['default_warning']))
<template x-if="!selectedActions.includes('{{ $checkbox['id'] }}')">
<li class="flex items-center text-red-500">
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span>{{ $checkbox['default_warning'] }}</span>
</li>
</template>
@endif
@endforeach
</ul>
@if (!$disableTwoStepConfirmation)

View file

@ -2,11 +2,17 @@
<div class="flex flex-col gap-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<div>
<h3 class="pb-2">Add Server from Cloud Provider</h3>
<div class="flex gap-2 flex-wrap">
<x-modal-input
buttonTitle="+ Hetzner"
title="Connect to Hetzner">
<x-modal-input title="Connect to Hetzner">
<x-slot:button-title>
<div class="flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
</svg>
<span>Hetzner</span>
</div>
</x-slot:button-title>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>

View file

@ -15,16 +15,15 @@
</div>
@if ($server->definedResources()->count() > 0)
<div class="pb-2 text-red-500">You need to delete all resources before deleting this server.</div>
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['This server will be permanently deleted.']" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Server Name below"
shortConfirmationLabel="Server Name" />
@else
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['This server will be permanently deleted.']" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Server Name below"
shortConfirmationLabel="Server Name" />
@endif
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete"
:actions="['This server will be permanently deleted from Coolify.']"
:checkboxes="$checkboxes"
confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Server Name below"
shortConfirmationLabel="Server Name" />
@endif
</div>
</div>

View file

@ -2,51 +2,128 @@
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
@if ($available_tokens->count() > 0)
@if ($current_step === 1)
<form class="flex flex-col w-full gap-2" wire:submit.prevent="nextStep">
@if ($available_tokens->count() > 0)
<div>
<x-forms.select label="Use Saved Token" id="selected_token_id"
wire:change="selectToken($event.target.value)">
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'Hetzner Token' }} (***{{ substr($token->token, -4) }})
</option>
@endforeach
</x-forms.select>
</div>
<div class="text-center text-sm dark:text-neutral-500 py-2">OR</div>
@endif
<div>
<x-forms.select label="Use Saved Token" id="selected_token_id" wire:change="selectToken($event.target.value)">
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'Hetzner Token' }} (***{{ substr($token->token, -4) }})
</option>
@endforeach
</x-forms.select>
<x-forms.input type="password" id="hetzner_token" label="New Hetzner API Token"
helper="Your Hetzner Cloud API token. You can create one in your <a href='https://console.hetzner.cloud/' target='_blank' class='underline dark:text-white'>Hetzner Cloud Console</a>." />
</div>
<div class="text-center text-sm dark:text-neutral-500 py-2">OR</div>
@endif
<div>
<x-forms.input
type="password"
id="hetzner_token"
label="New Hetzner API Token"
helper="Your Hetzner Cloud API token. You can create one in your <a href='https://console.hetzner.cloud/' target='_blank' class='underline dark:text-white'>Hetzner Cloud Console</a>."
/>
</div>
<div>
<x-forms.checkbox
id="save_token"
label="Save this token for my team"
/>
</div>
@if ($save_token)
<div>
<x-forms.input
id="token_name"
label="Token Name"
placeholder="e.g., Production Hetzner"
helper="Give this token a friendly name to identify it later."
/>
<x-forms.checkbox id="save_token" label="Save this token for my team" />
</div>
@endif
<x-forms.button canGate="create" :canResource="App\Models\Server::class" type="submit">
Continue
</x-forms.button>
</form>
<div>
<x-forms.input id="token_name" label="Token Name" placeholder="e.g., Production Hetzner"
helper="Give this token a friendly name to identify it later." />
</div>
<x-forms.button canGate="create" :canResource="App\Models\Server::class" type="submit">
Continue
</x-forms.button>
</form>
@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 Hetzner 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="Location" id="selected_location" wire:model.live="selected_location"
required>
<option value="">Select a location...</option>
@foreach ($locations as $location)
<option value="{{ $location['name'] }}">
{{ $location['city'] }} - {{ $location['country'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Server Type" id="selected_server_type"
wire:model.live="selected_server_type" required :disabled="!$selected_location">
<option value="">{{ $selected_location ? 'Select a server type...' : 'Select a location first' }}</option>
@foreach ($this->availableServerTypes as $serverType)
<option value="{{ $serverType['name'] }}">
{{ $serverType['description'] }} -
{{ $serverType['cores'] }} vCPU,
{{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB
@if (isset($serverType['architecture']))
({{ $serverType['architecture'] }})
@endif
@if (isset($serverType['prices']))
-
{{ number_format($serverType['prices'][0]['price_monthly']['gross'] ?? 0, 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_server_type">
<option value="">{{ $selected_server_type ? 'Select an image...' : 'Select a server type first' }}</option>
@foreach ($this->availableImages as $image)
<option value="{{ $image['id'] }}">
{{ $image['description'] ?? $image['name'] }}
@if (isset($image['architecture']))
({{ $image['architecture'] }})
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<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>
</div>
<div>
<x-forms.checkbox id="start_after_create" label="Start server after creation" />
</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">
Create Server
</x-forms.button>
</div>
</form>
@endif
@endif
@endif
</div>

View file

@ -9,6 +9,16 @@
<form wire:submit.prevent='submit' class="flex flex-col">
<div class="flex gap-2">
<h2>General</h2>
@if ($server->hetzner_server_id)
<div
class="flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded bg-white dark:bg-coolgray-100 dark:text-white ">
<svg class="w-4 h-4" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
</svg>
<span>Hetzner</span>
</div>
@endif
@if ($server->id === 0)
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
submitAction="submit" :actions="[
@ -141,8 +151,9 @@ class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-co
<input readonly disabled autocomplete="off"
class="w-full input opacity-50 cursor-not-allowed"
value="{{ $serverTimezone ?: 'No timezone set' }}" placeholder="Server Timezone">
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>