@@ -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." />
@can('update', $server)
-
+ @if ($isValidating)
+ @endif
@else
@@ -171,7 +263,7 @@ class="w-full input opacity-50 cursor-not-allowed"
label="Use it as a build server?" />
@else
+ id="isBuildServer" label="Use it as a build server?" :disabled="$isValidating" />
@endif
@@ -191,7 +283,7 @@ class="w-full input opacity-50 cursor-not-allowed"
+ 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"
+ label="Is it a Swarm Worker?" :disabled="$isValidating" />
@endif
@endif
From f4e5c195fe8eb5436395e4ee030729f3c31f73c8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 9 Oct 2025 17:00:05 +0200
Subject: [PATCH 15/52] refactor: replace direct SslCertificate queries with
server relationship methods for consistency
---
app/Actions/Database/StartDragonfly.php | 4 ++--
app/Actions/Database/StartKeydb.php | 4 ++--
app/Actions/Database/StartMariadb.php | 4 ++--
app/Actions/Database/StartMongodb.php | 4 ++--
app/Actions/Database/StartMysql.php | 4 ++--
app/Actions/Database/StartPostgresql.php | 4 ++--
app/Actions/Database/StartRedis.php | 4 ++--
app/Actions/Server/InstallDocker.php | 3 +--
app/Jobs/RegenerateSslCertJob.php | 2 +-
app/Livewire/Project/Database/Dragonfly/General.php | 5 ++---
app/Livewire/Project/Database/Keydb/General.php | 3 +--
app/Livewire/Project/Database/Mariadb/General.php | 3 +--
app/Livewire/Project/Database/Mongodb/General.php | 3 +--
app/Livewire/Project/Database/Mysql/General.php | 3 +--
app/Livewire/Project/Database/Postgresql/General.php | 3 +--
app/Livewire/Project/Database/Redis/General.php | 3 +--
app/Livewire/Server/CaCertificate/Show.php | 2 +-
app/Models/Server.php | 2 +-
database/seeders/CaSslCertSeeder.php | 3 +--
19 files changed, 27 insertions(+), 36 deletions(-)
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index 38ad99d2e..0db73ba07 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index 59bcd4123..2908eb9c1 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 13dba4b43..11b424ff8 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 870b5b7e5..0bf6ab253 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 5d5611e07..bf81d01ab 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 38d46b3c1..6cdf32e27 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 68a1f3fe3..c040ec391 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 5410b1cbd..10589c8b9 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -4,7 +4,6 @@
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -20,7 +19,7 @@ public function handle(Server $server)
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing:
documentation.');
}
- if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
+ if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
$serverCert = SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $server->id,
diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php
index cf598c75c..c0284e1ee 100644
--- a/app/Jobs/RegenerateSslCertJob.php
+++ b/app/Jobs/RegenerateSslCertJob.php
@@ -45,7 +45,7 @@ public function handle()
$query->cursor()->each(function ($certificate) use ($regenerated) {
try {
- $caCert = SslCertificate::where('server_id', $certificate->server_id)
+ $caCert = $certificate->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index fabbc7cb4..4b93e69d7 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -249,13 +248,13 @@ public function regenerateSslCertificate()
$server = $this->database->destination->server;
- $caCert = SslCertificate::where('server_id', $server->id)
+ $caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
- $caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index 7502d001d..91952533f 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -255,7 +254,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)
+ $caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index c82c4538f..3ec6a9954 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -207,7 +206,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index 4fbc45437..be52cfa7a 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -215,7 +214,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index ada1b3a2c..e6b0ead24 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -215,7 +214,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 2d37620b9..06c16a658 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -169,7 +168,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index 1eb4f5c8d..4cb93e836 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@@ -209,7 +208,7 @@ public function regenerateSslCertificate()
return;
}
- $caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
+ $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->commonName,
diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php
index 039b5f71d..c929d9b3d 100644
--- a/app/Livewire/Server/CaCertificate/Show.php
+++ b/app/Livewire/Server/CaCertificate/Show.php
@@ -39,7 +39,7 @@ public function mount(string $server_uuid)
public function loadCaCertificate()
{
- $this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
+ $this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if ($this->caCertificate) {
$this->certificateContent = $this->caCertificate->ssl_certificate;
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 92111f0f0..e39526949 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1342,7 +1342,7 @@ public function generateCaCertificate()
isCaCertificate: true,
validityDays: 10 * 365
);
- $caCertificate = SslCertificate::where('server_id', $this->id)->where('is_ca_certificate', true)->first();
+ $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php
index 09f6cc984..1b71a5e43 100644
--- a/database/seeders/CaSslCertSeeder.php
+++ b/database/seeders/CaSslCertSeeder.php
@@ -4,7 +4,6 @@
use App\Helpers\SslHelper;
use App\Models\Server;
-use App\Models\SslCertificate;
use Illuminate\Database\Seeder;
class CaSslCertSeeder extends Seeder
@@ -13,7 +12,7 @@ public function run()
{
Server::chunk(200, function ($servers) {
foreach ($servers as $server) {
- $existingCaCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
+ $existingCaCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $existingCaCert) {
$caCert = SslHelper::generateSslCertificate(
From 77dcabe51cc616a36689936a9cdc6d5510c2a6ba Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 09:35:49 +0200
Subject: [PATCH 16/52] fix: refresh server data before showing notification to
ensure accurate proxy status
---
app/Livewire/Server/Navbar.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index beefed12a..d8a3c7acf 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -118,6 +118,7 @@ public function checkProxyStatus()
public function showNotification()
{
+ $this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$forceStop = $this->server->proxy->force_stop ?? false;
From bbaef0360244717fcbcf7f1a4d1ab04a8214fdc9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 09:35:54 +0200
Subject: [PATCH 17/52] fix: update Hetzner server status handling to prevent
unnecessary database updates and improve UI responsiveness
---
app/Livewire/Server/Show.php | 12 +-
.../views/livewire/server/show.blade.php | 127 +++++++++++-------
2 files changed, 82 insertions(+), 57 deletions(-)
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index e758f0f54..1d82ead9e 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -388,11 +388,13 @@ public function checkHetznerServerStatus(bool $manual = false)
$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'));
+ // Save status to database without triggering model events
+ if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) {
+ $this->server->hetzner_server_status = $this->hetznerServerStatus;
+ $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
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php
index f47f24b20..5f99ad97f 100644
--- a/resources/views/livewire/server/show.blade.php
+++ b/resources/views/livewire/server/show.blade.php
@@ -10,19 +10,38 @@
General
@if ($server->hetzner_server_id)
-
-
- @if ($hetznerServerStatus)
-
- @if (in_array($hetznerServerStatus, ['starting', 'initializing']))
+
+
+
+ @if ($hetznerServerStatus)
+
+ @if (in_array($hetznerServerStatus, ['starting', 'initializing']))
+
+ @endif
+ $hetznerServerStatus === 'running',
+ 'text-red-500' => $hetznerServerStatus === 'off',
+ ])>
+ {{ ucfirst($hetznerServerStatus) }}
+
+
+ @else
+
- @endif
- $hetznerServerStatus === 'running',
- 'text-red-500' => $hetznerServerStatus === 'off',
- ])>
- {{ ucfirst($hetznerServerStatus) }}
+ Checking status...
-
- @else
-
-
- Checking status...
-
- @endif
+ @endif
+
+
+
@if ($server->cloudProviderToken && !$server->isFunctional() && $hetznerServerStatus === 'off')
@if ($server->isSentinelLive())
- Save
- Restart
+ Save
+ Restart
Sentinel Logs
- Logs
+ Logs
@else
- Save
- Sync
+ Save
+ Sync
Sentinel Logs
- Logs
+ Logs
@endif
@@ -345,14 +365,14 @@ class="w-full input opacity-50 cursor-not-allowed"
+ label="Enable Sentinel" :disabled="$isValidating" />
@if ($server->isSentinelEnabled())
@if (isDev())
+ label="Enable Sentinel (with debug)" instantSave :disabled="$isValidating" />
@endif
+ id="isMetricsEnabled" label="Enable Metrics" :disabled="$isValidating" />
@else
@if (isDev())
isSentinelEnabled())
+ label="Sentinel token" required helper="Token for Sentinel." :disabled="$isValidating" />
Regenerate
+ wire:click="regenerateSentinelToken" :disabled="$isValidating">Regenerate
+ helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance."
+ :disabled="$isValidating" />
+ helper="Interval used for gathering metrics. Lower values result in more disk space usage."
+ :disabled="$isValidating" />
+ helper="Number of days to retain metrics data for." :disabled="$isValidating" />
+ helper="Interval at which metrics data is sent to the collector."
+ :disabled="$isValidating" />
@endif
From 513f6b54f7d0f41c7a62aff4b2059e489d58786b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 09:35:58 +0200
Subject: [PATCH 18/52] feat: implement Hetzner deletion failure notification
system with email and messaging support
---
app/Actions/Server/DeleteServer.php | 6 +
.../Server/HetznerDeletionFailed.php | 71 +++++++++++
app/Traits/HasNotificationSettings.php | 1 +
.../emails/hetzner-deletion-failed.blade.php | 13 ++
.../HetznerDeletionFailedNotificationTest.php | 114 ++++++++++++++++++
5 files changed, 205 insertions(+)
create mode 100644 app/Notifications/Server/HetznerDeletionFailed.php
create mode 100644 resources/views/emails/hetzner-deletion-failed.blade.php
create mode 100644 tests/Unit/HetznerDeletionFailedNotificationTest.php
diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php
index 1b4302911..45ec68abc 100644
--- a/app/Actions/Server/DeleteServer.php
+++ b/app/Actions/Server/DeleteServer.php
@@ -4,6 +4,8 @@
use App\Models\CloudProviderToken;
use App\Models\Server;
+use App\Models\Team;
+use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\HetznerService;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -92,6 +94,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
+
+ // Notify the team about the failure
+ $team = Team::find($teamId);
+ $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
}
diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php
new file mode 100644
index 000000000..de894331b
--- /dev/null
+++ b/app/Notifications/Server/HetznerDeletionFailed.php
@@ -0,0 +1,71 @@
+onQueue('high');
+ }
+
+ public function via(object $notifiable): array
+ {
+ ray('hello');
+ ray($notifiable);
+
+ return $notifiable->getEnabledChannels('hetzner_deletion_failed');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}");
+ $mail->view('emails.hetzner-deletion-failed', [
+ 'hetznerServerId' => $this->hetznerServerId,
+ 'errorMessage' => $this->errorMessage,
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ return new DiscordMessage(
+ title: ':cross_mark: Coolify: [ACTION REQUIRED] Failed to delete Hetzner server',
+ description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\n**Error:** {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
+ color: DiscordMessage::errorColor(),
+ );
+ }
+
+ public function toTelegram(): array
+ {
+ return [
+ 'message' => "Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ return new PushoverMessage(
+ title: 'Hetzner Server Deletion Failed',
+ level: 'error',
+ message: "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check and manually delete if needed.",
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ return new SlackMessage(
+ title: 'Coolify: [ACTION REQUIRED] Hetzner Server Deletion Failed',
+ description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.",
+ color: SlackMessage::errorColor()
+ );
+ }
+}
diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php
index 236e4d97c..2c1b4a68c 100644
--- a/app/Traits/HasNotificationSettings.php
+++ b/app/Traits/HasNotificationSettings.php
@@ -17,6 +17,7 @@ trait HasNotificationSettings
'general',
'test',
'ssl_certificate_renewal',
+ 'hetzner_deletion_failure',
];
/**
diff --git a/resources/views/emails/hetzner-deletion-failed.blade.php b/resources/views/emails/hetzner-deletion-failed.blade.php
new file mode 100644
index 000000000..32995b11a
--- /dev/null
+++ b/resources/views/emails/hetzner-deletion-failed.blade.php
@@ -0,0 +1,13 @@
+
+Failed to delete Hetzner server #{{ $hetznerServerId }} from Hetzner Cloud.
+
+Error:
+
+{{ $errorMessage }}
+
+
+The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.
+
+Please check your Hetzner Cloud console and manually delete the server if needed to avoid ongoing charges.
+
+
diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php
new file mode 100644
index 000000000..6cb9f0bb3
--- /dev/null
+++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php
@@ -0,0 +1,114 @@
+toBeInstanceOf(HetznerDeletionFailed::class)
+ ->and($notification->hetznerServerId)->toBe(12345)
+ ->and($notification->teamId)->toBe(1)
+ ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found');
+});
+
+it('uses hetzner_deletion_failed event for channels', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 12345,
+ teamId: 1,
+ errorMessage: 'Test error'
+ );
+
+ $mockNotifiable = Mockery::mock();
+ $mockNotifiable->shouldReceive('getEnabledChannels')
+ ->with('hetzner_deletion_failed')
+ ->once()
+ ->andReturn([]);
+
+ $channels = $notification->via($mockNotifiable);
+
+ expect($channels)->toBeArray();
+});
+
+it('generates correct mail content', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 67890,
+ teamId: 1,
+ errorMessage: 'Connection timeout'
+ );
+
+ $mail = $notification->toMail();
+
+ expect($mail->subject)->toBe('Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #67890')
+ ->and($mail->view)->toBe('emails.hetzner-deletion-failed')
+ ->and($mail->viewData['hetznerServerId'])->toBe(67890)
+ ->and($mail->viewData['errorMessage'])->toBe('Connection timeout');
+});
+
+it('generates correct discord content', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 11111,
+ teamId: 1,
+ errorMessage: 'API rate limit exceeded'
+ );
+
+ $discord = $notification->toDiscord();
+
+ expect($discord->title)->toContain('Failed to delete Hetzner server')
+ ->and($discord->description)->toContain('#11111')
+ ->and($discord->description)->toContain('API rate limit exceeded')
+ ->and($discord->description)->toContain('may still exist in your Hetzner Cloud account');
+});
+
+it('generates correct telegram content', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 22222,
+ teamId: 1,
+ errorMessage: 'Invalid token'
+ );
+
+ $telegram = $notification->toTelegram();
+
+ expect($telegram)->toBeArray()
+ ->and($telegram)->toHaveKey('message')
+ ->and($telegram['message'])->toContain('#22222')
+ ->and($telegram['message'])->toContain('Invalid token')
+ ->and($telegram['message'])->toContain('ACTION REQUIRED');
+});
+
+it('generates correct pushover content', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 33333,
+ teamId: 1,
+ errorMessage: 'Network error'
+ );
+
+ $pushover = $notification->toPushover();
+
+ expect($pushover->title)->toBe('Hetzner Server Deletion Failed')
+ ->and($pushover->level)->toBe('error')
+ ->and($pushover->message)->toContain('#33333')
+ ->and($pushover->message)->toContain('Network error');
+});
+
+it('generates correct slack content', function () {
+ $notification = new HetznerDeletionFailed(
+ hetznerServerId: 44444,
+ teamId: 1,
+ errorMessage: 'Permission denied'
+ );
+
+ $slack = $notification->toSlack();
+
+ expect($slack->title)->toContain('Hetzner Server Deletion Failed')
+ ->and($slack->description)->toContain('#44444')
+ ->and($slack->description)->toContain('Permission denied');
+});
From 32b53d756a4faa4d8bf6eda4b5fa733f0f319416 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 09:37:05 +0200
Subject: [PATCH 19/52] feat: enhance proxy status notifications with detailed
messages for various states
---
app/Livewire/Server/Navbar.php | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index d8a3c7acf..32799b83d 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -125,11 +125,25 @@ public function showNotification()
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
+ $this->dispatch('success', 'Proxy is running.');
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
break;
+ case 'exited':
+ $this->dispatch('info', 'Proxy has exited.');
+ break;
+ case 'stopping':
+ $this->dispatch('info', 'Proxy is stopping.');
+ break;
+ case 'starting':
+ $this->dispatch('info', 'Proxy is starting.');
+ break;
+ case 'unknown':
+ $this->dispatch('info', 'Proxy status is unknown.');
+ break;
default:
+ $this->dispatch('info', 'Proxy status updated.');
break;
}
From 00cb06150e0ab4d639a2b677d4f6ac9b6ae33fa8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 10:12:59 +0200
Subject: [PATCH 20/52] fix: improve error logging and handling in
ServerConnectionCheckJob for Hetzner server status
---
app/Jobs/ServerConnectionCheckJob.php | 30 +++++++++++++--------------
1 file changed, 14 insertions(+), 16 deletions(-)
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 5cb10146d..9dbce4bfe 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -91,6 +91,11 @@ public function handle()
]);
} catch (\Throwable $e) {
+
+ Log::error('ServerConnectionCheckJob failed', [
+ 'error' => $e->getMessage(),
+ 'server_id' => $this->server->id,
+ ]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@@ -107,28 +112,21 @@ private function checkHetznerStatus(): void
$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(),
]);
}
+ if ($this->server->hetzner_server_status !== $status) {
+ $this->server->update(['hetzner_server_status' => $status]);
+ $this->server->hetzner_server_status = $status;
+ if ($status === 'off') {
+ ray('Server is powered off, marking as unreachable');
+ throw new \Exception('Server is powered off');
+ }
+ }
+
}
private function checkConnection(): bool
From 2bca22082cd9096833ec5da047459e975f0a20c5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 10:13:08 +0200
Subject: [PATCH 21/52] feat: add retry functionality for server validation
process
---
app/Livewire/Server/ValidateAndInstall.php | 13 +++++++++++++
.../livewire/server/validate-and-install.blade.php | 3 +++
2 files changed, 16 insertions(+)
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 39b473660..bbd7f3dd9 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -64,6 +64,19 @@ public function startValidatingAfterAsking()
$this->init();
}
+ public function retry()
+ {
+ $this->authorize('update', $this->server);
+ $this->uptime = null;
+ $this->supported_os_type = null;
+ $this->docker_installed = null;
+ $this->docker_compose_installed = null;
+ $this->docker_version = null;
+ $this->error = null;
+ $this->number_of_tries = 0;
+ $this->init();
+ }
+
public function validateConnection()
{
$this->authorize('update', $this->server);
diff --git a/resources/views/livewire/server/validate-and-install.blade.php b/resources/views/livewire/server/validate-and-install.blade.php
index 814f81652..572da85e8 100644
--- a/resources/views/livewire/server/validate-and-install.blade.php
+++ b/resources/views/livewire/server/validate-and-install.blade.php
@@ -123,6 +123,9 @@
@isset($error)
{!! $error !!}
+
+ Retry Validation
+
@endisset
@endif
From 5362952e2aa6bcff1984fd078e0cd37f5dbfeae6 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 10:13:14 +0200
Subject: [PATCH 22/52] fix: correct dispatch logic for Hetzner server status
refresh in checkHetznerServerStatus method
---
app/Livewire/Server/Show.php | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 1d82ead9e..4626a9135 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -392,9 +392,9 @@ public function checkHetznerServerStatus(bool $manual = false)
if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) {
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
- if ($manual) {
- $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
- }
+ }
+ 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
From 9c152fd40a75f6b354754160c9a2c3cea9b336bd Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 10:41:37 +0200
Subject: [PATCH 23/52] feat: add retry mechanism with rate limit handling to
API requests in HetznerService
---
app/Services/HetznerService.php | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php
index 80afa02b9..95cd1e8e8 100644
--- a/app/Services/HetznerService.php
+++ b/app/Services/HetznerService.php
@@ -19,7 +19,31 @@ private function request(string $method, string $endpoint, array $data = [])
{
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->token,
- ])->timeout(30)->{$method}($this->baseUrl.$endpoint, $data);
+ ])
+ ->timeout(30)
+ ->retry(3, function (int $attempt, \Exception $exception) {
+ // Handle rate limiting (429 Too Many Requests)
+ if ($exception instanceof \Illuminate\Http\Client\RequestException) {
+ $response = $exception->response;
+
+ if ($response && $response->status() === 429) {
+ // Get rate limit reset timestamp from headers
+ $resetTime = $response->header('RateLimit-Reset');
+
+ if ($resetTime) {
+ // Calculate wait time until rate limit resets
+ $waitSeconds = max(0, $resetTime - time());
+
+ // Cap wait time at 60 seconds for safety
+ return min($waitSeconds, 60) * 1000;
+ }
+ }
+ }
+
+ // Exponential backoff for other retriable errors: 100ms, 200ms, 400ms
+ return $attempt * 100;
+ })
+ ->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
From bd88bbca5ba4b7707f0370e2fd98ce7517809c5d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 10:41:58 +0200
Subject: [PATCH 24/52] fix: streamline proxy status handling in StartProxy and
Navbar components
---
app/Actions/Proxy/StartProxy.php | 9 ++++++---
app/Livewire/Server/Navbar.php | 1 -
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php
index ecfb13d0b..8671a5f27 100644
--- a/app/Actions/Proxy/StartProxy.php
+++ b/app/Actions/Proxy/StartProxy.php
@@ -19,6 +19,11 @@ public function handle(Server $server, bool $async = true, bool $force = false):
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
return 'OK';
}
+ $server->proxy->set('status', 'starting');
+ $server->save();
+ $server->refresh();
+ ProxyStatusChangedUI::dispatch($server->team_id);
+
$commands = collect([]);
$proxy_path = $server->proxyPath();
$configuration = GetProxyConfiguration::run($server);
@@ -64,14 +69,12 @@ public function handle(Server $server, bool $async = true, bool $force = false):
]);
$commands = $commands->merge(connectProxyToNetworks($server));
}
- $server->proxy->set('status', 'starting');
- $server->save();
- ProxyStatusChangedUI::dispatch($server->team_id);
if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
} else {
instant_remote_process($commands, $server);
+
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index 32799b83d..6baa54672 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -120,7 +120,6 @@ public function showNotification()
{
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
- $forceStop = $this->server->proxy->force_stop ?? false;
switch ($this->proxyStatus) {
case 'running':
From 2e21d875afd6edb3c106eee6cf2823d6fc52c610 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 11:03:13 +0200
Subject: [PATCH 25/52] feat: implement ValidHostname validation rule and
integrate it into server creation process
---
app/Livewire/Server/New/ByHetzner.php | 8 +-
app/Rules/ValidHostname.php | 114 ++++++++++++++++++
.../views/livewire/server/create.blade.php | 25 ++--
tests/Unit/ValidHostnameTest.php | 74 ++++++++++++
4 files changed, 209 insertions(+), 12 deletions(-)
create mode 100644 app/Rules/ValidHostname.php
create mode 100644 tests/Unit/ValidHostnameTest.php
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index c5559576d..6f7e915c9 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -7,6 +7,7 @@
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
+use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
@@ -104,7 +105,7 @@ protected function rules(): array
if ($this->current_step === 2) {
$rules = array_merge($rules, [
- 'server_name' => 'required|string|max:255',
+ 'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_location' => 'required|string',
'selected_image' => 'required|integer',
'selected_server_type' => 'required|string',
@@ -361,9 +362,12 @@ private function createHetznerServer(string $token): array
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
+ // Normalize server name to lowercase for RFC 1123 compliance
+ $normalizedServerName = strtolower(trim($this->server_name));
+
// Prepare server creation parameters
$params = [
- 'name' => $this->server_name,
+ 'name' => $normalizedServerName,
'server_type' => $this->selected_server_type,
'image' => $this->selected_image,
'location' => $this->selected_location,
diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php
new file mode 100644
index 000000000..b6b2b8d32
--- /dev/null
+++ b/app/Rules/ValidHostname.php
@@ -0,0 +1,114 @@
+ 253) {
+ $fail('The :attribute must not exceed 253 characters.');
+
+ return;
+ }
+
+ // Check for dangerous shell metacharacters
+ $dangerousChars = [
+ ';', '|', '&', '$', '`', '(', ')', '{', '}',
+ '<', '>', '\n', '\r', '\0', '"', "'", '\\',
+ '!', '*', '?', '[', ']', '~', '^', ':', '#',
+ '@', '%', '=', '+', ',', ' ',
+ ];
+
+ foreach ($dangerousChars as $char) {
+ if (str_contains($hostname, $char)) {
+ try {
+ $logData = [
+ 'hostname' => $hostname,
+ 'character' => $char,
+ ];
+
+ if (function_exists('request') && app()->has('request')) {
+ $logData['ip'] = request()->ip();
+ }
+
+ if (function_exists('auth') && app()->has('auth')) {
+ $logData['user_id'] = auth()->id();
+ }
+
+ Log::warning('Hostname validation failed - dangerous character', $logData);
+ } catch (\Throwable $e) {
+ // Ignore errors when facades are not available (e.g., in unit tests)
+ }
+
+ $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
+
+ return;
+ }
+ }
+
+ // Additional validation: hostname should not start or end with a dot
+ if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
+ $fail('The :attribute cannot start or end with a dot.');
+
+ return;
+ }
+
+ // Check for consecutive dots
+ if (str_contains($hostname, '..')) {
+ $fail('The :attribute cannot contain consecutive dots.');
+
+ return;
+ }
+
+ // Split into labels (segments between dots)
+ $labels = explode('.', $hostname);
+
+ foreach ($labels as $label) {
+ // Check label length (RFC 1123: max 63 characters per label)
+ if (strlen($label) < 1 || strlen($label) > 63) {
+ $fail('The :attribute contains an invalid label. Each segment must be 1-63 characters.');
+
+ return;
+ }
+
+ // Check if label starts or ends with hyphen
+ if (str_starts_with($label, '-') || str_ends_with($label, '-')) {
+ $fail('The :attribute contains an invalid label. Labels cannot start or end with a hyphen.');
+
+ return;
+ }
+
+ // Check if label contains only valid characters (lowercase letters, digits, hyphens)
+ if (! preg_match('/^[a-z0-9-]+$/', $label)) {
+ $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
+
+ return;
+ }
+
+ // RFC 1123 allows labels to be all numeric (unlike RFC 952)
+ // So we don't need to check for all-numeric labels
+ }
+ }
+}
diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php
index 394b55a4b..0f178bd34 100644
--- a/resources/views/livewire/server/create.blade.php
+++ b/resources/views/livewire/server/create.blade.php
@@ -2,20 +2,25 @@
@can('viewAny', App\Models\CloudProviderToken::class)
-
-
-
-
-
+
+
+
diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php
new file mode 100644
index 000000000..859262c3e
--- /dev/null
+++ b/tests/Unit/ValidHostnameTest.php
@@ -0,0 +1,74 @@
+validate('server_name', $hostname, function () use (&$failCalled) {
+ $failCalled = true;
+ });
+
+ expect($failCalled)->toBeFalse();
+})->with([
+ 'simple hostname' => 'myserver',
+ 'hostname with hyphen' => 'my-server',
+ 'hostname with numbers' => 'server123',
+ 'hostname starting with number' => '123server',
+ 'all numeric hostname' => '12345',
+ 'fqdn' => 'server.example.com',
+ 'subdomain' => 'web.app.example.com',
+ 'max label length' => str_repeat('a', 63),
+ 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
+]);
+
+it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
+ $rule = new ValidHostname;
+ $failCalled = false;
+ $errorMessage = '';
+
+ $rule->validate('server_name', $hostname, function ($message) use (&$failCalled, &$errorMessage) {
+ $failCalled = true;
+ $errorMessage = $message;
+ });
+
+ expect($failCalled)->toBeTrue();
+ expect($errorMessage)->toContain($expectedError);
+})->with([
+ 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
+ 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
+ 'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
+ 'ends with dot' => ['myserver.', 'cannot start or end with a dot'],
+ 'consecutive dots' => ['my..server', 'consecutive dots'],
+ 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
+ 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
+ 'empty label' => ['my..server', 'consecutive dots'],
+ 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+ 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
+]);
+
+it('accepts empty hostname', function () {
+ $rule = new ValidHostname;
+ $failCalled = false;
+
+ $rule->validate('server_name', '', function () use (&$failCalled) {
+ $failCalled = true;
+ });
+
+ expect($failCalled)->toBeFalse();
+});
+
+it('trims whitespace before validation', function () {
+ $rule = new ValidHostname;
+ $failCalled = false;
+
+ $rule->validate('server_name', ' myserver ', function () use (&$failCalled) {
+ $failCalled = true;
+ });
+
+ expect($failCalled)->toBeFalse();
+});
From ee211526eaf9cc98fb7a9555630032193e8fb511 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 11:38:35 +0200
Subject: [PATCH 26/52] fix: improve placeholder text for token name input in
cloud provider token form
---
.../security/cloud-provider-token-form.blade.php | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php
index 3a569c08c..b82fff35a 100644
--- a/resources/views/livewire/security/cloud-provider-token-form.blade.php
+++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php
@@ -12,7 +12,7 @@
@endif
+ placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
@@ -22,13 +22,14 @@
{{-- Full page layout: horizontal, spacious --}}
-
-
+
+
-
+
From ac3af8a882de907dd8ac172c13554b0fb17a17e9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 12:17:05 +0200
Subject: [PATCH 27/52] feat: add support for selecting additional SSH keys
from Hetzner in server creation form
---
app/Livewire/Server/New/ByHetzner.php | 26 ++-
app/View/Components/Forms/Datalist.php | 26 ++-
.../views/components/forms/datalist.blade.php | 209 +++++++++++++++---
.../livewire/server/new/by-hetzner.blade.php | 16 ++
tests/Feature/HetznerServerCreationTest.php | 49 ++++
tests/Unit/DatalistComponentTest.php | 68 ++++++
tests/Unit/HetznerSshKeysTest.php | 53 +++++
7 files changed, 413 insertions(+), 34 deletions(-)
create mode 100644 tests/Feature/HetznerServerCreationTest.php
create mode 100644 tests/Unit/DatalistComponentTest.php
create mode 100644 tests/Unit/HetznerSshKeysTest.php
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 6f7e915c9..e344524ea 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -42,12 +42,16 @@ class ByHetzner extends Component
public array $serverTypes = [];
+ public array $hetznerSshKeys = [];
+
public ?string $selected_location = null;
public ?int $selected_image = null;
public ?string $selected_server_type = null;
+ public array $selectedHetznerSshKeyIds = [];
+
public string $server_name = '';
public ?int $private_key_id = null;
@@ -110,6 +114,8 @@ 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,
+ 'selectedHetznerSshKeyIds' => 'nullable|array',
+ 'selectedHetznerSshKeyIds.*' => 'integer',
]);
}
@@ -224,6 +230,14 @@ private function loadHetznerData(string $token)
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
+ // Load SSH keys from Hetzner
+ $this->hetznerSshKeys = $hetznerService->getSshKeys();
+
+ ray('Hetzner SSH Keys', [
+ 'total_count' => count($this->hetznerSshKeys),
+ 'keys' => $this->hetznerSshKeys,
+ ]);
+
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
@@ -365,6 +379,16 @@ private function createHetznerServer(string $token): array
// Normalize server name to lowercase for RFC 1123 compliance
$normalizedServerName = strtolower(trim($this->server_name));
+ // Prepare SSH keys array: Coolify key + user-selected Hetzner keys
+ $sshKeys = array_merge(
+ [$sshKeyId], // Coolify key (always included)
+ $this->selectedHetznerSshKeyIds // User-selected Hetzner keys
+ );
+
+ // Remove duplicates in case the Coolify key was also selected
+ $sshKeys = array_unique($sshKeys);
+ $sshKeys = array_values($sshKeys); // Re-index array
+
// Prepare server creation parameters
$params = [
'name' => $normalizedServerName,
@@ -372,7 +396,7 @@ private function createHetznerServer(string $token): array
'image' => $this->selected_image,
'location' => $this->selected_location,
'start_after_create' => true,
- 'ssh_keys' => [$sshKeyId],
+ 'ssh_keys' => $sshKeys,
];
ray('Server creation parameters', $params);
diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php
index 25643753d..33e264e37 100644
--- a/app/View/Components/Forms/Datalist.php
+++ b/app/View/Components/Forms/Datalist.php
@@ -4,7 +4,7 @@
use Closure;
use Illuminate\Contracts\View\View;
-use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
@@ -19,9 +19,27 @@ public function __construct(
public ?string $label = null,
public ?string $helper = null,
public bool $required = false,
- public string $defaultClass = 'input'
+ public bool $disabled = false,
+ public bool $readonly = false,
+ public bool $multiple = false,
+ public string|bool $instantSave = false,
+ public ?string $value = null,
+ public ?string $placeholder = null,
+ public bool $autofocus = false,
+ public string $defaultClass = 'input',
+ public ?string $canGate = null,
+ public mixed $canResource = null,
+ public bool $autoDisable = true,
) {
- //
+ // Handle authorization-based disabling
+ if ($this->canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ $this->instantSave = false; // Disable instant save for unauthorized users
+ }
+ }
}
/**
@@ -36,8 +54,6 @@ public function render(): View|Closure|string
$this->name = $this->id;
}
- $this->label = Str::title($this->label);
-
return view('components.forms.datalist');
}
}
diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php
index c9710b728..8ce19773e 100644
--- a/resources/views/components/forms/datalist.blade.php
+++ b/resources/views/components/forms/datalist.blade.php
@@ -1,6 +1,6 @@
-
+ @endif
+
@error($id)
{{ $message }}
@enderror
- {{-- --}}
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php
index e95c85887..b8191593d 100644
--- a/resources/views/livewire/server/new/by-hetzner.blade.php
+++ b/resources/views/livewire/server/new/by-hetzner.blade.php
@@ -99,6 +99,22 @@
+
+
+
+
diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php
new file mode 100644
index 000000000..815462ffa
--- /dev/null
+++ b/tests/Feature/HetznerServerCreationTest.php
@@ -0,0 +1,49 @@
+toBe([123])
+ ->and(count($sshKeys))->toBe(1);
+});
+
+it('validates SSH key array merging with additional Hetzner keys', function () {
+ $coolifyKeyId = 123;
+ $selectedHetznerKeys = [456, 789];
+
+ $sshKeys = array_merge(
+ [$coolifyKeyId],
+ $selectedHetznerKeys
+ );
+ $sshKeys = array_unique($sshKeys);
+ $sshKeys = array_values($sshKeys);
+
+ expect($sshKeys)->toBe([123, 456, 789])
+ ->and(count($sshKeys))->toBe(3);
+});
+
+it('validates deduplication when Coolify key is also in selected keys', function () {
+ $coolifyKeyId = 123;
+ $selectedHetznerKeys = [123, 456, 789];
+
+ $sshKeys = array_merge(
+ [$coolifyKeyId],
+ $selectedHetznerKeys
+ );
+ $sshKeys = array_unique($sshKeys);
+ $sshKeys = array_values($sshKeys);
+
+ expect($sshKeys)->toBe([123, 456, 789])
+ ->and(count($sshKeys))->toBe(3);
+});
diff --git a/tests/Unit/DatalistComponentTest.php b/tests/Unit/DatalistComponentTest.php
new file mode 100644
index 000000000..12699c30a
--- /dev/null
+++ b/tests/Unit/DatalistComponentTest.php
@@ -0,0 +1,68 @@
+required)->toBeFalse()
+ ->and($component->disabled)->toBeFalse()
+ ->and($component->readonly)->toBeFalse()
+ ->and($component->multiple)->toBeFalse()
+ ->and($component->instantSave)->toBeFalse()
+ ->and($component->defaultClass)->toBe('input');
+});
+
+it('uses provided id', function () {
+ $component = new Datalist(id: 'test-datalist');
+
+ expect($component->id)->toBe('test-datalist');
+});
+
+it('accepts multiple selection mode', function () {
+ $component = new Datalist(multiple: true);
+
+ expect($component->multiple)->toBeTrue();
+});
+
+it('accepts instantSave parameter', function () {
+ $component = new Datalist(instantSave: 'customSave');
+
+ expect($component->instantSave)->toBe('customSave');
+});
+
+it('accepts placeholder', function () {
+ $component = new Datalist(placeholder: 'Select an option...');
+
+ expect($component->placeholder)->toBe('Select an option...');
+});
+
+it('accepts autofocus', function () {
+ $component = new Datalist(autofocus: true);
+
+ expect($component->autofocus)->toBeTrue();
+});
+
+it('accepts disabled state', function () {
+ $component = new Datalist(disabled: true);
+
+ expect($component->disabled)->toBeTrue();
+});
+
+it('accepts readonly state', function () {
+ $component = new Datalist(readonly: true);
+
+ expect($component->readonly)->toBeTrue();
+});
+
+it('accepts authorization properties', function () {
+ $component = new Datalist(
+ canGate: 'update',
+ canResource: 'resource',
+ autoDisable: false
+ );
+
+ expect($component->canGate)->toBe('update')
+ ->and($component->canResource)->toBe('resource')
+ ->and($component->autoDisable)->toBeFalse();
+});
diff --git a/tests/Unit/HetznerSshKeysTest.php b/tests/Unit/HetznerSshKeysTest.php
new file mode 100644
index 000000000..06c6b06e6
--- /dev/null
+++ b/tests/Unit/HetznerSshKeysTest.php
@@ -0,0 +1,53 @@
+toBe([123, 456, 789])
+ ->and(count($sshKeys))->toBe(3);
+});
+
+it('removes duplicate SSH key IDs', function () {
+ $coolifyKeyId = 123;
+ $selectedHetznerKeys = [123, 456, 789]; // User also selected Coolify key
+
+ // Simulate the merge and deduplication logic
+ $sshKeys = array_merge(
+ [$coolifyKeyId],
+ $selectedHetznerKeys
+ );
+ $sshKeys = array_unique($sshKeys);
+ $sshKeys = array_values($sshKeys);
+
+ expect($sshKeys)->toBe([123, 456, 789])
+ ->and(count($sshKeys))->toBe(3);
+});
+
+it('works with no selected Hetzner keys', function () {
+ $coolifyKeyId = 123;
+ $selectedHetznerKeys = [];
+
+ // Simulate the merge logic
+ $sshKeys = array_merge(
+ [$coolifyKeyId],
+ $selectedHetznerKeys
+ );
+
+ expect($sshKeys)->toBe([123])
+ ->and(count($sshKeys))->toBe(1);
+});
+
+it('validates SSH key IDs are integers', function () {
+ $selectedHetznerKeys = [456, 789, 1011];
+
+ foreach ($selectedHetznerKeys as $keyId) {
+ expect($keyId)->toBeInt();
+ }
+});
From a90236ed6016ab317480e6e649061cb6df365e0a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 15:40:15 +0200
Subject: [PATCH 28/52] feat: enhance datalist component with unified input
container and improved option handling
---
.../views/components/forms/datalist.blade.php | 196 ++++++++----------
1 file changed, 90 insertions(+), 106 deletions(-)
diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php
index 8ce19773e..224ff82ab 100644
--- a/resources/views/components/forms/datalist.blade.php
+++ b/resources/views/components/forms/datalist.blade.php
@@ -19,7 +19,7 @@
selected: @entangle($id).live,
options: [],
filteredOptions: [],
-
+
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Try to parse as integer, fallback to string
@@ -39,7 +39,7 @@
this.selected = [];
}
},
-
+
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
@@ -50,7 +50,7 @@
opt.text.toLowerCase().includes(searchLower)
);
},
-
+
toggleOption(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
@@ -64,20 +64,24 @@
}
this.search = '';
this.filterOptions();
+ // Focus input after selection
+ this.$refs.searchInput.focus();
},
-
- removeOption(value) {
+
+ removeOption(value, event) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
return;
}
+ // Prevent triggering container click
+ event.stopPropagation();
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
}
},
-
+
isSelected(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
@@ -85,113 +89,93 @@
}
return this.selected.includes(value);
},
-
+
getSelectedText(value) {
const option = this.options.find(opt => opt.value == value);
return option ? option.text : value;
}
- }"
- @click.outside="open = false"
- class="relative">
+ }" @click.outside="open = false" class="relative">
- {{-- Selected Items Display --}}
-
+ {{-- Unified Input Container with Tags Inside --}}
+
+
+ {{-- Selected Tags Inside Input --}}
-
-
-
-
-
-
-
- {{-- Search Input --}}
-
merge(['class' => $defaultClass]) }}
- @required($required)
- @readonly($readonly)
- @disabled($disabled)
- wire:dirty.class="dark:ring-warning ring-warning"
- wire:loading.attr="disabled"
- @if ($autofocus) x-ref="autofocusInput" @endif
- >
-
- {{-- Dropdown Options --}}
-
-
-
-
- No options found
-
+
-
-
-
-
-
-
-
-
- {{-- Hidden datalist for options --}}
-
-
- @else
- {{-- Single Selection Mode (Standard HTML5 Datalist) --}}
- merge(['class' => $defaultClass]) }}
- @required($required)
- @readonly($readonly)
- @disabled($disabled)
- wire:dirty.class="dark:ring-warning ring-warning"
- wire:loading.attr="disabled"
- name="{{ $id }}"
- @if ($value) value="{{ $value }}" @endif
- @if ($placeholder) placeholder="{{ $placeholder }}" @endif
- @if ($attributes->whereStartsWith('wire:model')->first())
- {{ $attributes->whereStartsWith('wire:model')->first() }}
- @else
- wire:model="{{ $id }}"
- @endif
- @if ($instantSave)
- wire:change="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}"
- wire:blur="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}"
- @endif
- @if ($autofocus) x-ref="autofocusInput" @endif
- >
-
+ {{-- Search Input (Borderless, Inside Container) --}}
+
- {{ $message }}
-
- @enderror
+ class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
+ />
+
+
+{{-- Dropdown Options --}}
+
+
+
+
+ No options found
+
+
+
+
+
+
+
+
+
+
+
+{{-- Hidden datalist for options --}}
+
+
+@else
+{{-- Single Selection Mode (Standard HTML5 Datalist) --}}
+
merge(['class' => $defaultClass]) }} @required($required)
+ @readonly($readonly) @disabled($disabled) wire:dirty.class="dark:ring-warning ring-warning"
+ wire:loading.attr="disabled" name="{{ $id }}"
+ @if ($value) value="{{ $value }}" @endif
+ @if ($placeholder) placeholder="{{ $placeholder }}" @endif
+ @if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }}
+ @else
+ wire:model="{{ $id }}" @endif
+ @if ($instantSave) wire:change="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}"
+ wire:blur="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}" @endif
+ @if ($autofocus) x-ref="autofocusInput" @endif>
+
+@endif
+
+@error($id)
+
+ {{ $message }}
+
+@enderror
From b28875c6123cc4e513cd1d2d7fa43d4708b40a3b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 15:40:20 +0200
Subject: [PATCH 29/52] fix: update cloud provider token form with improved
placeholder and guidance for API token creation
---
.../cloud-provider-token-form.blade.php | 32 +++++++++++++------
.../livewire/server/new/by-hetzner.blade.php | 30 ++++++++---------
2 files changed, 36 insertions(+), 26 deletions(-)
diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php
index b82fff35a..ae25ba995 100644
--- a/resources/views/livewire/security/cloud-provider-token-form.blade.php
+++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php
@@ -14,10 +14,19 @@
-
+
-
Add Token
+ @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
+
+ Create an API token in the
{{ ucfirst($provider) }} Console → choose
+ Project → Security → API Tokens.
+
+ @endif
+
+
Validate & Add Token
@else
{{-- Full page layout: horizontal, spacious --}}
@@ -32,13 +41,18 @@
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
-
-
-
-
-
Add Token
+
+
+ @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
+
+ Create an API token in the
Hetzner Console → choose Project → Security → API
+ Tokens.
+
+ @endif
+
Validate & Add Token
@endif
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php
index b8191593d..c0067c945 100644
--- a/resources/views/livewire/server/new/by-hetzner.blade.php
+++ b/resources/views/livewire/server/new/by-hetzner.blade.php
@@ -99,22 +99,6 @@
-
-
-
-
@@ -125,7 +109,19 @@
@endforeach
-
+
+
+
Back
From 3c74620f36a77437b7117eba9b2d3785b04879d1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 15:53:17 +0200
Subject: [PATCH 30/52] feat: add modal support for creating private keys in
server creation form and enhance UI for private key selection
---
app/Livewire/Security/PrivateKey/Create.php | 10 +++++
app/Livewire/Server/New/ByHetzner.php | 13 ++++++
package-lock.json | 22 ++++------
.../livewire/server/new/by-hetzner.blade.php | 42 +++++++++++++++----
4 files changed, 65 insertions(+), 22 deletions(-)
diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php
index 0f36037ff..656e73958 100644
--- a/app/Livewire/Security/PrivateKey/Create.php
+++ b/app/Livewire/Security/PrivateKey/Create.php
@@ -21,6 +21,8 @@ class Create extends Component
public ?string $publicKey = null;
+ public bool $modal_mode = false;
+
protected function rules(): array
{
return [
@@ -77,6 +79,14 @@ public function createPrivateKey()
'team_id' => currentTeam()->id,
]);
+ // If in modal mode, dispatch event and don't redirect
+ if ($this->modal_mode) {
+ $this->dispatch('privateKeyCreated', keyId: $privateKey->id);
+ $this->dispatch('success', 'Private key created successfully.');
+
+ return;
+ }
+
return $this->redirectAfterCreation($privateKey);
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index e344524ea..0d65bc603 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -72,6 +72,7 @@ public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
+ 'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
@@ -101,6 +102,18 @@ public function handleTokenAdded($tokenId)
$this->nextStep();
}
+ public function handlePrivateKeyCreated($keyId)
+ {
+ // Refresh private keys list
+ $this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
+
+ // Auto-select the new key
+ $this->private_key_id = $keyId;
+
+ // Clear validation errors for private_key_id
+ $this->resetErrorBag('private_key_id');
+ }
+
protected function rules(): array
{
$rules = [
diff --git a/package-lock.json b/package-lock.json
index 56e48288c..210747b4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -888,8 +888,7 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
@@ -1404,7 +1403,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -1567,7 +1567,6 @@
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@@ -1582,7 +1581,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1601,7 +1599,6 @@
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.0.0"
}
@@ -2376,6 +2373,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -2452,6 +2450,7 @@
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@@ -2534,7 +2533,6 @@
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
@@ -2551,7 +2549,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2570,7 +2567,6 @@
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@@ -2585,7 +2581,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2634,7 +2629,8 @@
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.2.2",
@@ -2700,6 +2696,7 @@
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -2799,6 +2796,7 @@
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-sfc": "3.5.16",
@@ -2821,7 +2819,6 @@
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -2843,7 +2840,6 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=0.4.0"
}
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php
index c0067c945..ea1934b68 100644
--- a/resources/views/livewire/server/new/by-hetzner.blade.php
+++ b/resources/views/livewire/server/new/by-hetzner.blade.php
@@ -100,14 +100,37 @@
-
-
- @foreach ($private_keys as $key)
-
- @endforeach
-
+ @if ($private_keys->count() === 0)
+
+
+ Private Key
+
+
+
+
+ No private keys found. You need to create a private key to continue.
+
+
+
+
+
+
+ @else
+
+
+ @foreach ($private_keys as $key)
+
+ @endforeach
+
+
+ This SSH key will be automatically added to your Hetzner account and used to access the
+ server.
+
+ @endif
From 7069236714055571f6d90a56a513e368683919d7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 18:22:25 +0200
Subject: [PATCH 31/52] feat: add IPv4/IPv6 network configuration for Hetzner
server creation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add support for configuring IPv4 and IPv6 public network interfaces when creating servers through the Hetzner integration. Users can now enable or disable IPv4 and IPv6 independently, with both enabled by default.
Features:
- Added enable_ipv4 and enable_ipv6 checkboxes in the server creation form
- Both options are enabled by default as per Hetzner best practices
- IPv4 is preferred when both are enabled
- Fallback to IPv6 when only IPv6 is enabled
- Proper validation and error handling for network configuration
- Comprehensive test coverage for IP address selection logic
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/Livewire/Server/New/ByHetzner.php | 24 ++++-
.../livewire/server/new/by-hetzner.blade.php | 11 +++
tests/Feature/HetznerServerCreationTest.php | 89 ++++++++++++++++++-
3 files changed, 122 insertions(+), 2 deletions(-)
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 0d65bc603..047d84d93 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -58,6 +58,10 @@ class ByHetzner extends Component
public bool $loading_data = false;
+ public bool $enable_ipv4 = true;
+
+ public bool $enable_ipv6 = true;
+
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
@@ -129,6 +133,8 @@ protected function rules(): array
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer',
+ 'enable_ipv4' => 'required|boolean',
+ 'enable_ipv6' => 'required|boolean',
]);
}
@@ -410,6 +416,10 @@ private function createHetznerServer(string $token): array
'location' => $this->selected_location,
'start_after_create' => true,
'ssh_keys' => $sshKeys,
+ 'public_net' => [
+ 'enable_ipv4' => $this->enable_ipv4,
+ 'enable_ipv6' => $this->enable_ipv6,
+ ],
];
ray('Server creation parameters', $params);
@@ -438,10 +448,22 @@ public function submit()
// Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerToken);
+ // Determine IP address to use (prefer IPv4, fallback to IPv6)
+ $ipAddress = null;
+ if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
+ $ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
+ } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
+ $ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
+ }
+
+ if (! $ipAddress) {
+ throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
+ }
+
// Create server in Coolify database
$server = Server::create([
'name' => $this->server_name,
- 'ip' => $hetznerServer['public_net']['ipv4']['ip'],
+ 'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php
index ea1934b68..d53286c53 100644
--- a/resources/views/livewire/server/new/by-hetzner.blade.php
+++ b/resources/views/livewire/server/new/by-hetzner.blade.php
@@ -145,6 +145,17 @@ class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50
@endforeach
+
+