Merge pull request #6817 from coollabsio/hetzner-do

Hetzner integration
This commit is contained in:
Andras Bacsai 2025-10-11 19:23:19 +02:00 committed by GitHub
commit 95fe04c484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 4443 additions and 398 deletions

View file

@ -142,6 +142,29 @@ Schema::create('applications', function (Blueprint $table) {
- **Soft deletes** for audit trails
- **Activity logging** with Spatie package
### **CRITICAL: Mass Assignment Protection**
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
**Checklist for new columns:**
1. ✅ Create migration file
2. ✅ Run migration
3. ✅ **Add column to model's `$fillable` array**
4. ✅ Update any Livewire components that sync this property
5. ✅ Test that the column can be read and written
**Example:**
```php
class Server extends BaseModel
{
protected $fillable = [
'name',
'ip',
'port',
'is_validating', // ← MUST add new columns here
];
}
```
### Relationship Patterns
```php
// Typical relationship structure in Application model

View file

@ -28,6 +28,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -50,8 +57,8 @@ jobs:
platforms: linux/amd64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
aarch64:
runs-on: [self-hosted, arm64]
@ -61,6 +68,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -83,8 +97,8 @@ jobs:
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
merge-manifest:
runs-on: ubuntu-latest
@ -95,6 +109,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
@ -114,14 +135,14 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()

View file

@ -160,6 +160,7 @@ ### Database Patterns
- Use database transactions for critical operations
- Leverage query scopes for reusable queries
- Apply indexes for performance-critical queries
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
### Security Best Practices
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,102 @@
namespace App\Actions\Server;
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;
class DeleteServer
{
use AsAction;
public function handle(Server $server)
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
{
StopSentinel::run($server);
$server->forceDelete();
$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
);
}
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 deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
// 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', $teamId)
->where('provider', 'hetzner')
->first();
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'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' => $hetznerServerId,
'team_id' => $teamId,
]);
// Notify the team about the failure
$team = Team::find($teamId);
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
}

View file

@ -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: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
}
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,

View file

@ -13,6 +13,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -58,6 +59,15 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in cleaning stucked resources: {$e->getMessage()}\n";
}
try {
$servers = Server::onlyTrashed()->get();
foreach ($servers as $server) {
echo "Force deleting stuck server: {$server->name}\n";
$server->forceDelete();
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck servers: {$e->getMessage()}\n";
}
try {
$applicationsDeploymentQueue = ApplicationDeploymentQueue::get();
foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) {
@ -427,5 +437,18 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in ServiceDatabases: {$e->getMessage()}\n";
}
try {
$orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) {
$query->select('id')->from('servers');
})->get();
foreach ($orphanedCerts as $cert) {
echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n";
$cert->delete();
}
} catch (\Throwable $e) {
echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n";
}
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Livewire\GlobalSearch;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class ClearGlobalSearchCache extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'search:clear {--team= : Clear cache for specific team ID} {--all : Clear cache for all teams}';
/**
* The console command description.
*/
protected $description = 'Clear the global search cache for testing or manual refresh';
/**
* Execute the console command.
*/
public function handle(): int
{
if ($this->option('all')) {
return $this->clearAllTeamsCache();
}
if ($teamId = $this->option('team')) {
return $this->clearTeamCache($teamId);
}
// If no options provided, clear cache for current user's team
if (! auth()->check()) {
$this->error('No authenticated user found. Use --team=ID or --all option.');
return Command::FAILURE;
}
$teamId = auth()->user()->currentTeam()->id;
return $this->clearTeamCache($teamId);
}
private function clearTeamCache(int $teamId): int
{
$team = Team::find($teamId);
if (! $team) {
$this->error("Team with ID {$teamId} not found.");
return Command::FAILURE;
}
GlobalSearch::clearTeamCache($teamId);
$this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})");
return Command::SUCCESS;
}
private function clearAllTeamsCache(): int
{
$teams = Team::all();
if ($teams->isEmpty()) {
$this->warn('No teams found.');
return Command::SUCCESS;
}
$count = 0;
foreach ($teams as $team) {
GlobalSearch::clearTeamCache($team->id);
$count++;
}
$this->info("✓ Cleared global search cache for {$count} team(s)");
return Command::SUCCESS;
}
}

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

View file

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

View file

@ -484,6 +484,10 @@ private function deploy_simple_dockerfile()
);
$this->generate_image_names();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
@ -1432,9 +1436,9 @@ private function save_buildtime_environment_variables()
'hidden' => true,
],
);
} elseif ($this->build_pack === 'dockercompose') {
// For Docker Compose, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in docker-compose commands
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
$this->execute_remote_command(
@ -3196,7 +3200,7 @@ private function add_build_env_variables_to_dockerfile()
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info');
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),

View file

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

View file

@ -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();
@ -86,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,
@ -95,6 +105,30 @@ 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;
} 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
{
try {

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

View file

@ -617,7 +617,14 @@ private function loadSearchableItems()
'type' => 'navigation',
'description' => 'Manage private keys and API tokens',
'link' => route('security.private-key.index'),
'search_text' => 'security private keys ssh api tokens',
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
],
[
'name' => 'Cloud-Init Scripts',
'type' => 'navigation',
'description' => 'Manage reusable cloud-init scripts',
'link' => route('security.cloud-init-scripts'),
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
],
[
'name' => 'Sources',

View file

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

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -0,0 +1,101 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScriptForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = true;
public ?int $scriptId = null;
public string $name = '';
public string $script = '';
public function mount(?int $scriptId = null)
{
if ($scriptId) {
$this->scriptId = $scriptId;
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('update', $cloudInitScript);
$this->name = $cloudInitScript->name;
$this->script = $cloudInitScript->script;
} else {
$this->authorize('create', CloudInitScript::class);
}
}
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
];
}
protected function messages(): array
{
return [
'name.required' => 'Script name is required.',
'name.max' => 'Script name cannot exceed 255 characters.',
'script.required' => 'Cloud-init script content is required.',
];
}
public function save()
{
$this->validate();
try {
if ($this->scriptId) {
// Update existing script
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId);
$this->authorize('update', $cloudInitScript);
$cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script updated successfully.';
} else {
// Create new script
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script created successfully.';
}
// Only reset fields if creating (not editing)
if (! $this->scriptId) {
$this->reset(['name', 'script']);
}
$this->dispatch('scriptSaved');
$this->dispatch('success', $message);
if ($this->modal_mode) {
$this->dispatch('closeModal');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-script-form');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScripts extends Component
{
use AuthorizesRequests;
public $scripts;
public function mount()
{
$this->authorize('viewAny', CloudInitScript::class);
$this->loadScripts();
}
public function getListeners()
{
return [
'scriptSaved' => 'loadScripts',
];
}
public function loadScripts()
{
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
}
public function deleteScript(int $scriptId)
{
try {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('delete', $script);
$script->delete();
$this->loadScripts();
$this->dispatch('success', 'Cloud-init script deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-scripts');
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class CloudProviderTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'hetzner';
public string $token = '';
public string $name = '';
public function mount()
{
$this->authorize('create', CloudProviderToken::class);
}
protected function rules(): array
{
return [
'provider' => 'required|string|in:hetzner,digitalocean',
'token' => 'required|string',
'name' => 'required|string|max:255',
];
}
protected function messages(): array
{
return [
'provider.required' => 'Please select a cloud provider.',
'provider.in' => 'Invalid cloud provider selected.',
'token.required' => 'API token is required.',
'name.required' => 'Token name is required.',
];
}
private function validateToken(string $provider, string $token): bool
{
try {
if ($provider === 'hetzner') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
ray($response);
return $response->successful();
}
// Add other providers here in the future
// if ($provider === 'digitalocean') { ... }
return false;
} catch (\Throwable $e) {
return false;
}
}
public function addToken()
{
$this->validate();
try {
// Validate the token with the provider's API
if (! $this->validateToken($this->provider, $this->token)) {
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
}
$savedToken = CloudProviderToken::create([
'team_id' => currentTeam()->id,
'provider' => $this->provider,
'token' => $this->token,
'name' => $this->name,
]);
$this->reset(['token', 'name']);
// Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id);
$this->dispatch('success', 'Cloud provider token added successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-token-form');
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudProviderTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
}
public function getListeners()
{
return [
'tokenAdded' => 'loadTokens',
];
}
public function loadTokens()
{
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
public function deleteToken(int $tokenId)
{
try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
// Check if any servers are using this token
if ($token->hasServers()) {
$serverCount = $token->servers()->count();
$this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first.");
return;
}
$token->delete();
$this->loadTokens();
$this->dispatch('success', 'Cloud provider token deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-tokens');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Security;
use Livewire\Component;
class CloudTokens extends Component
{
public function render()
{
return view('livewire.security.cloud-tokens');
}
}

View file

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

View file

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

View file

@ -0,0 +1,144 @@
<?php
namespace App\Livewire\Server\CloudProviderToken;
use App\Models\CloudProviderToken;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public $cloudProviderTokens = [];
public $parameters = [];
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->loadTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
];
}
public function loadTokens()
{
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
$this->loadTokens();
}
public function setCloudProviderToken($tokenId)
{
$ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId);
if (is_null($ownedToken)) {
$this->dispatch('error', 'You are not allowed to use this token.');
return;
}
try {
$this->authorize('update', $this->server);
// Validate the token works and can access this specific server
$validationResult = $this->validateTokenForServer($ownedToken);
if (! $validationResult['valid']) {
$this->dispatch('error', $validationResult['error']);
return;
}
$this->server->cloudProviderToken()->associate($ownedToken);
$this->server->save();
$this->dispatch('success', 'Hetzner token updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
$this->server->refresh();
$this->dispatch('error', $e->getMessage());
}
}
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
// First, validate the token itself
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if (! $response->successful()) {
return [
'valid' => false,
'error' => 'This token is invalid or has insufficient permissions.',
];
}
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) {
$serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this server. It may belong to a different Hetzner project.',
];
}
}
return ['valid' => true];
} catch (\Throwable $e) {
return [
'valid' => false,
'error' => 'Failed to validate token: '.$e->getMessage(),
];
}
}
public function validateToken()
{
try {
$token = $this->server->cloudProviderToken;
if (! $token) {
$this->dispatch('error', 'No Hetzner token is associated with this server.');
return;
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if ($response->successful()) {
$this->dispatch('success', 'Hetzner token is valid and working.');
} else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.cloud-provider-token.show');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Server;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use Livewire\Component;
@ -12,6 +13,8 @@ class Create extends Component
public bool $limit_reached = false;
public bool $has_hetzner_tokens = false;
public function mount()
{
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
@ -21,6 +24,11 @@ public function mount()
return;
}
$this->limit_reached = Team::serverLimitReached();
// Check if user has Hetzner tokens
$this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->exists();
}
public function render()

View file

@ -16,6 +16,8 @@ class Delete extends Component
public Server $server;
public bool $delete_from_hetzner = false;
public function mount(string $server_uuid)
{
try {
@ -41,8 +43,15 @@ public function delete($password)
return;
}
$this->server->delete();
DeleteServer::dispatch($this->server);
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) {
@ -52,6 +61,18 @@ public function delete($password)
public function render()
{
return view('livewire.server.delete');
$checkboxes = [];
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
'label' => 'Also delete server from Hetzner Cloud',
'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.',
];
}
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);
}
}

View file

@ -118,17 +118,31 @@ public function checkProxyStatus()
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':
$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;
}

View file

@ -0,0 +1,565 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
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;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByHetzner extends Component
{
use AuthorizesRequests;
// Step tracking
public int $current_step = 1;
// Locked data
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
// Step 1: Token selection
public ?int $selected_token_id = null;
// Step 2: Server configuration
public array $locations = [];
public array $images = [];
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;
public bool $loading_data = false;
public bool $enable_ipv4 = true;
public bool $enable_ipv6 = true;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
}
public function loadSavedCloudInitScripts()
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function resetSelection()
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
}
public function loadTokens()
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
// Refresh token list
$this->loadTokens();
// Auto-select the new token
$this->selected_token_id = $tokenId;
// Automatically proceed to next step
$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 = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_location' => 'required|string',
'selected_image' => 'required|integer',
'selected_server_type' => 'required|string',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a Hetzner token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId)
{
$this->selected_token_id = $tokenId;
}
private function validateHetznerToken(string $token): bool
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
private function getHetznerToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
public function nextStep()
{
// Validate step 1 - just need a token selected
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
$hetznerToken = $this->getHetznerToken();
if (! $hetznerToken) {
return $this->dispatch('error', 'Please select a valid Hetzner token.');
}
// Load Hetzner data
$this->loadHetznerData($hetznerToken);
// Move to step 2
$this->current_step = 2;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function previousStep()
{
$this->current_step = 1;
}
private function loadHetznerData(string $token)
{
$this->loading_data = true;
try {
$hetznerService = new HetznerService($token);
$this->locations = $hetznerService->getLocations();
$this->serverTypes = $hetznerService->getServerTypes();
// Get images and sort by name
$images = $hetznerService->getImages();
ray('Raw images from Hetzner API', [
'total_count' => count($images),
'types' => collect($images)->pluck('type')->unique()->values(),
'sample' => array_slice($images, 0, 3),
]);
$this->images = collect($images)
->filter(function ($image) {
// Only system images
if (! isset($image['type']) || $image['type'] !== 'system') {
return false;
}
// Filter out deprecated images
if (isset($image['deprecated']) && $image['deprecated'] === true) {
return false;
}
return true;
})
->sortBy('name')
->values()
->toArray();
ray('Filtered images', [
'filtered_count' => count($this->images),
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
// 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;
throw $e;
}
}
public function getAvailableServerTypesProperty()
{
ray('Getting available server types', [
'selected_location' => $this->selected_location,
'total_server_types' => count($this->serverTypes),
]);
if (! $this->selected_location) {
return $this->serverTypes;
}
$filtered = collect($this->serverTypes)
->filter(function ($type) {
if (! isset($type['locations'])) {
return false;
}
$locationNames = collect($type['locations'])->pluck('name')->toArray();
return in_array($this->selected_location, $locationNames);
})
->values()
->toArray();
ray('Filtered server types', [
'selected_location' => $this->selected_location,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getAvailableImagesProperty()
{
ray('Getting available images', [
'selected_server_type' => $this->selected_server_type,
'total_images' => count($this->images),
'images' => $this->images,
]);
if (! $this->selected_server_type) {
return $this->images;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
ray('Server type data', $serverType);
if (! $serverType || ! isset($serverType['architecture'])) {
ray('No architecture in server type, returning all');
return $this->images;
}
$architecture = $serverType['architecture'];
$filtered = collect($this->images)
->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture)
->values()
->toArray();
ray('Filtered images', [
'architecture' => $architecture,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getSelectedServerPriceProperty(): ?string
{
if (! $this->selected_server_type) {
return null;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
return null;
}
$price = $serverType['prices'][0]['price_monthly']['gross'];
return '€'.number_format($price, 2);
}
public function updatedSelectedLocation($value)
{
ray('Location selected', $value);
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
}
public function updatedSelectedServerType($value)
{
ray('Server type selected', $value);
// Reset image when server type changes
$this->selected_image = null;
}
public function updatedSelectedImage($value)
{
ray('Image selected', $value);
}
public function updatedSelectedCloudInitScriptId($value)
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
}
}
public function clearCloudInitScript()
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
}
private function createHetznerServer(string $token): array
{
$hetznerService = new HetznerService($token);
// Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
ray('Private Key Info', [
'private_key_id' => $this->private_key_id,
'sha256_fingerprint' => $privateKey->fingerprint,
'md5_fingerprint' => $md5Fingerprint,
]);
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
ray('Existing SSH Keys on Hetzner', $existingSshKeys);
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
break;
}
}
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
// 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,
'server_type' => $this->selected_server_type,
'image' => $this->selected_image,
'location' => $this->selected_location,
'start_after_create' => true,
'ssh_keys' => $sshKeys,
'public_net' => [
'enable_ipv4' => $this->enable_ipv4,
'enable_ipv6' => $this->enable_ipv6,
],
];
// Add cloud-init script if provided
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
ray('Server creation parameters', $params);
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
ray('Hetzner server created', $hetznerServer);
return $hetznerServer;
}
public function submit()
{
$this->validate();
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
// Save cloud-init script if requested
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->cloud_init_script_name,
'script' => $this->cloud_init_script,
]);
}
$hetznerToken = $this->getHetznerToken();
// 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' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'],
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.new.by-hetzner');
}
}

View file

@ -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,87 @@ 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 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
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 {

View file

@ -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;
@ -63,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);
@ -136,8 +150,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) {

View file

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudInitScript extends Model
{
protected $fillable = [
'team_id',
'name',
'script',
];
protected function casts(): array
{
return [
'script' => 'encrypted',
];
}
public function team()
{
return $this->belongsTo(Team::class);
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudProviderToken extends Model
{
protected $guarded = [];
protected $casts = [
'token' => 'encrypted',
];
public function team()
{
return $this->belongsTo(Team::class);
}
public function servers()
{
return $this->hasMany(Server::class);
}
public function hasServers(): bool
{
return $this->servers()->exists();
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
}
public function scopeForProvider($query, string $provider)
{
return $query->where('provider', $provider);
}
}

View file

@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey)
}
}
public static function generateMd5Fingerprint($privateKey)
{
try {
$key = PublicKeyLoader::load($privateKey);
return $key->getPublicKey()->getFingerprint('md5');
} catch (\Throwable $e) {
return null;
}
}
public static function fingerprintExists($fingerprint, $excludeId = null)
{
$query = self::query()

View file

@ -136,6 +136,7 @@ protected static function booted()
$destination->delete();
});
$server->settings()->delete();
$server->sslCertificates()->delete();
});
}
@ -161,7 +162,11 @@ protected static function booted()
'user',
'description',
'private_key_id',
'cloud_provider_token_id',
'team_id',
'hetzner_server_id',
'hetzner_server_status',
'is_validating',
];
protected $guarded = [];
@ -889,6 +894,16 @@ public function privateKey()
return $this->belongsTo(PrivateKey::class);
}
public function cloudProviderToken()
{
return $this->belongsTo(CloudProviderToken::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function muxFilename()
{
return 'mux_'.$this->uuid;
@ -1327,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;

View file

@ -258,6 +258,11 @@ public function privateKeys()
return $this->hasMany(PrivateKey::class);
}
public function cloudProviderTokens()
{
return $this->hasMany(CloudProviderToken::class);
}
public function sources()
{
$sources = collect([]);

View file

@ -0,0 +1,71 @@
<?php
namespace App\Notifications\Server;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class HetznerDeletionFailed extends CustomEmailNotification
{
public function __construct(public int $hetznerServerId, public int $teamId, public string $errorMessage)
{
$this->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()
);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\CloudInitScript;
use App\Models\User;
class CloudInitScriptPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, CloudInitScript $cloudInitScript): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, CloudInitScript $cloudInitScript): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, CloudInitScript $cloudInitScript): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, CloudInitScript $cloudInitScript): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, CloudInitScript $cloudInitScript): bool
{
return $user->isAdmin();
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\CloudProviderToken;
use App\Models\User;
class CloudProviderTokenPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, CloudProviderToken $cloudProviderToken): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, CloudProviderToken $cloudProviderToken): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, CloudProviderToken $cloudProviderToken): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, CloudProviderToken $cloudProviderToken): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool
{
return $user->isAdmin();
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
class ValidCloudInitYaml implements ValidationRule
{
/**
* Run the validation rule.
*
* Validates that the cloud-init script is either:
* - Valid YAML format (for cloud-config)
* - Valid bash script (starting with #!)
* - Empty/null (optional field)
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$script = trim($value);
// If it's a bash script (starts with shebang), skip YAML validation
if (str_starts_with($script, '#!')) {
return;
}
// If it's a cloud-config file (starts with #cloud-config), validate YAML
if (str_starts_with($script, '#cloud-config')) {
// Remove the #cloud-config header and validate the rest as YAML
$yamlContent = preg_replace('/^#cloud-config\s*/m', '', $script, 1);
try {
Yaml::parse($yamlContent);
} catch (ParseException $e) {
$fail('The :attribute must be valid YAML format. Error: '.$e->getMessage());
}
return;
}
// If it doesn't start with #! or #cloud-config, try to parse as YAML
// (some users might omit the #cloud-config header)
try {
Yaml::parse($script);
} catch (ParseException $e) {
$fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage());
}
}
}

114
app/Rules/ValidHostname.php Normal file
View file

@ -0,0 +1,114 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class ValidHostname implements ValidationRule
{
/**
* Run the validation rule.
*
* Validates hostname according to RFC 1123:
* - Must be 1-253 characters total
* - Each label (segment between dots) must be 1-63 characters
* - Labels can contain lowercase letters (a-z), digits (0-9), and hyphens (-)
* - Labels cannot start or end with a hyphen
* - Labels cannot be all numeric
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$hostname = trim($value);
// Check total length (RFC 1123: max 253 characters)
if (strlen($hostname) > 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
}
}
}

View file

@ -0,0 +1,143 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class HetznerService
{
private string $token;
private string $baseUrl = 'https://api.hetzner.cloud/v1';
public function __construct(string $token)
{
$this->token = $token;
}
private function request(string $method, string $endpoint, array $data = [])
{
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->token,
])
->timeout(30)
->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'));
}
return $response->json();
}
private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array
{
$allResults = [];
$page = 1;
do {
$data['page'] = $page;
$data['per_page'] = 50;
$response = $this->request($method, $endpoint, $data);
if (isset($response[$resourceKey])) {
$allResults = array_merge($allResults, $response[$resourceKey]);
}
$nextPage = $response['meta']['pagination']['next_page'] ?? null;
$page = $nextPage;
} while ($nextPage !== null);
return $allResults;
}
public function getLocations(): array
{
return $this->requestPaginated('get', '/locations', 'locations');
}
public function getImages(): array
{
return $this->requestPaginated('get', '/images', 'images', [
'type' => 'system',
]);
}
public function getServerTypes(): array
{
return $this->requestPaginated('get', '/server_types', 'server_types');
}
public function getSshKeys(): array
{
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
}
public function uploadSshKey(string $name, string $publicKey): array
{
$response = $this->request('post', '/ssh_keys', [
'name' => $name,
'public_key' => $publicKey,
]);
return $response['ssh_key'] ?? [];
}
public function createServer(array $params): array
{
ray('Hetzner createServer request', [
'endpoint' => '/servers',
'params' => $params,
]);
$response = $this->request('post', '/servers', $params);
ray('Hetzner createServer response', [
'response' => $response,
]);
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}");
}
}

View file

@ -17,6 +17,7 @@ trait HasNotificationSettings
'general',
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
];
/**

View file

@ -22,7 +22,7 @@ public function __construct(
public string|bool|null $checked = false,
public string|bool $instantSave = false,
public bool $disabled = false,
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed',
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark: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',
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,

View file

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

View file

@ -1,32 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up()
{
// Change the default value for the 'image' column
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnamilegacy/clickhouse')->change();
});
// Optionally, update any existing rows with the old default to the new one
DB::table('standalone_clickhouses')
->where('image', 'bitnami/clickhouse')
->update(['image' => 'bitnamilegacy/clickhouse']);
}
public function down()
{
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnami/clickhouse')->change();
});
// Optionally, revert any changed values
DB::table('standalone_clickhouses')
->where('image', 'bitnamilegacy/clickhouse')
->update(['image' => 'bitnami/clickhouse']);
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
// Change the default value for the 'image' column
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnamilegacy/clickhouse')->change();
});
// Optionally, update any existing rows with the old default to the new one
DB::table('standalone_clickhouses')
->where('image', 'bitnami/clickhouse')
->update(['image' => 'bitnamilegacy/clickhouse']);
}
public function down()
{
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnami/clickhouse')->change();
});
// Optionally, revert any changed values
DB::table('standalone_clickhouses')
->where('image', 'bitnamilegacy/clickhouse')
->update(['image' => 'bitnami/clickhouse']);
}
};

View file

@ -0,0 +1,33 @@
<?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::create('cloud_provider_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('provider');
$table->text('token');
$table->string('name')->nullable();
$table->timestamps();
$table->index(['team_id', 'provider']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_provider_tokens');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->bigInteger('hetzner_server_id')->nullable()->after('id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('hetzner_server_id');
});
}
};

View file

@ -0,0 +1,29 @@
<?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->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropForeign(['cloud_provider_token_id']);
$table->dropColumn('cloud_provider_token_id');
});
}
};

View file

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

View file

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

View file

@ -0,0 +1,32 @@
<?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::create('cloud_init_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('script'); // Encrypted in the model
$table->timestamps();
$table->index('team_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_init_scripts');
}
};

View file

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

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

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

After

Width:  |  Height:  |  Size: 284 B

View file

@ -18,7 +18,7 @@ @theme {
--color-base: #101010;
--color-warning: #fcd452;
--color-success: #16a34a;
--color-success: #22C55E;
--color-error: #dc2626;
--color-coollabs-50: #f5f0ff;
--color-coollabs: #6b16ed;

View file

@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title {
}
@utility input-sticky {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300;
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 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;
}
@utility input-sticky-active {
@ -41,7 +41,7 @@ @utility input-sticky-active {
/* Focus */
@utility input-focus {
@apply focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300;
@apply 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;
}
/* input, select before */
@ -52,18 +52,18 @@ @utility input-select {
/* Readonly */
@utility input {
@apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply input-focus;
@apply input-select;
@apply 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;
}
@utility select {
@apply w-full;
@apply input-focus;
@apply input-select;
@apply 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;
}
@utility button {
@apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 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;
@apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 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;
}
@utility alert-success {

View file

@ -1,6 +1,6 @@
<div class="w-full">
<label>
@if ($label)
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neutral-600' : '' }}">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@ -8,37 +8,174 @@
@if ($helper)
<x-helper :helper="$helper" />
@endif
@endif
<input list={{ $id }} {{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
wire:dirty.class.remove='dark:text-white' wire:dirty.class="text-black bg-warning" wire:loading.attr="disabled"
name={{ $id }}
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif
@if ($attributes->whereStartsWith('onUpdate')->first()) wire:change={{ $attributes->whereStartsWith('onUpdate')->first() }} wire:keydown.enter={{ $attributes->whereStartsWith('onUpdate')->first() }} wire:blur={{ $attributes->whereStartsWith('onUpdate')->first() }} @else wire:change={{ $id }} wire:blur={{ $id }} wire:keydown.enter={{ $id }} @endif>
<datalist id={{ $id }}>
{{ $slot }}
</datalist>
</label>
@error($id)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
{{-- <script>
const input = document.querySelector(`input[list={{ $id }}]`);
input.addEventListener('focus', function(e) {
const input = e.target.value;
const datalist = document.getElementById('{{ $id }}');
if (datalist.options) {
for (let option of datalist.options) {
// change background color to red on all options
option.style.display = "none";
if (option.value.includes(input)) {
option.style.display = "block";
@endif
@if ($multiple)
{{-- Multiple Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
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
let value = opt.value;
const intValue = parseInt(value, 10);
if (!isNaN(intValue) && intValue.toString() === value) {
value = intValue;
}
return {
value: value,
text: opt.textContent.trim()
};
});
this.filteredOptions = this.options;
// Ensure selected is always an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
return;
}
const searchLower = this.search.toLowerCase();
this.filteredOptions = this.options.filter(opt =>
opt.text.toLowerCase().includes(searchLower)
);
},
toggleOption(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
} else {
this.selected.push(value);
}
this.search = '';
this.filterOptions();
// Focus input after selection
this.$refs.searchInput.focus();
},
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)) {
return false;
}
return this.selected.includes(value);
},
getSelectedText(value) {
const option = this.options.find(opt => opt.value == value);
return option ? option.text : value;
}
});
</script> --}}
}" @click.outside="open = false" class="relative">
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 p-2 min-h-[42px] max-h-40 overflow-y-auto {{ $defaultClass }} cursor-text"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
<button
type="button"
@click.stop="removeOption(value, $event)"
:disabled="{{ $disabled ? 'true' : 'false' }}"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs bg-coolgray-200 dark:bg-coolgray-700 rounded whitespace-nowrap {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400' }}"
aria-label="Remove">
<span x-text="getSelectedText(value)" class="max-w-[200px] truncate"></span>
</button>
</template>
{{-- Search Input (Borderless, Inside Container) --}}
<input type="text" x-model="search" x-ref="searchInput" @input="filterOptions()" @focus="open = true"
@keydown.escape="open = false"
:placeholder="(Array.isArray(selected) && selected.length > 0) ? '' :
{{ json_encode($placeholder ?: 'Search...') }}"
@required($required) @readonly($readonly) @disabled($disabled) @if ($autofocus)
autofocus
@endif
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"
/>
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="toggleOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
<input type="checkbox" :checked="isSelected(option.value)"
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
tabindex="-1">
<span class="text-sm flex-1" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@else
{{-- Single Selection Mode (Standard HTML5 Datalist) --}}
<input list="{{ $id }}" {{ $attributes->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>
<datalist id="{{ $id }}">
{{ $slot }}
</datalist>
@endif
@error($id)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>

View file

@ -28,8 +28,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"
@ -40,8 +39,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"

View file

@ -11,8 +11,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu
</label>
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" name={{ $id }}
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $id }}
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif>
{{ $slot }}
</select>

View file

@ -46,8 +46,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
@ -56,7 +55,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif
wire:dirty.class="dark:ring-warning ring-warning" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
@ -68,7 +67,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300' wire:dirty.class="dark:focus:ring-warning dark:ring-warning" @endif
wire:dirty.class="dark:ring-warning ring-warning" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
@endif

View file

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

View file

@ -8,8 +8,11 @@
'content' => null,
'closeOutside' => true,
'minWidth' => '36rem',
'isFullWidth' => false,
])
<div x-data="{ modalOpen: false }" :class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
<div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
class="relative w-auto h-auto" wire:ignore>
@if ($content)
<div @click="modalOpen=true">
@ -17,13 +20,13 @@ class="relative w-auto h-auto" wire:ignore>
</div>
@else
@if ($disabled)
<x-forms.button isError disabled>{{ $buttonTitle }}</x-forms.button>
<x-forms.button isError disabled @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
@elseif ($isErrorButton)
<x-forms.button isError @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
<x-forms.button isError @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
@elseif ($isHighlightedButton)
<x-forms.button isHighlighted @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
<x-forms.button isHighlighted @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
@else
<x-forms.button @click="modalOpen=true">{{ $buttonTitle }}</x-forms.button>
<x-forms.button @click="modalOpen=true" @class(['w-full' => $isFullWidth])>{{ $buttonTitle }}</x-forms.button>
@endif
@endif
<template x-teleport="body">
@ -46,7 +49,7 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<div class="flex items-center justify-between pb-3">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 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">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />

View file

@ -6,8 +6,18 @@
<a href="{{ route('security.private-key.index') }}">
<button>Private Keys</button>
</a>
@can('viewAny', App\Models\CloudProviderToken::class)
<a href="{{ route('security.cloud-tokens') }}">
<button>Cloud Tokens</button>
</a>
@endcan
@can('viewAny', App\Models\CloudInitScript::class)
<a href="{{ route('security.cloud-init-scripts') }}">
<button>Cloud-Init Scripts</button>
</a>
@endcan
<a href="{{ route('security.api-tokens') }}">
<button>API tokens</button>
<button>API Tokens</button>
</a>
</nav>
</div>

View file

@ -9,6 +9,11 @@
<a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}"
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
</a>
@if ($server->hetzner_server_id)
<a class="menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}">Hetzner Token
</a>
@endif
<a class="menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}"
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}">CA Certificate
</a>

View file

@ -5,7 +5,7 @@
<x-loading wire:loading.delay.longer />
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-warning"></div>
<div class="pl-2 pr-1 text-xs font-bold tracking-widerr dark:text-warning">
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">
{{ str($status)->before(':')->headline() }}
</div>
@if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))

View file

@ -10,7 +10,7 @@
@endif
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-warning"></div>
<div class="pl-2 pr-1 text-xs font-bold tracking-wider dark:text-warning" @if($title) title="{{$title}}" @endif>
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning" @if($title) title="{{$title}}" @endif>
@if ($lastDeploymentLink)
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
{{ str($status)->before(':')->headline() }}

View file

@ -8,7 +8,7 @@
<div class="flex items-center">
<div wire:loading.delay.longer wire:target="checkProxy(true)" class="badge badge-warning"></div>
<div wire:loading.remove.delay.longer wire:target="checkProxy(true)" class="badge badge-success"></div>
<div class="pl-2 pr-1 text-xs font-bold tracking-wider text-success"
<div class="pl-2 pr-1 text-xs font-bold text-success"
@if ($title) title="{{ $title }}" @endif>
@if ($lastDeploymentLink)
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">

View file

@ -8,6 +8,6 @@
@endif
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-error "></div>
<div class="pl-2 pr-1 text-xs font-bold tracking-wider text-error">{{ str($status)->before(':')->headline() }}</div>
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ str($status)->before(':')->headline() }}</div>
</span>
</div>

View file

@ -0,0 +1,13 @@
<x-emails.layout>
Failed to delete Hetzner server #{{ $hetznerServerId }} from Hetzner Cloud.
Error:
<pre>
{{ $errorMessage }}
</pre>
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.
</x-emails.layout>

View file

@ -14,6 +14,11 @@
return [];
}
// Don't execute search if data is still loading
if (this.isLoadingInitialData) {
return [];
}
const query = this.searchQuery.toLowerCase().trim();
const results = this.allSearchableItems.filter(item => {
@ -28,6 +33,12 @@
if (!this.searchQuery || this.searchQuery.length < 1) {
return [];
}
// Don't execute search if data is still loading
if (this.isLoadingInitialData) {
return [];
}
const query = this.searchQuery.toLowerCase().trim();
if (query === 'new') {
@ -239,7 +250,8 @@
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
</div>
<div x-show="modalOpen" x-trap.inert="modalOpen" x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
<div x-show="modalOpen" x-trap.inert="modalOpen"
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
@ -249,24 +261,24 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
<!-- Search input (always visible) -->
<div class="relative">
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<svg x-show="!isLoadingInitialData" class="w-5 h-5 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<svg x-show="!isLoadingInitialData" class="w-5 h-5 text-neutral-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning"
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>
<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>
</div>
<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 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 disabled:opacity-50 disabled:cursor-not-allowed" />
placeholder="Search resources, paths, everything (type new for create)..." x-ref="searchInput"
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 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 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
@ -278,22 +290,6 @@ class="pointer-events-auto px-2 py-1 text-xs font-medium text-neutral-500 dark:t
</div>
</div>
<!-- Debug: Show data loaded (temporary) -->
{{-- <div x-show="!isLoadingInitialData && searchQuery === '' && allSearchableItems.length > 0" x-cloak
class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 overflow-hidden p-6">
<div class="text-center">
<p class="text-sm font-semibold text-green-600 dark:text-green-400">
Data loaded successfully!
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
<span x-text="allSearchableItems.length"></span> searchable items available
</p>
<p class="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Start typing to search...
</p>
</div>
</div> --}}
<!-- Search results (with background) -->
<div x-show="searchQuery.length >= 1" x-cloak
class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 overflow-hidden">
@ -311,8 +307,8 @@ class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutr
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M15 19l-7-7 7-7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
@ -327,13 +323,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
</div>
@if ($loadingServers)
<div
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
@ -343,8 +337,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
</div>
@elseif (count($availableServers) > 0)
@foreach ($availableServers as $index => $server)
<button type="button"
wire:click="selectServer({{ $server['id'] }}, true)"
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
@ -352,8 +345,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
{{ $server['name'] }}
</div>
@if (!empty($server['description']))
<div
class="text-xs text-neutral-500 dark:text-neutral-400">
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $server['description'] }}
</div>
@else
@ -363,10 +355,10 @@ class="text-xs text-neutral-500 dark:text-neutral-400">
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
@ -388,10 +380,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M15 19l-7-7 7-7" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
@ -406,13 +398,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
</div>
@if ($loadingDestinations)
<div
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
@ -422,24 +412,22 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
</div>
@elseif (count($availableDestinations) > 0)
@foreach ($availableDestinations as $index => $destination)
<button type="button"
wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white">
{{ $destination['name'] }}
</div>
<div
class="text-xs text-neutral-500 dark:text-neutral-400">
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Network: {{ $destination['network'] }}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
@ -461,10 +449,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M15 19l-7-7 7-7" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
@ -479,13 +467,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
</div>
@if ($loadingProjects)
<div
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
@ -495,8 +481,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
</div>
@elseif (count($availableProjects) > 0)
@foreach ($availableProjects as $index => $project)
<button type="button"
wire:click="selectProject('{{ $project['uuid'] }}', true)"
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
@ -504,8 +489,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
{{ $project['name'] }}
</div>
@if (!empty($project['description']))
<div
class="text-xs text-neutral-500 dark:text-neutral-400">
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $project['description'] }}
</div>
@else
@ -515,10 +499,10 @@ class="text-xs text-neutral-500 dark:text-neutral-400">
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
@ -540,10 +524,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M15 19l-7-7 7-7" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
@ -558,13 +542,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
</div>
@if ($loadingEnvironments)
<div
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 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>
@ -574,8 +556,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
</div>
@elseif (count($availableEnvironments) > 0)
@foreach ($availableEnvironments as $index => $environment)
<button type="button"
wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
@ -583,8 +564,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
{{ $environment['name'] }}
</div>
@if (!empty($environment['description']))
<div
class="text-xs text-neutral-500 dark:text-neutral-400">
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $environment['description'] }}
</div>
@else
@ -594,10 +574,10 @@ class="text-xs text-neutral-500 dark:text-neutral-400">
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
@ -636,8 +616,7 @@ class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-cool
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span
class="font-medium text-neutral-900 dark:text-white truncate">
<span class="font-medium text-neutral-900 dark:text-white truncate">
{{ $result['name'] }}
</span>
<span
@ -658,15 +637,13 @@ class="px-2 py-0.5 text-xs rounded-full bg-neutral-100 dark:bg-coolgray-300 text
</span>
</div>
@if (!empty($result['project']) && !empty($result['environment']))
<div
class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
<div class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{{ $result['project'] }} /
{{ $result['environment'] }}
</div>
@endif
@if (!empty($result['description']))
<div
class="text-sm text-neutral-600 dark:text-neutral-400">
<div class="text-sm text-neutral-600 dark:text-neutral-400">
{{ Str::limit($result['description'], 80) }}
</div>
@endif
@ -674,8 +651,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400">
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</a>
@ -705,16 +682,15 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div
class="font-medium text-neutral-900 dark:text-white truncate">
<div class="font-medium text-neutral-900 dark:text-white truncate">
{{ $item['name'] }}
</div>
@if (isset($item['quickcommand']))
@ -722,8 +698,7 @@ class="font-medium text-neutral-900 dark:text-white truncate">
class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span>
@endif
</div>
<div
class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
<div class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
{{ $item['description'] }}
</div>
</div>
@ -731,8 +706,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
@ -817,8 +792,7 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24"
stroke="currentColor">
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
@ -854,7 +828,7 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
</template>
<template
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !$wire.isSelectingResource && !$wire.autoOpenResource">
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
<div class="flex items-center justify-center py-12 px-4">
<div class="text-center">
<p class="mt-4 text-sm font-medium text-neutral-900 dark:text-white">
@ -886,12 +860,10 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -904,8 +876,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New Project</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -928,12 +900,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -946,8 +916,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New Server</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -970,12 +940,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -988,8 +956,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New Team</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -1012,12 +980,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -1030,8 +996,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New S3 Storage</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -1054,12 +1020,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -1072,8 +1036,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New Private Key</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -1096,12 +1060,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
if (firstInput) firstInput.focus();
}, 200);
}
})"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -1114,8 +1076,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
<h3 class="text-2xl font-bold">New GitHub App</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -1128,4 +1090,4 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</template>
</div>
</div>
</div>

View file

@ -7,7 +7,7 @@
<h2>API Tokens</h2>
@if (!$isApiEnabled)
<div>API is disabled. If you want to use the API, please enable it in the <a
href="{{ route('settings.index') }}" class="underline dark:text-white">Settings</a> menu.</div>
href="{{ route('settings.advanced') }}" class="underline dark:text-white">Settings</a> menu.</div>
@else
<div>Tokens are created with the current team as scope.</div>
</div>

View file

@ -0,0 +1,17 @@
<form wire:submit='save' class="flex flex-col gap-4 w-full">
<x-forms.input id="name" label="Script Name" helper="A descriptive name for this cloud-init script." required />
<x-forms.textarea id="script" label="Script Content" rows="12"
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
<div class="flex justify-end gap-2">
@if ($modal_mode)
<x-forms.button type="button" @click="$dispatch('closeModal')">
Cancel
</x-forms.button>
@endif
<x-forms.button type="submit" isHighlighted>
{{ $scriptId ? 'Update Script' : 'Create Script' }}
</x-forms.button>
</div>
</form>

View file

@ -0,0 +1,50 @@
<div>
<x-security.navbar />
<div class="flex gap-2">
<h2 class="pb-4">Cloud-Init Scripts</h2>
@can('create', App\Models\CloudInitScript::class)
<x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script">
<livewire:security.cloud-init-script-form />
</x-modal-input>
@endcan
</div>
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization. Currently working only with <span class="text-red-500 font-bold">Hetzner's</span> integration.</div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($scripts as $script)
<div wire:key="script-{{ $script->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div class="flex justify-between items-center">
<div class="flex-1">
<div class="font-bold dark:text-white">{{ $script->name }}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Created {{ $script->created_at->diffForHumans() }}
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
@can('update', $script)
<x-modal-input buttonTitle="Edit" title="Edit Cloud-Init Script" fullWidth>
<livewire:security.cloud-init-script-form :scriptId="$script->id"
wire:key="edit-{{ $script->id }}" />
</x-modal-input>
@endcan
@can('delete', $script)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
submitAction="deleteScript({{ $script->id }})" :actions="[
'This cloud-init script will be permanently deleted.',
'This action cannot be undone.',
]" confirmationText="{{ $script->name }}"
confirmationLabel="Please confirm the deletion by entering the script name below"
shortConfirmationLabel="Script Name" :confirmWithPassword="false"
step2ButtonText="Delete Script" />
@endcan
</div>
</div>
@empty
<div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div>
@endforelse
</div>
</div>

View file

@ -0,0 +1,58 @@
<div class="w-full">
<form class="flex flex-col gap-2 {{ $modal_mode ? 'w-full' : '' }}" wire:submit='addToken'>
@if ($modal_mode)
{{-- Modal layout: vertical, compact --}}
@if (!isset($provider) || empty($provider) || $provider === '')
<x-forms.select required id="provider" label="Provider">
<option value="hetzner">Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
</x-forms.select>
@else
<input type="hidden" wire:model="provider" />
@endif
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose
Project Security API Tokens.
</div>
@endif
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
@else
{{-- Full page layout: horizontal, spacious --}}
<div class="flex gap-2 items-end flex-wrap">
<div class="w-64">
<x-forms.select required id="provider" label="Provider" disabled>
<option value="hetzner" selected>Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
</x-forms.select>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
</div>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
class='underline dark:text-white'>Hetzner Console</a> choose Project Security API
Tokens.
</div>
@endif
</div>
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
@endif
</form>
</div>

View file

@ -0,0 +1,40 @@
<div>
<h2>Cloud Provider Tokens</h2>
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).</div>
<h3>New Token</h3>
@can('create', App\Models\CloudProviderToken::class)
<livewire:security.cloud-provider-token-form :modal_mode="false" />
@endcan
<h3 class="py-4">Saved Tokens</h3>
<div class="grid gap-2 lg:grid-cols-1">
@forelse ($tokens as $savedToken)
<div wire:key="token-{{ $savedToken->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-bold rounded dark:bg-coolgray-300 dark:text-white">
{{ strtoupper($savedToken->provider) }}
</span>
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
</div>
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
@can('delete', $savedToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.',
]"
confirmationText="{{ $savedToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
</div>
@empty
<div>
<div>No cloud provider tokens found.</div>
</div>
@endforelse
</div>
</div>

View file

@ -0,0 +1,7 @@
<div>
<x-slot:title>
Cloud Tokens | Coolify
</x-slot>
<x-security.navbar />
<livewire:security.cloud-provider-tokens />
</div>

View file

@ -0,0 +1,61 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
<div class="w-full">
@if ($server->hetzner_server_id)
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input buttonTitle="+ Add" title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
</x-modal-input>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click.prevent='validateToken'>
Validate token
</x-forms.button>
</div>
<div class="pb-4">Change your server's Hetzner token.</div>
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
@forelse ($cloudProviderTokens as $token)
<div
class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center flex flex-col gap-2">
<div class="flex flex-col w-full">
<div class="box-title">{{ $token->name }}</div>
<div class="box-description">
Created {{ $token->created_at->diffForHumans() }}
</div>
</div>
@if (data_get($server, 'cloudProviderToken.id') !== $token->id)
<x-forms.button canGate="update" :canResource="$server" class="w-full"
wire:click='setCloudProviderToken({{ $token->id }})'>
Use this token
</x-forms.button>
@else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
</div>
@empty
<div>No Hetzner tokens found. </div>
@endforelse
</div>
@else
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
</div>
<div class="pb-4">This server was not created through Hetzner Cloud integration.</div>
<div class="p-4 border rounded-md dark:border-coolgray-300 dark:bg-coolgray-100">
<p class="dark:text-neutral-400">
Only servers created through Hetzner Cloud can have their tokens managed here.
</p>
</div>
@endif
</div>
</div>
</div>

View file

@ -1,3 +1,34 @@
<div class="w-full">
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
<div class="flex flex-col gap-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<div>
<x-modal-input title="Connect a Hetzner Server">
<x-slot:content>
<div class="relative gap-2 cursor-pointer box group">
<div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
</svg>
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a Hetzner Server</div>
<div class="box-description">
Deploy servers directly from your Hetzner Cloud account
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
@endcan
<div>
<h3 class="pb-2">Add Server by IP Address</h3>
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
</div>
</div>
</div>

View file

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

View file

@ -0,0 +1,200 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
@if ($available_tokens->count() > 0)
<div class="flex gap-2">
<div class="flex-1">
<x-forms.select label="Select Hetzner Token" id="selected_token_id"
wire:change="selectToken($event.target.value)" required>
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'Hetzner Token' }}
</option>
@endforeach
</x-forms.select>
</div>
<div class="flex items-end">
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
:disabled="!$selected_token_id">
Continue
</x-forms.button>
</div>
</div>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif
<x-modal-input isFullWidth
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Hetzner Token' }}"
title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
</x-modal-input>
</div>
@elseif ($current_step === 2)
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Hetzner data...</p>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Location" id="selected_location" wire:model.live="selected_location" required>
<option value="">Select a location...</option>
@foreach ($locations as $location)
<option value="{{ $location['name'] }}">
{{ $location['city'] }} - {{ $location['country'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Server Type" id="selected_server_type" wire:model.live="selected_server_type"
required :disabled="!$selected_location">
<option value="">
{{ $selected_location ? 'Select a server type...' : 'Select a location first' }}
</option>
@foreach ($this->availableServerTypes as $serverType)
<option value="{{ $serverType['name'] }}">
{{ $serverType['description'] }} -
{{ $serverType['cores'] }} vCPU,
{{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB
@if (isset($serverType['architecture']))
({{ $serverType['architecture'] }})
@endif
@if (isset($serverType['prices']))
-
{{ number_format($serverType['prices'][0]['price_monthly']['gross'] ?? 0, 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_server_type">
<option value="">
{{ $selected_server_type ? 'Select an image...' : 'Select a server type first' }}
</option>
@foreach ($this->availableImages as $image)
<option value="{{ $image['id'] }}">
{{ $image['description'] ?? $image['name'] }}
@if (isset($image['architecture']))
({{ $image['architecture'] }})
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50 dark:bg-yellow-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key" isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your Hetzner account and used to access the
server.
</p>
@endif
</div>
<div>
<x-forms.datalist label="Additional SSH Keys (from Hetzner)" id="selectedHetznerSshKeyIds"
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in Hetzner account'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
helper="Enable public IPv4 address for this server" />
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
</div>
</div>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
Back
</x-forms.button>
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
:disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button>
</div>
</form>
@endif
@endif
@endif
</div>

View file

@ -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>
@ -9,6 +9,89 @@
<form wire:submit.prevent='submit' class="flex flex-col">
<div class="flex gap-2">
<h2>General</h2>
@if ($server->hetzner_server_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if (in_array($hetznerServerStatus, ['starting', 'initializing'])) wire:poll.5s="checkHetznerServerStatus" @endif>
<svg class="w-4 h-4" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
fill="white" />
</svg>
@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>
<button wire:loading.remove wire:target="checkHetznerServerStatus" title="Refresh Status"
wire:click.prevent='checkHetznerServerStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkHetznerServerStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && !$server->isFunctional() && $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)
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
submitAction="submit" :actions="[
@ -16,7 +99,8 @@
]" :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>
@ -36,7 +120,21 @@
@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>
@ -69,12 +167,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>
@ -82,11 +183,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">
@ -96,53 +198,72 @@ 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">
<input readonly disabled autocomplete="off"
class="w-full input opacity-50 cursor-not-allowed"
value="{{ $serverTimezone ?: 'No timezone set' }}" placeholder="Server Timezone">
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
@ -160,7 +281,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>
@ -180,7 +301,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)
@ -191,7 +312,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
@ -208,32 +329,34 @@ class="w-full input opacity-50 cursor-not-allowed"
<div class="flex gap-2 items-center">
@if ($server->isSentinelLive())
<x-status.running status="In sync" noLoading title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update"
:canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update"
:canResource="$server">Restart</x-forms.button>
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server"
:disabled="$isValidating">Restart</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
<x-forms.button @click="slideOverOpen=true"
:disabled="$isValidating">Logs</x-forms.button>
</x-slide-over>
@else
<x-status.stopped status="Out of sync" noLoading
title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update"
:canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update"
:canResource="$server">Sync</x-forms.button>
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server"
:disabled="$isValidating">Sync</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
<x-forms.button @click="slideOverOpen=true"
:disabled="$isValidating">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
@ -242,14 +365,14 @@ class="w-full input opacity-50 cursor-not-allowed"
<div class="flex flex-col gap-2">
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$server" wire:model.live="isSentinelEnabled"
label="Enable Sentinel" />
label="Enable Sentinel" :disabled="$isValidating" />
@if ($server->isSentinelEnabled())
@if (isDev())
<x-forms.checkbox canGate="update" :canResource="$server" id="isSentinelDebugEnabled"
label="Enable Sentinel (with debug)" instantSave />
label="Enable Sentinel (with debug)" instantSave :disabled="$isValidating" />
@endif
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
id="isMetricsEnabled" label="Enable Metrics" />
id="isMetricsEnabled" label="Enable Metrics" :disabled="$isValidating" />
@else
@if (isDev())
<x-forms.checkbox id="isSentinelDebugEnabled" label="Enable Sentinel (with debug)"
@ -276,26 +399,29 @@ class="w-full input opacity-50 cursor-not-allowed"
@if ($server->isSentinelEnabled())
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
<x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken"
label="Sentinel token" required helper="Token for Sentinel." />
label="Sentinel token" required helper="Token for Sentinel." :disabled="$isValidating" />
<x-forms.button canGate="update" :canResource="$server"
wire:click="regenerateSentinelToken">Regenerate</x-forms.button>
wire:click="regenerateSentinelToken" :disabled="$isValidating">Regenerate</x-forms.button>
</div>
<x-forms.input canGate="update" :canResource="$server" id="sentinelCustomUrl" required
label="Coolify URL"
helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance." />
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" />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input canGate="update" :canResource="$server"
id="sentinelMetricsRefreshRateSeconds" label="Metrics rate (seconds)" required
helper="Interval used for gathering metrics. Lower values result in more disk space usage." />
helper="Interval used for gathering metrics. Lower values result in more disk space usage."
:disabled="$isValidating" />
<x-forms.input canGate="update" :canResource="$server" id="sentinelMetricsHistoryDays"
label="Metrics history (days)" required
helper="Number of days to retain metrics data for." />
helper="Number of days to retain metrics data for." :disabled="$isValidating" />
<x-forms.input canGate="update" :canResource="$server"
id="sentinelPushIntervalSeconds" label="Push interval (seconds)" required
helper="Interval at which metrics data is sent to the collector." />
helper="Interval at which metrics data is sent to the collector."
:disabled="$isValidating" />
</div>
</div>
@endif

View file

@ -123,6 +123,9 @@
<livewire:activity-monitor header="Docker Installation Logs" :showWaiting="false" />
@isset($error)
<pre class="font-bold whitespace-pre-line text-error">{!! $error !!}</pre>
<x-forms.button canGate="update" :canResource="$server" wire:click="retry" class="mt-4">
Retry Validation
</x-forms.button>
@endisset
@endif
</div>

View file

@ -34,12 +34,15 @@
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
use App\Livewire\Project\Show as ProjectShow;
use App\Livewire\Security\ApiTokens;
use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
use App\Livewire\Server\Advanced as ServerAdvanced;
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
use App\Livewire\Server\Charts as ServerCharts;
use App\Livewire\Server\CloudflareTunnel;
use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
use App\Livewire\Server\Delete as DeleteServer;
use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup;
@ -247,6 +250,7 @@
Route::get('/', ServerShow::class)->name('server.show');
Route::get('/advanced', ServerAdvanced::class)->name('server.advanced');
Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key');
Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token');
Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate');
Route::get('/resources', ResourcesShow::class)->name('server.resources');
Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel');
@ -271,6 +275,8 @@
// Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create');
Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show');
Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens');
Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts');
Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens');
});

View file

@ -0,0 +1,101 @@
<?php
// Note: These tests verify cloud-init script logic without database setup
it('validates cloud-init script is included in server params when provided', function () {
$cloudInitScript = "#!/bin/bash\necho 'Hello World'";
$params = [
'name' => 'test-server',
'server_type' => 'cx11',
'image' => 1,
'location' => 'nbg1',
'start_after_create' => true,
'ssh_keys' => [123],
'public_net' => [
'enable_ipv4' => true,
'enable_ipv6' => true,
],
];
// Add cloud-init script if provided
if (! empty($cloudInitScript)) {
$params['user_data'] = $cloudInitScript;
}
expect($params)
->toHaveKey('user_data')
->and($params['user_data'])->toBe("#!/bin/bash\necho 'Hello World'");
});
it('validates cloud-init script is not included when empty', function () {
$cloudInitScript = null;
$params = [
'name' => 'test-server',
'server_type' => 'cx11',
'image' => 1,
'location' => 'nbg1',
'start_after_create' => true,
'ssh_keys' => [123],
'public_net' => [
'enable_ipv4' => true,
'enable_ipv6' => true,
],
];
// Add cloud-init script if provided
if (! empty($cloudInitScript)) {
$params['user_data'] = $cloudInitScript;
}
expect($params)->not->toHaveKey('user_data');
});
it('validates cloud-init script is not included when empty string', function () {
$cloudInitScript = '';
$params = [
'name' => 'test-server',
'server_type' => 'cx11',
'image' => 1,
'location' => 'nbg1',
'start_after_create' => true,
'ssh_keys' => [123],
'public_net' => [
'enable_ipv4' => true,
'enable_ipv6' => true,
],
];
// Add cloud-init script if provided
if (! empty($cloudInitScript)) {
$params['user_data'] = $cloudInitScript;
}
expect($params)->not->toHaveKey('user_data');
});
it('validates cloud-init script with multiline content', function () {
$cloudInitScript = "#cloud-config\n\npackages:\n - nginx\n - git\n\nruncmd:\n - systemctl start nginx";
$params = [
'name' => 'test-server',
'server_type' => 'cx11',
'image' => 1,
'location' => 'nbg1',
'start_after_create' => true,
'ssh_keys' => [123],
'public_net' => [
'enable_ipv4' => true,
'enable_ipv6' => true,
],
];
// Add cloud-init script if provided
if (! empty($cloudInitScript)) {
$params['user_data'] = $cloudInitScript;
}
expect($params)
->toHaveKey('user_data')
->and($params['user_data'])->toContain('#cloud-config')
->and($params['user_data'])->toContain('packages:')
->and($params['user_data'])->toContain('runcmd:');
});

View file

@ -0,0 +1,136 @@
<?php
// Note: Full Livewire integration tests require database setup
// These tests verify the SSH key merging logic and public_net configuration
it('validates public_net configuration with IPv4 and IPv6 enabled', function () {
$enableIpv4 = true;
$enableIpv6 = true;
$publicNet = [
'enable_ipv4' => $enableIpv4,
'enable_ipv6' => $enableIpv6,
];
expect($publicNet)->toBe([
'enable_ipv4' => true,
'enable_ipv6' => true,
]);
});
it('validates public_net configuration with IPv4 only', function () {
$enableIpv4 = true;
$enableIpv6 = false;
$publicNet = [
'enable_ipv4' => $enableIpv4,
'enable_ipv6' => $enableIpv6,
];
expect($publicNet)->toBe([
'enable_ipv4' => true,
'enable_ipv6' => false,
]);
});
it('validates public_net configuration with IPv6 only', function () {
$enableIpv4 = false;
$enableIpv6 = true;
$publicNet = [
'enable_ipv4' => $enableIpv4,
'enable_ipv6' => $enableIpv6,
];
expect($publicNet)->toBe([
'enable_ipv4' => false,
'enable_ipv6' => true,
]);
});
it('validates IP address selection prefers IPv4 when both are enabled', function () {
$enableIpv4 = true;
$enableIpv6 = true;
$hetznerServer = [
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
];
$ipAddress = null;
if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
} elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
expect($ipAddress)->toBe('1.2.3.4');
});
it('validates IP address selection uses IPv6 when only IPv6 is enabled', function () {
$enableIpv4 = false;
$enableIpv6 = true;
$hetznerServer = [
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
];
$ipAddress = null;
if ($enableIpv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
} elseif ($enableIpv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
expect($ipAddress)->toBe('2001:db8::1');
});
it('validates SSH key array merging logic with Coolify key', function () {
$coolifyKeyId = 123;
$selectedHetznerKeys = [];
$sshKeys = array_merge(
[$coolifyKeyId],
$selectedHetznerKeys
);
$sshKeys = array_unique($sshKeys);
$sshKeys = array_values($sshKeys);
expect($sshKeys)->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);
});

View file

@ -0,0 +1,76 @@
<?php
// Unit tests for cloud-init script validation logic
it('validates cloud-init script is optional', function () {
$cloudInitScript = null;
$isRequired = false;
$hasValue = ! empty($cloudInitScript);
expect($isRequired)->toBeFalse()
->and($hasValue)->toBeFalse();
});
it('validates cloud-init script name is required when saving', function () {
$saveScript = true;
$scriptName = 'My Installation Script';
$isNameRequired = $saveScript;
$hasName = ! empty($scriptName);
expect($isNameRequired)->toBeTrue()
->and($hasName)->toBeTrue();
});
it('validates cloud-init script description is optional', function () {
$scriptDescription = null;
$isDescriptionRequired = false;
$hasDescription = ! empty($scriptDescription);
expect($isDescriptionRequired)->toBeFalse()
->and($hasDescription)->toBeFalse();
});
it('validates save_cloud_init_script must be boolean', function () {
$saveCloudInitScript = true;
expect($saveCloudInitScript)->toBeBool();
});
it('validates save_cloud_init_script defaults to false', function () {
$saveCloudInitScript = false;
expect($saveCloudInitScript)->toBeFalse();
});
it('validates cloud-init script can be a bash script', function () {
$cloudInitScript = "#!/bin/bash\napt-get update\napt-get install -y nginx";
expect($cloudInitScript)->toBeString()
->and($cloudInitScript)->toContain('#!/bin/bash');
});
it('validates cloud-init script can be cloud-config yaml', function () {
$cloudInitScript = "#cloud-config\npackages:\n - nginx\n - git";
expect($cloudInitScript)->toBeString()
->and($cloudInitScript)->toContain('#cloud-config');
});
it('validates script name max length is 255 characters', function () {
$scriptName = str_repeat('a', 255);
expect(strlen($scriptName))->toBe(255)
->and(strlen($scriptName))->toBeLessThanOrEqual(255);
});
it('validates script name exceeding 255 characters should be invalid', function () {
$scriptName = str_repeat('a', 256);
$isValid = strlen($scriptName) <= 255;
expect($isValid)->toBeFalse()
->and(strlen($scriptName))->toBeGreaterThan(255);
});

View file

@ -0,0 +1,68 @@
<?php
use App\View\Components\Forms\Datalist;
it('renders with default properties', function () {
$component = new Datalist;
expect($component->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();
});

View file

@ -0,0 +1,114 @@
<?php
use App\Notifications\Server\HetznerDeletionFailed;
use Mockery;
afterEach(function () {
Mockery::close();
});
it('can be instantiated with correct properties', function () {
$notification = new HetznerDeletionFailed(
hetznerServerId: 12345,
teamId: 1,
errorMessage: 'Hetzner API error: Server not found'
);
expect($notification)->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');
});

Some files were not shown because too many files have changed in this diff Show more