basics of adding / removing hetzner servers
This commit is contained in:
parent
c1bcc41546
commit
215301fa8f
15 changed files with 744 additions and 119 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ protected static function booted()
|
|||
'description',
|
||||
'private_key_id',
|
||||
'team_id',
|
||||
'hetzner_server_id',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
96
app/Services/HetznerService.php
Normal file
96
app/Services/HetznerService.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
// Change the default value for the 'image' column
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
}
|
||||
};
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
// Change the default value for the 'image' column
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
6
public/svgs/hetzner.svg
Normal 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 |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue