work work on hetzner integration
This commit is contained in:
parent
f85a4f04d9
commit
bf5c08d071
16 changed files with 628 additions and 79 deletions
|
|
@ -11,57 +11,86 @@ class DeleteServer
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $deleteFromHetzner = false)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
|
||||
{
|
||||
// Delete from Hetzner Cloud if requested and server has hetzner_server_id
|
||||
if ($deleteFromHetzner && $server->hetzner_server_id) {
|
||||
$this->deleteFromHetzner($server);
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
// Delete from Hetzner even if server is already gone from Coolify
|
||||
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
|
||||
$this->deleteFromHetznerById(
|
||||
$hetznerServerId ?? $server->hetzner_server_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
StopSentinel::run($server);
|
||||
$server->forceDelete();
|
||||
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
if (! $server) {
|
||||
return; // Server already force deleted from Coolify
|
||||
}
|
||||
|
||||
ray('force deleting server from Coolify', ['server_id' => $server->id]);
|
||||
|
||||
try {
|
||||
$server->forceDelete();
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
logger()->error('Failed to force delete server from Coolify', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromHetzner(Server $server): void
|
||||
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
// Use the server's associated token, or fallback to first available team token
|
||||
$token = $server->cloudProviderToken;
|
||||
// Use the provided token, or fallback to first available team token
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::find($cloudProviderTokenId);
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $server->team_id)
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->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,
|
||||
'team_id' => $teamId,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$hetznerService->deleteServer($server->hetzner_server_id);
|
||||
$hetznerService->deleteServer($hetznerServerId);
|
||||
|
||||
ray('Deleted server from Hetzner', [
|
||||
'hetzner_server_id' => $server->hetzner_server_id,
|
||||
'server_id' => $server->id,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to delete server from Hetzner', [
|
||||
'error' => $e->getMessage(),
|
||||
'hetzner_server_id' => $server->hetzner_server_id,
|
||||
'server_id' => $server->id,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
// 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,
|
||||
'hetzner_server_id' => $hetznerServerId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
app/Events/ServerValidated.php
Normal file
51
app/Events/ServerValidated.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ServerValidated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public function __construct(?int $teamId = null, ?string $serverUuid = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
$this->serverUuid = $serverUuid;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ServerValidated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'teamId' => $this->teamId,
|
||||
'serverUuid' => $this->serverUuid,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -746,7 +746,13 @@ public function delete_server(Request $request)
|
|||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
$server->delete();
|
||||
DeleteServer::dispatch($server);
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
false, // Don't delete from Hetzner via API
|
||||
$server->hetzner_server_id,
|
||||
$server->cloud_provider_token_id,
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ public function handle()
|
|||
return;
|
||||
}
|
||||
|
||||
// Check Hetzner server status if applicable
|
||||
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
|
||||
$this->checkHetznerStatus();
|
||||
}
|
||||
|
||||
// Temporarily disable mux if requested
|
||||
if ($this->disableMux) {
|
||||
$this->disableSshMux();
|
||||
|
|
@ -95,6 +100,37 @@ public function handle()
|
|||
}
|
||||
}
|
||||
|
||||
private function checkHetznerStatus(): void
|
||||
{
|
||||
try {
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
$status = $serverData['status'] ?? null;
|
||||
|
||||
// Save status to database
|
||||
$this->server->update(['hetzner_server_status' => $status]);
|
||||
|
||||
// If Hetzner reports server is off, mark as unreachable
|
||||
if ($status === 'off') {
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
Log::info('ServerConnectionCheck: Hetzner server is powered off', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
'hetzner_status' => $status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkConnection(): bool
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateAndInstallServerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 600; // 10 minutes
|
||||
|
||||
public int $maxTries = 3;
|
||||
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
public int $numberOfTries = 0
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Mark validation as in progress
|
||||
$this->server->update(['is_validating' => true]);
|
||||
|
||||
Log::info('ValidateAndInstallServer: Starting validation', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Validate connection
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $uptime) {
|
||||
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Server not reachable', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate OS
|
||||
$supportedOsType = $this->server->validateOS();
|
||||
if (! $supportedOsType) {
|
||||
$errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: OS not supported', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Docker is installed
|
||||
$dockerInstalled = $this->server->validateDockerEngine();
|
||||
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
||||
|
||||
if (! $dockerInstalled || ! $dockerComposeInstalled) {
|
||||
// Try to install Docker
|
||||
if ($this->numberOfTries >= $this->maxTries) {
|
||||
$errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempts' => $this->numberOfTries,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('ValidateAndInstallServer: Installing Docker', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Install Docker
|
||||
$this->server->installDocker();
|
||||
|
||||
// Retry validation after installation
|
||||
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate Docker version
|
||||
$dockerVersion = $this->server->validateDockerEngineVersion();
|
||||
if (! $dockerVersion) {
|
||||
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
$errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker version not sufficient', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation successful!
|
||||
Log::info('ValidateAndInstallServer: Validation successful', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
// Start proxy if needed
|
||||
if (! $this->server->isBuildServer()) {
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if ($proxyShouldRun) {
|
||||
StartProxy::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark validation as complete
|
||||
$this->server->update(['is_validating' => false]);
|
||||
|
||||
// Refresh server to get latest state
|
||||
$this->server->refresh();
|
||||
|
||||
// Broadcast events to update UI
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ValidateAndInstallServer: Exception occurred', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->server->update([
|
||||
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
|
||||
'is_validating' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,13 @@ public function delete($password)
|
|||
}
|
||||
|
||||
$this->server->delete();
|
||||
DeleteServer::dispatch($this->server, $this->delete_from_hetzner);
|
||||
DeleteServer::dispatch(
|
||||
$this->server->id,
|
||||
$this->delete_from_hetzner,
|
||||
$this->server->hetzner_server_id,
|
||||
$this->server->cloud_provider_token_id,
|
||||
$this->server->team_id
|
||||
);
|
||||
|
||||
return redirect()->route('server.index');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ class ByHetzner extends Component
|
|||
|
||||
public string $server_name = '';
|
||||
|
||||
public bool $start_after_create = true;
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
|
@ -111,7 +109,6 @@ protected function rules(): array
|
|||
'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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -370,7 +367,7 @@ private function createHetznerServer(string $token): array
|
|||
'server_type' => $this->selected_server_type,
|
||||
'image' => $this->selected_image,
|
||||
'location' => $this->selected_location,
|
||||
'start_after_create' => $this->start_after_create,
|
||||
'start_after_create' => true,
|
||||
'ssh_keys' => [$sshKeyId],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -67,13 +67,21 @@ class Show extends Component
|
|||
|
||||
public string $serverTimezone;
|
||||
|
||||
public ?string $hetznerServerStatus = null;
|
||||
|
||||
public bool $hetznerServerManuallyStarted = false;
|
||||
|
||||
public bool $isValidating = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServerShow' => 'refresh',
|
||||
'refreshServer' => '$refresh',
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
|
||||
"echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +146,10 @@ public function mount(string $server_uuid)
|
|||
if (! $this->server->isEmpty()) {
|
||||
$this->isBuildServerLocked = true;
|
||||
}
|
||||
// Load saved Hetzner status and validation state
|
||||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -218,6 +230,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->serverTimezone = $this->server->settings->server_timezone;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +374,85 @@ public function instantSave()
|
|||
}
|
||||
}
|
||||
|
||||
public function checkHetznerServerStatus(bool $manual = false)
|
||||
{
|
||||
try {
|
||||
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
|
||||
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
||||
|
||||
// Save status to database
|
||||
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
|
||||
|
||||
if ($manual) {
|
||||
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
|
||||
}
|
||||
|
||||
// If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state
|
||||
if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) {
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Server is reachable.');
|
||||
$this->server->settings->is_reachable = $this->isReachable = true;
|
||||
$this->server->settings->is_usable = $this->isUsable = true;
|
||||
$this->server->settings->save();
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
} else {
|
||||
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handleServerValidated($event = null)
|
||||
{
|
||||
// Check if event is for this server
|
||||
if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh server data
|
||||
$this->server->refresh();
|
||||
$this->syncData();
|
||||
|
||||
// Update validation state
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshServer');
|
||||
}
|
||||
|
||||
public function startHetznerServer()
|
||||
{
|
||||
try {
|
||||
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$hetznerService->powerOnServer($this->server->hetzner_server_id);
|
||||
|
||||
$this->hetznerServerStatus = 'starting';
|
||||
$this->server->update(['hetzner_server_status' => 'starting']);
|
||||
$this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running
|
||||
$this->dispatch('success', 'Hetzner server is starting...');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
|
@ -136,8 +137,12 @@ public function validateDockerVersion()
|
|||
} else {
|
||||
$this->docker_version = $this->server->validateDockerEngineVersion();
|
||||
if ($this->docker_version) {
|
||||
// Mark validation as complete
|
||||
$this->server->update(['is_validating' => false]);
|
||||
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshBoardingIndex');
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
$this->dispatch('success', 'Server validated, proxy is starting in a moment.');
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if (! $proxyShouldRun) {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ protected static function booted()
|
|||
$destination->delete();
|
||||
});
|
||||
$server->settings()->delete();
|
||||
$server->sslCertificates()->delete();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +165,8 @@ protected static function booted()
|
|||
'cloud_provider_token_id',
|
||||
'team_id',
|
||||
'hetzner_server_id',
|
||||
'hetzner_server_status',
|
||||
'is_validating',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
@ -896,6 +899,11 @@ public function cloudProviderToken()
|
|||
return $this->belongsTo(CloudProviderToken::class);
|
||||
}
|
||||
|
||||
public function sslCertificates()
|
||||
{
|
||||
return $this->hasMany(SslCertificate::class);
|
||||
}
|
||||
|
||||
public function muxFilename()
|
||||
{
|
||||
return 'mux_'.$this->uuid;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,20 @@ public function createServer(array $params): array
|
|||
return $response['server'] ?? [];
|
||||
}
|
||||
|
||||
public function getServer(int $serverId): array
|
||||
{
|
||||
$response = $this->request('get', "/servers/{$serverId}");
|
||||
|
||||
return $response['server'] ?? [];
|
||||
}
|
||||
|
||||
public function powerOnServer(int $serverId): array
|
||||
{
|
||||
$response = $this->request('post', "/servers/{$serverId}/actions/poweron");
|
||||
|
||||
return $response['action'] ?? [];
|
||||
}
|
||||
|
||||
public function deleteServer(int $serverId): void
|
||||
{
|
||||
$this->request('delete', "/servers/{$serverId}");
|
||||
|
|
|
|||
|
|
@ -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->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('hetzner_server_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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->boolean('is_validating')->default(false)->after('hetzner_server_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('is_validating');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -266,7 +266,7 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
|
|||
<input type="text" x-model="searchQuery"
|
||||
placeholder="Search resources (type new for create things)..." x-ref="searchInput"
|
||||
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })" :disabled="isLoadingInitialData"
|
||||
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" />
|
||||
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-base" />
|
||||
<div class="absolute inset-y-0 right-2 flex items-center gap-2 pointer-events-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-500">
|
||||
/ or ⌘K to focus
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
</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">
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep" :disabled="!$selected_token_id">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
|
@ -111,16 +110,12 @@
|
|||
</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
|
||||
Buy & Create Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div>
|
||||
<div x-data x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus() @endif">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
|
||||
</x-slot>
|
||||
|
|
@ -10,13 +10,68 @@
|
|||
<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 ">
|
||||
<div @class([
|
||||
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all cursor-pointer hover:opacity-80',
|
||||
'bg-white dark:bg-coolgray-100 dark:text-white',
|
||||
])
|
||||
@if (in_array($hetznerServerStatus, ['starting', 'initializing'])) wire:poll.5s="checkHetznerServerStatus" @endif
|
||||
wire:click="checkHetznerServerStatus(true)" title="Click to refresh status">
|
||||
<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>
|
||||
@if ($hetznerServerStatus)
|
||||
<span class="pl-1.5">
|
||||
@if (in_array($hetznerServerStatus, ['starting', 'initializing']))
|
||||
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-yellow-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' => $hetznerServerStatus === 'running',
|
||||
'text-red-500' => $hetznerServerStatus === 'off',
|
||||
])>
|
||||
{{ ucfirst($hetznerServerStatus) }}
|
||||
</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="pl-1.5">
|
||||
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-yellow-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>
|
||||
@if ($server->cloudProviderToken && !$server->isFunctional() && $hetznerServerStatus === 'off')
|
||||
<x-forms.button wire:click.prevent='startHetznerServer' 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-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">
|
||||
<svg class="inline animate-spin h-3 w-3" 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>Validating...</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->id === 0)
|
||||
|
|
@ -26,7 +81,8 @@ class="flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded bg-white
|
|||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Save" canGate="update" :canResource="$server" />
|
||||
@else
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$server">Save</x-forms.button>
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$server"
|
||||
:disabled="$isValidating">Save</x-forms.button>
|
||||
@if ($server->isFunctional())
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validate & configure</x-slot:title>
|
||||
|
|
@ -46,7 +102,21 @@ class="flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded bg-white
|
|||
@else
|
||||
You can't use this server until it is validated.
|
||||
@endif
|
||||
@if ((!$isReachable || !$isUsable) && $server->id !== 0)
|
||||
@if ($isValidating)
|
||||
<div x-data="{ slideOverOpen: true }">
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validation in Progress</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:server.validate-and-install :server="$server" />
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
</div>
|
||||
@endif
|
||||
@if (
|
||||
(!$isReachable || !$isUsable) &&
|
||||
$server->id !== 0 &&
|
||||
!$isValidating &&
|
||||
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']))
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validate & configure</x-slot:title>
|
||||
<x-slot:content>
|
||||
|
|
@ -79,12 +149,15 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
|
|||
@endif
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
<div class="flex flex-col gap-2 w-full lg:flex-row">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="name" label="Name" required />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="description" label="Description" />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="name" label="Name" required
|
||||
:disabled="$isValidating" />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="description" label="Description"
|
||||
:disabled="$isValidating" />
|
||||
@if (!$isSwarmWorker && !$isBuildServer)
|
||||
<x-forms.input canGate="update" :canResource="$server" placeholder="https://example.com"
|
||||
id="wildcardDomain" label="Wildcard Domain"
|
||||
helper='A wildcard domain allows you to receive a randomly generated domain for your new applications. <br><br>For instance, if you set "https://example.com" as your wildcard domain, your applications will receive domains like "https://randomId.example.com".' />
|
||||
helper='A wildcard domain allows you to receive a randomly generated domain for your new applications. <br><br>For instance, if you set "https://example.com" as your wildcard domain, your applications will receive domains like "https://randomId.example.com".'
|
||||
:disabled="$isValidating" />
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
|
@ -92,11 +165,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
|
|||
<x-forms.input canGate="update" :canResource="$server" type="password" id="ip"
|
||||
label="IP Address/Domain"
|
||||
helper="An IP Address (127.0.0.1) or domain (example.com). Make sure there is no protocol like http(s):// so you provide a FQDN not a URL."
|
||||
required />
|
||||
required :disabled="$isValidating" />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="user" label="User" required />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="user" label="User" required
|
||||
:disabled="$isValidating" />
|
||||
<x-forms.input canGate="update" :canResource="$server" type="number" id="port"
|
||||
label="Port" required />
|
||||
label="Port" required :disabled="$isValidating" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
|
|
@ -106,45 +180,63 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
|
|||
helper="Server's timezone. This is used for backups, cron jobs, etc." />
|
||||
</div>
|
||||
@can('update', $server)
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '{{ $serverTimezone ?: '' }}',
|
||||
timezones: @js($this->timezones),
|
||||
placeholder: '{{ $serverTimezone ? 'Search timezone...' : 'Select Server Timezone' }}',
|
||||
init() {
|
||||
this.$watch('search', value => {
|
||||
if (value === '') {
|
||||
this.open = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
}">
|
||||
@if ($isValidating)
|
||||
<div class="relative">
|
||||
<div class="inline-flex relative items-center w-64">
|
||||
<input autocomplete="off"
|
||||
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
|
||||
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" x-model="search"
|
||||
@focus="open = true" @click.away="open = false" @input="open = true"
|
||||
class="w-full input" :placeholder="placeholder" wire:model="serverTimezone">
|
||||
<svg class="absolute right-0 mr-2 w-4 h-4" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
@click="open = true">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div x-show="open"
|
||||
class="overflow-auto overflow-x-hidden absolute z-50 mt-1 w-64 max-h-60 bg-white rounded-md border shadow-lg dark:bg-coolgray-100 dark:border-coolgray-200 scrollbar">
|
||||
<template
|
||||
x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))"
|
||||
:key="timezone">
|
||||
<div @click="search = timezone; open = false; $wire.set('serverTimezone', timezone); $wire.submit()"
|
||||
class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-coolgray-300 dark:text-gray-200"
|
||||
x-text="timezone"></div>
|
||||
</template>
|
||||
</div>
|
||||
@else
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '{{ $serverTimezone ?: '' }}',
|
||||
timezones: @js($this->timezones),
|
||||
placeholder: '{{ $serverTimezone ? 'Search timezone...' : 'Select Server Timezone' }}',
|
||||
init() {
|
||||
this.$watch('search', value => {
|
||||
if (value === '') {
|
||||
this.open = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
}">
|
||||
<div class="relative">
|
||||
<div class="inline-flex relative items-center w-64">
|
||||
<input autocomplete="off"
|
||||
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
|
||||
wire:dirty.class="dark:focus:ring-warning dark:ring-warning"
|
||||
x-model="search" @focus="open = true" @click.away="open = false"
|
||||
@input="open = true" class="w-full input" :placeholder="placeholder"
|
||||
wire:model="serverTimezone">
|
||||
<svg class="absolute right-0 mr-2 w-4 h-4" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" @click="open = true">
|
||||
<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>
|
||||
</div>
|
||||
<div x-show="open"
|
||||
class="overflow-auto overflow-x-hidden absolute z-50 mt-1 w-64 max-h-60 bg-white rounded-md border shadow-lg dark:bg-coolgray-100 dark:border-coolgray-200 scrollbar">
|
||||
<template
|
||||
x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))"
|
||||
:key="timezone">
|
||||
<div @click="search = timezone; open = false; $wire.set('serverTimezone', timezone); $wire.submit()"
|
||||
class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-coolgray-300 dark:text-gray-200"
|
||||
x-text="timezone"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="relative">
|
||||
<div class="inline-flex relative items-center w-64">
|
||||
|
|
@ -171,7 +263,7 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
label="Use it as a build server?" />
|
||||
@else
|
||||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
|
||||
id="isBuildServer" label="Use it as a build server?" />
|
||||
id="isBuildServer" label="Use it as a build server?" :disabled="$isValidating" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
@ -191,7 +283,7 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
|
||||
type="checkbox" id="isSwarmManager"
|
||||
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
|
||||
label="Is it a Swarm Manager?" />
|
||||
label="Is it a Swarm Manager?" :disabled="$isValidating" />
|
||||
@endif
|
||||
|
||||
@if ($server->settings->is_swarm_manager)
|
||||
|
|
@ -202,7 +294,7 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
|
||||
type="checkbox" id="isSwarmWorker"
|
||||
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
|
||||
label="Is it a Swarm Worker?" />
|
||||
label="Is it a Swarm Worker?" :disabled="$isValidating" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
Loading…
Reference in a new issue