Merge pull request #6817 from coollabsio/hetzner-do
Hetzner integration
This commit is contained in:
commit
95fe04c484
103 changed files with 4443 additions and 398 deletions
|
|
@ -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
|
||||
|
|
|
|||
37
.github/workflows/coolify-staging-build.yml
vendored
37
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal file
83
app/Console/Commands/ClearGlobalSearchCache.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/Events/ServerValidated.php
Normal file
51
app/Events/ServerValidated.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ServerValidated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public function __construct(?int $teamId = null, ?string $serverUuid = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
$this->serverUuid = $serverUuid;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ServerValidated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'teamId' => $this->teamId,
|
||||
'serverUuid' => $this->serverUuid,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -746,7 +746,13 @@ public function delete_server(Request $request)
|
|||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
$server->delete();
|
||||
DeleteServer::dispatch($server);
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
false, // Don't delete from Hetzner via API
|
||||
$server->hetzner_server_id,
|
||||
$server->cloud_provider_token_id,
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
162
app/Jobs/ValidateAndInstallServerJob.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateAndInstallServerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 600; // 10 minutes
|
||||
|
||||
public int $maxTries = 3;
|
||||
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
public int $numberOfTries = 0
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Mark validation as in progress
|
||||
$this->server->update(['is_validating' => true]);
|
||||
|
||||
Log::info('ValidateAndInstallServer: Starting validation', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Validate connection
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $uptime) {
|
||||
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Server not reachable', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate OS
|
||||
$supportedOsType = $this->server->validateOS();
|
||||
if (! $supportedOsType) {
|
||||
$errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: OS not supported', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Docker is installed
|
||||
$dockerInstalled = $this->server->validateDockerEngine();
|
||||
$dockerComposeInstalled = $this->server->validateDockerCompose();
|
||||
|
||||
if (! $dockerInstalled || ! $dockerComposeInstalled) {
|
||||
// Try to install Docker
|
||||
if ($this->numberOfTries >= $this->maxTries) {
|
||||
$errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempts' => $this->numberOfTries,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('ValidateAndInstallServer: Installing Docker', [
|
||||
'server_id' => $this->server->id,
|
||||
'attempt' => $this->numberOfTries + 1,
|
||||
]);
|
||||
|
||||
// Install Docker
|
||||
$this->server->installDocker();
|
||||
|
||||
// Retry validation after installation
|
||||
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate Docker version
|
||||
$dockerVersion = $this->server->validateDockerEngineVersion();
|
||||
if (! $dockerVersion) {
|
||||
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
$errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::error('ValidateAndInstallServer: Docker version not sufficient', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation successful!
|
||||
Log::info('ValidateAndInstallServer: Validation successful', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
// Start proxy if needed
|
||||
if (! $this->server->isBuildServer()) {
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if ($proxyShouldRun) {
|
||||
StartProxy::dispatch($this->server);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark validation as complete
|
||||
$this->server->update(['is_validating' => false]);
|
||||
|
||||
// Refresh server to get latest state
|
||||
$this->server->refresh();
|
||||
|
||||
// Broadcast events to update UI
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ValidateAndInstallServer: Exception occurred', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->server->update([
|
||||
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
|
||||
'is_validating' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
101
app/Livewire/Security/CloudInitScriptForm.php
Normal file
101
app/Livewire/Security/CloudInitScriptForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
52
app/Livewire/Security/CloudInitScripts.php
Normal file
52
app/Livewire/Security/CloudInitScripts.php
Normal 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');
|
||||
}
|
||||
}
|
||||
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal file
99
app/Livewire/Security/CloudProviderTokenForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
60
app/Livewire/Security/CloudProviderTokens.php
Normal file
60
app/Livewire/Security/CloudProviderTokens.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Security/CloudTokens.php
Normal file
13
app/Livewire/Security/CloudTokens.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal file
144
app/Livewire/Server/CloudProviderToken/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
565
app/Livewire/Server/New/ByHetzner.php
Normal file
565
app/Livewire/Server/New/ByHetzner.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
33
app/Models/CloudInitScript.php
Normal file
33
app/Models/CloudInitScript.php
Normal 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());
|
||||
}
|
||||
}
|
||||
41
app/Models/CloudProviderToken.php
Normal file
41
app/Models/CloudProviderToken.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
71
app/Notifications/Server/HetznerDeletionFailed.php
Normal file
71
app/Notifications/Server/HetznerDeletionFailed.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/Policies/CloudInitScriptPolicy.php
Normal file
65
app/Policies/CloudInitScriptPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
65
app/Policies/CloudProviderTokenPolicy.php
Normal file
65
app/Policies/CloudProviderTokenPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
55
app/Rules/ValidCloudInitYaml.php
Normal file
55
app/Rules/ValidCloudInitYaml.php
Normal 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
114
app/Rules/ValidHostname.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Services/HetznerService.php
Normal file
143
app/Services/HetznerService.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ trait HasNotificationSettings
|
|||
'general',
|
||||
'test',
|
||||
'ssl_certificate_renewal',
|
||||
'hetzner_deletion_failure',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
// Change the default value for the 'image' column
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->string('image')->default('bitnamilegacy/clickhouse')->change();
|
||||
});
|
||||
// Optionally, update any existing rows with the old default to the new one
|
||||
DB::table('standalone_clickhouses')
|
||||
->where('image', 'bitnami/clickhouse')
|
||||
->update(['image' => 'bitnamilegacy/clickhouse']);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->string('image')->default('bitnami/clickhouse')->change();
|
||||
});
|
||||
// Optionally, revert any changed values
|
||||
DB::table('standalone_clickhouses')
|
||||
->where('image', 'bitnamilegacy/clickhouse')
|
||||
->update(['image' => 'bitnami/clickhouse']);
|
||||
}
|
||||
};
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
// Change the default value for the 'image' column
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->string('image')->default('bitnamilegacy/clickhouse')->change();
|
||||
});
|
||||
// Optionally, update any existing rows with the old default to the new one
|
||||
DB::table('standalone_clickhouses')
|
||||
->where('image', 'bitnami/clickhouse')
|
||||
->update(['image' => 'bitnamilegacy/clickhouse']);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('standalone_clickhouses', function (Blueprint $table) {
|
||||
$table->string('image')->default('bitnami/clickhouse')->change();
|
||||
});
|
||||
// Optionally, revert any changed values
|
||||
DB::table('standalone_clickhouses')
|
||||
->where('image', 'bitnamilegacy/clickhouse')
|
||||
->update(['image' => 'bitnami/clickhouse']);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('hetzner_server_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->boolean('is_validating')->default(false)->after('hetzner_server_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('is_validating');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
6
public/svgs/hetzner.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<!-- Hetzner red background -->
|
||||
<rect width="200" height="200" fill="#D50C2D" rx="8"/>
|
||||
<!-- Hetzner "H" logo in white -->
|
||||
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') }}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('('))
|
||||
|
|
|
|||
|
|
@ -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() }}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
13
resources/views/emails/hetzner-deletion-failed.blade.php
Normal file
13
resources/views/emails/hetzner-deletion-failed.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
7
resources/views/livewire/security/cloud-tokens.blade.php
Normal file
7
resources/views/livewire/security/cloud-tokens.blade.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Cloud Tokens | Coolify
|
||||
</x-slot>
|
||||
<x-security.navbar />
|
||||
<livewire:security.cloud-provider-tokens />
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
200
resources/views/livewire/server/new/by-hetzner.blade.php
Normal file
200
resources/views/livewire/server/new/by-hetzner.blade.php
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
101
tests/Feature/CloudInitScriptTest.php
Normal file
101
tests/Feature/CloudInitScriptTest.php
Normal 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:');
|
||||
});
|
||||
136
tests/Feature/HetznerServerCreationTest.php
Normal file
136
tests/Feature/HetznerServerCreationTest.php
Normal 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);
|
||||
});
|
||||
76
tests/Unit/CloudInitScriptValidationTest.php
Normal file
76
tests/Unit/CloudInitScriptValidationTest.php
Normal 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);
|
||||
});
|
||||
68
tests/Unit/DatalistComponentTest.php
Normal file
68
tests/Unit/DatalistComponentTest.php
Normal 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();
|
||||
});
|
||||
114
tests/Unit/HetznerDeletionFailedNotificationTest.php
Normal file
114
tests/Unit/HetznerDeletionFailedNotificationTest.php
Normal 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
Loading…
Reference in a new issue