mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 22:23:42 +00:00
feat(v5): add server bootstrap job with realtime cluster broadcasting
- Add V5BootstrapServerJob: SSH into new servers, install coold/WireGuard, track bootstrap progress via last_bootstrap_action/status/output fields - Add V5ClusterUpdated event: broadcasts full cluster+servers payload to team private channel on bootstrap state changes - Add V5RealtimeTestEvent + RealtimeTest page for WebSocket connectivity testing - Consolidate 3 dropped migrations into base cluster/server migrations: WireGuard config, builder CPU quota, status check fields, uuid - Auto-generate uuid on V5Model create via Schema::hasColumn guard - Expose currentTeam.id in Inertia shared props - Add showCluster, realtimeTest, broadcastRealtimeTest controller endpoints - Add dropdown-menu UI component
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5ClusterUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public int $teamId, public int $clusterId) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.cluster.updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cluster: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$cluster = V5Cluster::query()
|
||||
->where('team_id', $this->teamId)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->find($this->clusterId);
|
||||
|
||||
return [
|
||||
'cluster' => $cluster instanceof V5Cluster ? $this->serializeCluster($cluster) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeCluster(V5Cluster $cluster): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $cluster->id,
|
||||
'name' => $cluster->name,
|
||||
'description' => $cluster->description,
|
||||
'wireguardInterface' => $cluster->wireguard_interface,
|
||||
'wireguardManagementPool' => $cluster->wireguard_management_pool,
|
||||
'wireguardListenPort' => $cluster->wireguard_listen_port,
|
||||
'containerNetworkPool' => $cluster->container_network_pool,
|
||||
'containerNetworkPrefix' => $cluster->container_network_prefix,
|
||||
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
|
||||
'defaultDenyContainers' => $cluster->default_deny_containers,
|
||||
'cooldVersion' => $cluster->coold_version,
|
||||
'corrosionVersion' => $cluster->corrosion_version,
|
||||
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
|
||||
'corrosionApiPort' => $cluster->corrosion_api_port,
|
||||
'builderEnabled' => $cluster->builder_enabled,
|
||||
'builderCapacity' => $cluster->builder_capacity,
|
||||
'builderCpuQuota' => $cluster->builder_cpu_quota,
|
||||
'builderMemoryMax' => $cluster->builder_memory_max,
|
||||
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
|
||||
'lastCliAction' => $cluster->last_cli_action,
|
||||
'lastCliStatus' => $cluster->last_cli_status,
|
||||
'lastCliSummary' => $cluster->last_cli_summary,
|
||||
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
|
||||
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
|
||||
'servers' => $cluster->servers->map(fn (V5Server $server) => [
|
||||
'id' => (string) $server->id,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
'capabilities' => $server->capabilities ?? [],
|
||||
'builderEnabled' => $server->builder_enabled,
|
||||
'builderCapacity' => $server->builder_capacity,
|
||||
'builderCpuQuota' => $server->builder_cpu_quota,
|
||||
'uuid' => $server->uuid,
|
||||
'nodeAddress' => $server->node_address,
|
||||
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
|
||||
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
|
||||
'wireguardManagementIp' => $server->wireguard_management_ip,
|
||||
'wireguardPublicKey' => $server->wireguard_public_key,
|
||||
'containerSubnets' => $server->container_subnets ?? [],
|
||||
'privateKeyName' => $server->privateKey?->name,
|
||||
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
|
||||
'lastBootstrapAction' => $server->last_bootstrap_action,
|
||||
'lastBootstrapStatus' => $server->last_bootstrap_status,
|
||||
'lastBootstrapOutput' => $server->last_bootstrap_output,
|
||||
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5RealtimeTestEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public string $sentAt;
|
||||
|
||||
public function __construct(public int $teamId, public string $message)
|
||||
{
|
||||
$this->sentAt = now()->toJSON();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.realtime.test';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{message: string, teamId: int, sentAt: string}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => $this->message,
|
||||
'teamId' => $this->teamId,
|
||||
'sentAt' => $this->sentAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
@@ -55,6 +58,56 @@ class DashboardController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function showCluster(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function realtimeTest(Request $request): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
if (! $currentTeam instanceof Team) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return Inertia::render('RealtimeTest', [
|
||||
'currentTeam' => [
|
||||
'id' => $currentTeam->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function broadcastRealtimeTest(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
if (! $currentTeam instanceof Team) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
V5RealtimeTestEvent::dispatch(
|
||||
$currentTeam->id,
|
||||
$validated['message'] ?? 'Manual v5 realtime test'
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Realtime test event broadcasted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function updateSelection(Request $request): \Illuminate\Http\Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
@@ -115,7 +168,9 @@ class DashboardController extends Controller
|
||||
'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, true)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_memory_max' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'],
|
||||
@@ -159,12 +214,18 @@ class DashboardController extends Controller
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (in_array($server->last_bootstrap_status, ['queued', 'running'], true)) {
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
'message' => 'Bootstrap is already queued or running for this server.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$action = $installedServers->isEmpty() ? 'bootstrap' : 'extend';
|
||||
$server->load('privateKey');
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
@@ -177,75 +238,20 @@ class DashboardController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$cluster->update([
|
||||
'last_cli_action' => $action,
|
||||
'last_cli_status' => 'running',
|
||||
'last_cli_summary' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_cli_ran_at' => now(),
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id);
|
||||
V5BootstrapServerJob::dispatch($cluster->id, $server->id);
|
||||
|
||||
$tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16);
|
||||
if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) {
|
||||
$cluster->update([
|
||||
'last_cli_status' => 'failed',
|
||||
'last_cli_summary' => 'Could not create a temporary SSH configuration directory.',
|
||||
'last_cli_ran_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
], 500);
|
||||
}
|
||||
|
||||
try {
|
||||
$sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory);
|
||||
$result = Process::timeout(max(60, (int) $cluster->builder_timeout_secs + 120))
|
||||
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$successful = $result->successful();
|
||||
} catch (\Throwable $e) {
|
||||
$output = $e->getMessage();
|
||||
$successful = false;
|
||||
} finally {
|
||||
$this->deleteDirectory($tempDirectory);
|
||||
}
|
||||
|
||||
$cluster->update([
|
||||
'last_cli_action' => $action,
|
||||
'last_cli_status' => $successful ? 'succeeded' : 'failed',
|
||||
'last_cli_summary' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(),
|
||||
'last_cli_ran_at' => now(),
|
||||
]);
|
||||
|
||||
if ($successful) {
|
||||
$capabilities = collect($server->capabilities ?? [])
|
||||
->push('coold')
|
||||
->when($server->builder_enabled, fn ($capabilities) => $capabilities->push('builder'))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$server->update([
|
||||
'status' => 'installed',
|
||||
'capabilities' => $capabilities,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
];
|
||||
|
||||
if (! $successful) {
|
||||
$payload['message'] = $cluster->last_cli_summary;
|
||||
}
|
||||
|
||||
return response()->json($payload, $successful ? 200 : 500);
|
||||
'message' => 'Bootstrap queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function storeServer(Request $request, V5Cluster $cluster): JsonResponse
|
||||
@@ -275,7 +281,9 @@ class DashboardController extends Controller
|
||||
],
|
||||
'node_address' => ['nullable', 'string', 'max:255'],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, $cluster->builder_enabled)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'],
|
||||
@@ -295,10 +303,10 @@ class DashboardController extends Controller
|
||||
'ssh_user' => $validated['ssh_user'],
|
||||
'ssh_port' => $validated['ssh_port'],
|
||||
'private_key_id' => $validated['private_key_id'] ?? null,
|
||||
'status' => 'pending',
|
||||
'status' => 'added',
|
||||
'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'],
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => $builderEnabled ? $builderCapacity : 0,
|
||||
'builder_capacity' => $builderCapacity,
|
||||
'builder_cpu_quota' => $builderCpuQuota,
|
||||
'node_address' => $validated['node_address'] ?? $validated['host'],
|
||||
'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'],
|
||||
@@ -330,7 +338,10 @@ class DashboardController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'builder_enabled' => ['required', 'boolean'],
|
||||
'builder_capacity' => ['required', 'integer', 'min:0', 'max:1000'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$request->boolean('builder_enabled'),
|
||||
required: true
|
||||
),
|
||||
'builder_cpu_quota' => ['required', 'string', 'max:32'],
|
||||
]);
|
||||
|
||||
@@ -346,7 +357,7 @@ class DashboardController extends Controller
|
||||
$server->update([
|
||||
'capabilities' => $capabilities,
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => $builderEnabled ? (int) $validated['builder_capacity'] : 0,
|
||||
'builder_capacity' => (int) $validated['builder_capacity'],
|
||||
'builder_cpu_quota' => $validated['builder_cpu_quota'],
|
||||
]);
|
||||
|
||||
@@ -374,14 +385,10 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
$server->update([
|
||||
'last_status_check' => 'failed',
|
||||
'last_status_output' => 'No private key is attached to this server.',
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
'status' => 'failed',
|
||||
'output' => 'No private key is attached to this server.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -392,14 +399,10 @@ class DashboardController extends Controller
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
$server->update([
|
||||
'last_status_check' => 'failed',
|
||||
'last_status_output' => 'Could not create a temporary SSH key file.',
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
'status' => 'failed',
|
||||
'output' => 'Could not create a temporary SSH key file.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -440,14 +443,10 @@ class DashboardController extends Controller
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'last_status_check' => $status,
|
||||
'last_status_output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => $this->freshSerializedCluster($cluster),
|
||||
'status' => $status,
|
||||
'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -464,12 +463,6 @@ class DashboardController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($server->last_bootstrapped_at !== null) {
|
||||
return response()->json([
|
||||
'message' => 'Only unbootstrapped servers can be deleted.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$server->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -800,6 +793,7 @@ class DashboardController extends Controller
|
||||
'builderEnabled' => $server->builder_enabled,
|
||||
'builderCapacity' => $server->builder_capacity,
|
||||
'builderCpuQuota' => $server->builder_cpu_quota,
|
||||
'uuid' => $server->uuid,
|
||||
'nodeAddress' => $server->node_address,
|
||||
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
|
||||
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
|
||||
@@ -808,9 +802,10 @@ class DashboardController extends Controller
|
||||
'containerSubnets' => $server->container_subnets ?? [],
|
||||
'privateKeyName' => $server->privateKey?->name,
|
||||
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
|
||||
'lastStatusCheck' => $server->last_status_check,
|
||||
'lastStatusOutput' => $server->last_status_output,
|
||||
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
|
||||
'lastBootstrapAction' => $server->last_bootstrap_action,
|
||||
'lastBootstrapStatus' => $server->last_bootstrap_status,
|
||||
'lastBootstrapOutput' => $server->last_bootstrap_output,
|
||||
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
@@ -875,6 +870,28 @@ class DashboardController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function builderCapacityRules(bool $builderEnabled, bool $required = false): array
|
||||
{
|
||||
return [
|
||||
$required ? 'required' : 'sometimes',
|
||||
'integer',
|
||||
$builderEnabled ? 'min:1' : 'min:0',
|
||||
'max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
private function requestedBuilderEnabled(Request $request, bool $default): bool
|
||||
{
|
||||
if (! $request->has('builder_enabled')) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $request->boolean('builder_enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}> $projects
|
||||
* @return array{0: array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}|null, 1: array{uuid: string, name: string}|null}
|
||||
|
||||
@@ -20,6 +20,9 @@ class HandleInertiaRequests extends Middleware
|
||||
'email' => $request->user()->email,
|
||||
] : null,
|
||||
],
|
||||
'currentTeam' => $request->attributes->get('v5.currentTeam') ? [
|
||||
'id' => $request->attributes->get('v5.currentTeam')->id,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const BOOTSTRAP_MARKER_PATH = '/etc/coolify/v5-node.json';
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 7200;
|
||||
|
||||
public function __construct(public int $clusterId, public int $serverId) {}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping("v5-bootstrap-server-{$this->serverId}"))->expireAfter(7200)->dontRelease()];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cluster = V5Cluster::query()->findOrFail($this->clusterId);
|
||||
$server = V5Server::query()->with('privateKey')->findOrFail($this->serverId);
|
||||
|
||||
if ($server->cluster_id !== $cluster->id || $server->last_bootstrapped_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$action = $installedServers->isEmpty() ? 'bootstrap' : 'extend';
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
$this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16);
|
||||
if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) {
|
||||
$this->markFailed($server, $action, 'Could not create a temporary SSH configuration directory.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory);
|
||||
$existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation);
|
||||
|
||||
if (($existingBootstrap['cluster_id'] ?? null) !== null) {
|
||||
if ((string) $existingBootstrap['cluster_id'] !== (string) $cluster->id) {
|
||||
$this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->adoptExistingBootstrap($server, $existingBootstrap);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = Process::timeout(max(60, (int) $cluster->builder_timeout_secs + 120))
|
||||
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$successful = $result->successful();
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => $successful ? 'succeeded' : 'failed',
|
||||
'last_bootstrap_output' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
if (! $successful) {
|
||||
return;
|
||||
}
|
||||
|
||||
$capabilities = collect($server->capabilities ?? [])
|
||||
->push('coold')
|
||||
->when($server->builder_enabled, fn ($capabilities) => $capabilities->push('builder'))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$server->update([
|
||||
'status' => 'installed',
|
||||
'capabilities' => $capabilities,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation);
|
||||
} catch (\Throwable $e) {
|
||||
$this->markFailed($server, $action, $e->getMessage());
|
||||
} finally {
|
||||
$this->deleteDirectory($tempDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markFailed($server, $server->last_bootstrap_action ?? 'bootstrap', $exception?->getMessage() ?? 'Bootstrap job failed.');
|
||||
|
||||
Log::warning('V5 server bootstrap job failed', [
|
||||
'server_id' => $this->serverId,
|
||||
'cluster_id' => $this->clusterId,
|
||||
'exception' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function markFailed(V5Server $server, string $action, string $output): void
|
||||
{
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_output' => str($output)->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
}
|
||||
|
||||
private function broadcastClusterUpdated(V5Server $server): void
|
||||
{
|
||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||
}
|
||||
|
||||
private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array
|
||||
{
|
||||
$command = [
|
||||
$this->coolifyCliBin(),
|
||||
'init',
|
||||
$action,
|
||||
'--format',
|
||||
'table',
|
||||
'--nodes',
|
||||
$servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','),
|
||||
'--ssh-config',
|
||||
$sshConfigLocation,
|
||||
'--namespaces',
|
||||
implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES),
|
||||
'--container-pool',
|
||||
$cluster->container_network_pool,
|
||||
'--container-prefix',
|
||||
(string) $cluster->container_network_prefix,
|
||||
'--wg-mgmt-pool',
|
||||
$cluster->wireguard_management_pool,
|
||||
'--wg-interface',
|
||||
$cluster->wireguard_interface,
|
||||
'--wg-listen-port',
|
||||
(string) $cluster->wireguard_listen_port,
|
||||
'--coold-version',
|
||||
$cluster->coold_version,
|
||||
'--corrosion-version',
|
||||
$cluster->corrosion_version,
|
||||
'--corrosion-gossip-port',
|
||||
(string) $cluster->corrosion_gossip_port,
|
||||
'--corrosion-api-port',
|
||||
(string) $cluster->corrosion_api_port,
|
||||
];
|
||||
|
||||
if ($action === 'extend') {
|
||||
array_push($command, '--new-nodes', $this->bootstrapNode($newServer));
|
||||
}
|
||||
|
||||
$listenOverrides = $this->wireguardListenPortOverrides($servers);
|
||||
if ($listenOverrides !== '') {
|
||||
array_push($command, '--wg-listen-port-overrides', $listenOverrides);
|
||||
}
|
||||
|
||||
$endpointOverrides = $this->wireguardEndpointOverrides($servers);
|
||||
if ($endpointOverrides !== '') {
|
||||
array_push($command, '--wg-endpoint-overrides', $endpointOverrides);
|
||||
}
|
||||
|
||||
if (! $cluster->default_deny_containers) {
|
||||
$command[] = '--skip-default-deny';
|
||||
}
|
||||
|
||||
$builderServers = $servers->filter(fn (V5Server $server) => $server->builder_enabled);
|
||||
if ($cluster->builder_enabled && $builderServers->isNotEmpty()) {
|
||||
array_push(
|
||||
$command,
|
||||
'--enable-builder',
|
||||
'--builder-hosts',
|
||||
$builderServers
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server))
|
||||
->implode(','),
|
||||
'--builder-capacity',
|
||||
(string) $cluster->builder_capacity,
|
||||
'--builder-cpu-quota',
|
||||
$cluster->builder_cpu_quota,
|
||||
'--builder-memory-max',
|
||||
$cluster->builder_memory_max,
|
||||
'--builder-timeout-secs',
|
||||
(string) $cluster->builder_timeout_secs,
|
||||
);
|
||||
}
|
||||
|
||||
$command[] = '--yes';
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function coolifyCliBin(): string
|
||||
{
|
||||
$configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify');
|
||||
$devBinary = base_path('.dev/bin/coolify');
|
||||
|
||||
if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) {
|
||||
return $devBinary;
|
||||
}
|
||||
|
||||
return $configuredBinary;
|
||||
}
|
||||
|
||||
private function isRunnableDevelopmentCliBinary(string $binary): bool
|
||||
{
|
||||
if (! is_file($binary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = file_get_contents($binary, false, null, 0, 4);
|
||||
|
||||
if ($header === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($header, '#!')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($header === "\x7FELF") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bootstrapNode(V5Server $server): string
|
||||
{
|
||||
return 'v5-server-'.($server->uuid ?: $server->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function detectExistingBootstrap(V5Server $server, string $sshConfigLocation): array
|
||||
{
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
'if [ -f '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' ]; then cat '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).'; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$output = trim($result->output());
|
||||
|
||||
if ($output === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $marker
|
||||
*/
|
||||
private function adoptExistingBootstrap(V5Server $server, array $marker): void
|
||||
{
|
||||
$serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null;
|
||||
$updates = [
|
||||
'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key,
|
||||
'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets,
|
||||
'status' => 'installed',
|
||||
'last_bootstrap_status' => 'succeeded',
|
||||
'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if ($serverUuid !== null && ! V5Server::query()->where('uuid', $serverUuid)->whereKeyNot($server->id)->exists()) {
|
||||
$updates['uuid'] = $serverUuid;
|
||||
}
|
||||
|
||||
$server->update($updates);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
}
|
||||
|
||||
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation): void
|
||||
{
|
||||
$payload = base64_encode(json_encode([
|
||||
'cluster_id' => $cluster->id,
|
||||
'server_uuid' => $server->uuid,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => $server->wireguard_public_key,
|
||||
'container_subnets' => $server->container_subnets ?? [],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
"payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string
|
||||
{
|
||||
$config = '';
|
||||
|
||||
$servers->each(function (V5Server $server) use (&$config, $tempDirectory): void {
|
||||
$keyLocation = "{$tempDirectory}/server-{$server->id}.key";
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$config .= implode("\n", [
|
||||
'Host '.$this->bootstrapNode($server),
|
||||
' HostName '.$server->host,
|
||||
' Port '.$server->ssh_port,
|
||||
' User '.$server->ssh_user,
|
||||
' IdentityFile '.$keyLocation,
|
||||
' IdentitiesOnly yes',
|
||||
' LogLevel ERROR',
|
||||
' StrictHostKeyChecking no',
|
||||
' UserKnownHostsFile /dev/null',
|
||||
' BatchMode yes',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
$sshConfigLocation = "{$tempDirectory}/ssh.config";
|
||||
file_put_contents($sshConfigLocation, $config);
|
||||
chmod($sshConfigLocation, 0600);
|
||||
|
||||
return $sshConfigLocation;
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($directory) ?: [] as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = "{$directory}/{$file}";
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardListenPortOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override)
|
||||
->implode(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardEndpointOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override)
|
||||
->implode(',');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class Server extends V5Model
|
||||
protected $table = 'v5_servers';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'cluster_id',
|
||||
'created_by_user_id',
|
||||
@@ -32,6 +33,10 @@ class Server extends V5Model
|
||||
'wireguard_public_key',
|
||||
'container_subnets',
|
||||
'last_bootstrapped_at',
|
||||
'last_bootstrap_action',
|
||||
'last_bootstrap_status',
|
||||
'last_bootstrap_output',
|
||||
'last_bootstrap_ran_at',
|
||||
'last_status_check',
|
||||
'last_status_output',
|
||||
'last_status_checked_at',
|
||||
@@ -44,6 +49,7 @@ class Server extends V5Model
|
||||
'builder_enabled' => 'boolean',
|
||||
'container_subnets' => 'array',
|
||||
'last_bootstrapped_at' => 'datetime',
|
||||
'last_bootstrap_ran_at' => 'datetime',
|
||||
'last_status_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,8 +3,21 @@
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
abstract class V5Model extends Model
|
||||
{
|
||||
//
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (Model $model): void {
|
||||
if (
|
||||
Schema::hasColumn($model->getTable(), 'uuid')
|
||||
&& ! $model->getAttribute('uuid')
|
||||
) {
|
||||
$model->setAttribute('uuid', new_public_id());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,26 @@ return new class extends Migration
|
||||
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('wireguard_interface')->default('wg0');
|
||||
$table->string('wireguard_management_pool')->default('100.64.0.0/16');
|
||||
$table->unsignedInteger('wireguard_listen_port')->default(51820);
|
||||
$table->string('container_network_pool')->default('10.210.0.0/16');
|
||||
$table->unsignedTinyInteger('container_network_prefix')->default(24);
|
||||
$table->json('namespaces')->nullable();
|
||||
$table->boolean('default_deny_containers')->default(true);
|
||||
$table->string('coold_version')->default('nightly');
|
||||
$table->string('corrosion_version')->default('v1.0.0');
|
||||
$table->unsignedInteger('corrosion_gossip_port')->default(8787);
|
||||
$table->unsignedInteger('corrosion_api_port')->default(8080);
|
||||
$table->boolean('builder_enabled')->default(true);
|
||||
$table->unsignedInteger('builder_capacity')->default(2);
|
||||
$table->string('builder_cpu_quota')->default('200%');
|
||||
$table->string('builder_memory_max')->default('2G');
|
||||
$table->unsignedInteger('builder_timeout_secs')->default(1800);
|
||||
$table->string('last_cli_action')->nullable();
|
||||
$table->string('last_cli_status')->nullable();
|
||||
$table->text('last_cli_summary')->nullable();
|
||||
$table->timestamp('last_cli_ran_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'name']);
|
||||
|
||||
@@ -13,6 +13,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('v5_servers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->nullable()->unique();
|
||||
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
|
||||
$table->foreignId('cluster_id')->nullable()->constrained('v5_clusters')->nullOnDelete();
|
||||
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
|
||||
@@ -25,7 +26,21 @@ return new class extends Migration
|
||||
$table->json('capabilities')->nullable();
|
||||
$table->boolean('builder_enabled')->default(false);
|
||||
$table->unsignedInteger('builder_capacity')->default(0);
|
||||
$table->string('builder_cpu_quota')->default('200%');
|
||||
$table->string('node_address')->nullable();
|
||||
$table->unsignedInteger('wireguard_listen_port_override')->nullable();
|
||||
$table->string('wireguard_endpoint_override')->nullable();
|
||||
$table->string('wireguard_management_ip')->nullable();
|
||||
$table->string('wireguard_public_key')->nullable();
|
||||
$table->json('container_subnets')->nullable();
|
||||
$table->timestamp('last_bootstrapped_at')->nullable();
|
||||
$table->string('last_bootstrap_action')->nullable();
|
||||
$table->string('last_bootstrap_status')->nullable();
|
||||
$table->text('last_bootstrap_output')->nullable();
|
||||
$table->timestamp('last_bootstrap_ran_at')->nullable();
|
||||
$table->string('last_status_check')->nullable();
|
||||
$table->text('last_status_output')->nullable();
|
||||
$table->timestamp('last_status_checked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'host', 'ssh_port']);
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<?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('v5_clusters', function (Blueprint $table) {
|
||||
$table->string('wireguard_interface')->default('wg0')->after('description');
|
||||
$table->string('wireguard_management_pool')->default('100.64.0.0/16')->after('wireguard_interface');
|
||||
$table->unsignedInteger('wireguard_listen_port')->default(51820)->after('wireguard_management_pool');
|
||||
$table->string('container_network_pool')->default('10.210.0.0/16')->after('wireguard_listen_port');
|
||||
$table->unsignedTinyInteger('container_network_prefix')->default(24)->after('container_network_pool');
|
||||
$table->json('namespaces')->nullable()->after('container_network_prefix');
|
||||
$table->boolean('default_deny_containers')->default(true)->after('namespaces');
|
||||
$table->string('coold_version')->default('nightly')->after('default_deny_containers');
|
||||
$table->string('corrosion_version')->default('v1.0.0')->after('coold_version');
|
||||
$table->unsignedInteger('corrosion_gossip_port')->default(8787)->after('corrosion_version');
|
||||
$table->unsignedInteger('corrosion_api_port')->default(8080)->after('corrosion_gossip_port');
|
||||
$table->boolean('builder_enabled')->default(true)->after('corrosion_api_port');
|
||||
$table->unsignedInteger('builder_capacity')->default(2)->after('builder_enabled');
|
||||
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
|
||||
$table->string('builder_memory_max')->default('2G')->after('builder_cpu_quota');
|
||||
$table->unsignedInteger('builder_timeout_secs')->default(1800)->after('builder_memory_max');
|
||||
$table->string('last_cli_action')->nullable()->after('builder_timeout_secs');
|
||||
$table->string('last_cli_status')->nullable()->after('last_cli_action');
|
||||
$table->text('last_cli_summary')->nullable()->after('last_cli_status');
|
||||
$table->timestamp('last_cli_ran_at')->nullable()->after('last_cli_summary');
|
||||
});
|
||||
|
||||
Schema::table('v5_servers', function (Blueprint $table) {
|
||||
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
|
||||
$table->string('node_address')->nullable()->after('builder_cpu_quota');
|
||||
$table->unsignedInteger('wireguard_listen_port_override')->nullable()->after('node_address');
|
||||
$table->string('wireguard_endpoint_override')->nullable()->after('wireguard_listen_port_override');
|
||||
$table->string('wireguard_management_ip')->nullable()->after('wireguard_endpoint_override');
|
||||
$table->string('wireguard_public_key')->nullable()->after('wireguard_management_ip');
|
||||
$table->json('container_subnets')->nullable()->after('wireguard_public_key');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('v5_servers', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'builder_cpu_quota',
|
||||
'node_address',
|
||||
'wireguard_listen_port_override',
|
||||
'wireguard_endpoint_override',
|
||||
'wireguard_management_ip',
|
||||
'wireguard_public_key',
|
||||
'container_subnets',
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('v5_clusters', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'wireguard_interface',
|
||||
'wireguard_management_pool',
|
||||
'wireguard_listen_port',
|
||||
'container_network_pool',
|
||||
'container_network_prefix',
|
||||
'namespaces',
|
||||
'default_deny_containers',
|
||||
'coold_version',
|
||||
'corrosion_version',
|
||||
'corrosion_gossip_port',
|
||||
'corrosion_api_port',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'builder_memory_max',
|
||||
'builder_timeout_secs',
|
||||
'last_cli_action',
|
||||
'last_cli_status',
|
||||
'last_cli_summary',
|
||||
'last_cli_ran_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
<?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
|
||||
{
|
||||
if (Schema::hasColumn('v5_servers', 'builder_cpu_quota')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('v5_servers', function (Blueprint $table) {
|
||||
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasColumn('v5_servers', 'builder_cpu_quota')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('v5_servers', function (Blueprint $table) {
|
||||
$table->dropColumn('builder_cpu_quota');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
<?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('v5_servers', function (Blueprint $table) {
|
||||
$table->string('last_status_check')->nullable()->after('last_bootstrapped_at');
|
||||
$table->text('last_status_output')->nullable()->after('last_status_check');
|
||||
$table->timestamp('last_status_checked_at')->nullable()->after('last_status_output');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('v5_servers', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'last_status_check',
|
||||
'last_status_output',
|
||||
'last_status_checked_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1353,6 +1353,7 @@ CREATE TABLE IF NOT EXISTS "v5_clusters" (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "v5_servers" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"uuid" TEXT,
|
||||
"team_id" INTEGER NOT NULL,
|
||||
"cluster_id" INTEGER,
|
||||
"created_by_user_id" INTEGER NOT NULL,
|
||||
@@ -1373,6 +1374,10 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
|
||||
"wireguard_public_key" TEXT,
|
||||
"container_subnets" JSON,
|
||||
"last_bootstrapped_at" TEXT,
|
||||
"last_bootstrap_action" TEXT,
|
||||
"last_bootstrap_status" TEXT,
|
||||
"last_bootstrap_output" TEXT,
|
||||
"last_bootstrap_ran_at" TEXT,
|
||||
"last_status_check" TEXT,
|
||||
"last_status_output" TEXT,
|
||||
"last_status_checked_at" TEXT,
|
||||
@@ -1494,6 +1499,7 @@ CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_cha
|
||||
CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id);
|
||||
|
||||
-- Migration records
|
||||
@@ -1811,8 +1817,5 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_v5_create_servers_table', 316);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_v5_create_clusters_table', 317);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers', 318);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table', 319);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_17_172845_add_status_check_fields_to_v5_servers_table', 320);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
|
||||
|
||||
+9
-1
@@ -4,7 +4,15 @@ This file documents the current local v5/coold dev setup in Coolify.
|
||||
|
||||
## Roles
|
||||
|
||||
- `scripts/dev.sh` owns local developer convenience.
|
||||
- `scripts/dev.sh` is the main developer-facing entrypoint. Use it for normal
|
||||
local workflows such as starting/stopping the stack, creating fresh dev state,
|
||||
inspecting Corrosion, managing firewall allow rules, and running example
|
||||
containers.
|
||||
- `scripts/coold-vm.sh` is a lower-level Lima VM helper used by `scripts/dev.sh`.
|
||||
It exists separately to keep VM lifecycle and guest setup details out of the
|
||||
main dev orchestration script. Call it directly only when debugging or
|
||||
operating an individual VM, for example `shell`, `status`, `logs-agent`, or
|
||||
`delete`.
|
||||
- Lima VMs act like real deployment servers.
|
||||
- `coolify init bootstrap` owns host wiring:
|
||||
- WireGuard
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--default-ring-color: var(--ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -140,6 +141,11 @@
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
@apply border-border outline-ring/50;
|
||||
--tw-ring-offset-color: var(--background);
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
+416
-228
@@ -1,9 +1,18 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DotsThreeIcon } from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { AppNavbar } from '@/components/app-navbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -15,6 +24,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { csrfToken } from '@/lib/csrf';
|
||||
import { usePendingIds } from '@/lib/use-pending-ids';
|
||||
import type { V5Cluster, V5DashboardProps, V5Server } from '@/types';
|
||||
@@ -67,18 +77,49 @@ type UpdateServerResponse = {
|
||||
};
|
||||
|
||||
type CheckServerResponse = {
|
||||
cluster: V5Cluster;
|
||||
status: string;
|
||||
output: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
type DeleteServerResponse = {
|
||||
cluster: V5Cluster;
|
||||
};
|
||||
|
||||
|
||||
type BootstrapServerResponse = {
|
||||
cluster?: V5Cluster;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ServerSshCheck = {
|
||||
status: string;
|
||||
output: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
type V5ClusterUpdatedEvent = {
|
||||
cluster: V5Cluster | null;
|
||||
};
|
||||
|
||||
type EchoChannel = {
|
||||
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
|
||||
subscribed?: (callback: () => void) => EchoChannel;
|
||||
error?: (callback: (error: unknown) => void) => EchoChannel;
|
||||
};
|
||||
|
||||
type EchoClient = {
|
||||
private: (channel: string) => EchoChannel;
|
||||
leave?: (channel: string) => void;
|
||||
leaveChannel?: (channel: string) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Echo?: EchoClient;
|
||||
}
|
||||
}
|
||||
|
||||
const clusterDefaults = {
|
||||
wireguardInterface: 'wg0',
|
||||
wireguardManagementPool: '100.64.0.0/16',
|
||||
@@ -109,16 +150,9 @@ function formatDate(value: string | null): string {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function normalizeCapabilities(capabilities: string[]): string {
|
||||
if (capabilities.length === 0) {
|
||||
return 'No capabilities';
|
||||
}
|
||||
|
||||
return capabilities.join(', ');
|
||||
}
|
||||
|
||||
export default function Clusters({
|
||||
flux,
|
||||
currentTeam = null,
|
||||
clusters = [],
|
||||
privateKeys = [],
|
||||
projects = [],
|
||||
@@ -167,6 +201,8 @@ export default function Clusters({
|
||||
const [isServerSubmitting, setIsServerSubmitting] = useState(false);
|
||||
const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false);
|
||||
const checkingServers = usePendingIds<string>();
|
||||
const [sshChecks, setSshChecks] = useState<Record<string, ServerSshCheck>>({});
|
||||
const [visibleBootstrapLogs, setVisibleBootstrapLogs] = useState<Record<string, boolean>>({});
|
||||
const bootstrappingServers = usePendingIds<string>();
|
||||
const [bootstrapServerError, setBootstrapServerError] = useState<string | null>(null);
|
||||
const deletingServers = usePendingIds<string>();
|
||||
@@ -186,6 +222,106 @@ export default function Clusters({
|
||||
[clusterList, selectedClusterId],
|
||||
);
|
||||
|
||||
const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? [];
|
||||
const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? [];
|
||||
const hasBootstrapInProgress =
|
||||
selectedCluster?.servers.some((server) => ['queued', 'running'].includes(server.lastBootstrapStatus ?? '')) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTeam) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let attempts = 0;
|
||||
const channelName = `team.${currentTeam.id}`;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
|
||||
if (!window.Echo) {
|
||||
if (attempts === 1) {
|
||||
console.debug('Waiting for window.Echo before subscribing to cluster updates');
|
||||
}
|
||||
|
||||
if (attempts >= 20) {
|
||||
window.clearInterval(interval);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(interval);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.subscribed?.(() => console.debug(`Subscribed to private-${channelName} for cluster updates`));
|
||||
channel.error?.((error) => console.error(`Subscription error on private-${channelName}`, error));
|
||||
channel.listen('.v5.cluster.updated', (payload) => {
|
||||
const event = payload as V5ClusterUpdatedEvent;
|
||||
|
||||
if (!event.cluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)),
|
||||
);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
|
||||
};
|
||||
}, [currentTeam]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCluster || !hasBootstrapInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
async function refreshCluster(): Promise<void> {
|
||||
if (!selectedCluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok || isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { cluster?: V5Cluster };
|
||||
|
||||
if (payload.cluster) {
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === payload.cluster?.id ? payload.cluster : cluster)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => void refreshCluster(), 3000);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hasBootstrapInProgress, selectedCluster]);
|
||||
|
||||
async function createCluster(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
@@ -393,10 +529,10 @@ export default function Clusters({
|
||||
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as CheckServerResponse;
|
||||
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
|
||||
);
|
||||
setSshChecks((currentChecks) => ({
|
||||
...currentChecks,
|
||||
[server.id]: payload,
|
||||
}));
|
||||
}
|
||||
|
||||
checkingServers.finish(server.id);
|
||||
@@ -428,7 +564,7 @@ export default function Clusters({
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setBootstrapServerError(payload.message ?? 'Unable to bootstrap this server. Check the CLI state output.');
|
||||
setBootstrapServerError(payload.message ?? 'Unable to queue bootstrap for this server.');
|
||||
}
|
||||
|
||||
bootstrappingServers.finish(server.id);
|
||||
@@ -448,7 +584,7 @@ export default function Clusters({
|
||||
}
|
||||
|
||||
function openDeleteServerDialog(server: V5Server): void {
|
||||
if (!selectedCluster || server.lastBootstrappedAt !== null) {
|
||||
if (!selectedCluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,11 +594,7 @@ export default function Clusters({
|
||||
setIsDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
async function deleteUnbootstrappedServer(cluster: V5Cluster, server: V5Server): Promise<void> {
|
||||
if (server.lastBootstrappedAt !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function deleteServer(cluster: V5Cluster, server: V5Server): Promise<void> {
|
||||
deletingServers.start(server.id);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`, {
|
||||
@@ -548,7 +680,7 @@ export default function Clusters({
|
||||
}
|
||||
|
||||
if (serverPendingDelete) {
|
||||
await deleteUnbootstrappedServer(clusterPendingDelete, serverPendingDelete);
|
||||
await deleteServer(clusterPendingDelete, serverPendingDelete);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -600,6 +732,168 @@ export default function Clusters({
|
||||
setEditServerErrors({});
|
||||
}
|
||||
|
||||
|
||||
function renderServerCard(server: V5Server) {
|
||||
const isCheckingServer = checkingServers.has(server.id);
|
||||
const isBootstrapInProgress = ['queued', 'running'].includes(server.lastBootstrapStatus ?? '');
|
||||
const isBootstrappingServer = bootstrappingServers.has(server.id) || isBootstrapInProgress;
|
||||
const isDeletingServer = deletingServers.has(server.id);
|
||||
const isServerInitialized = server.lastBootstrappedAt !== null;
|
||||
const latestSshCheck = sshChecks[server.id] ?? null;
|
||||
const hasBootstrapLogs = server.lastBootstrapOutput !== null && server.lastBootstrapOutput.trim() !== '';
|
||||
const canShowBootstrapLogs = hasBootstrapLogs || isBootstrapInProgress;
|
||||
const isBootstrapLogVisible =
|
||||
isBootstrapInProgress || (canShowBootstrapLogs && (visibleBootstrapLogs[server.id] ?? false));
|
||||
|
||||
return (
|
||||
<article key={server.id} className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h4 className="break-words text-sm font-semibold text-foreground">{server.name}</h4>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{server.host}</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{!isServerInitialized ? (
|
||||
<div role="group" aria-label="Server initialization" className="inline-flex">
|
||||
<span className="inline-flex h-7 items-center rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10 px-2 text-xs font-medium text-destructive">
|
||||
Not initialized
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="coolify"
|
||||
size="sm"
|
||||
className="rounded-r-md"
|
||||
disabled={isBootstrappingServer}
|
||||
onClick={() => void bootstrapServer(server)}
|
||||
>
|
||||
{isBootstrapInProgress
|
||||
? 'Bootstrapping...'
|
||||
: isBootstrappingServer
|
||||
? 'Queueing...'
|
||||
: 'Bootstrap'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Server actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DotsThreeIcon data-icon="inline-start" weight="bold" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={isCheckingServer}
|
||||
onClick={() => void checkServer(server)}
|
||||
>
|
||||
{isCheckingServer ? 'Checking...' : 'Check connection'}
|
||||
</DropdownMenuItem>
|
||||
{canShowBootstrapLogs ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isBootstrapInProgress}
|
||||
onClick={() =>
|
||||
setVisibleBootstrapLogs((currentLogs) => ({
|
||||
...currentLogs,
|
||||
[server.id]: !isBootstrapLogVisible,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isBootstrapInProgress
|
||||
? 'Install logs shown'
|
||||
: isBootstrapLogVisible
|
||||
? 'Hide install logs'
|
||||
: 'Show install logs'}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => openEditServerDialog(server)}>
|
||||
Edit server
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeletingServer}
|
||||
onClick={() => openDeleteServerDialog(server)}
|
||||
>
|
||||
{isDeletingServer ? 'Deleting...' : 'Delete server'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Builder capacity</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.builderEnabled ? server.builderCapacity : 'Disabled'}
|
||||
</dd>
|
||||
</div>
|
||||
{server.builderEnabled ? (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Builder CPU quota</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">{server.builderCpuQuota}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt className="text-muted-foreground">WireGuard IP</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.wireguardManagementIp ?? 'Not assigned'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Server IP</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">{server.host}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Private key</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.privateKeyName ?? 'No key'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{latestSshCheck ? (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="font-medium text-foreground">Latest SSH check: {latestSshCheck.status}</span>
|
||||
<span className="text-muted-foreground">{formatDate(latestSshCheck.checkedAt)}</span>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-muted-foreground">
|
||||
{latestSshCheck.output}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBootstrapLogVisible ? (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="font-medium text-foreground">
|
||||
Install logs
|
||||
{server.lastBootstrapStatus ? `: ${server.lastBootstrapStatus}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{formatDate(server.lastBootstrapRanAt)}</span>
|
||||
</div>
|
||||
{server.lastBootstrapOutput ? (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-muted-foreground">
|
||||
{server.lastBootstrapOutput}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">No install logs captured yet.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Clusters" />
|
||||
@@ -614,56 +908,69 @@ export default function Clusters({
|
||||
/>
|
||||
|
||||
<main className="flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6">
|
||||
<section className="grid w-full grid-cols-1 gap-4 py-4 lg:min-h-0 lg:py-6 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<aside className="flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border p-4">
|
||||
<div>
|
||||
<section className="flex w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Clusters
|
||||
</p>
|
||||
<h1 className="mt-1 text-lg font-semibold text-foreground">Cluster inventory</h1>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="coolify"
|
||||
size="sm"
|
||||
aria-label="Create cluster"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
Add cluster
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{clusterList.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
No clusters yet. Create your first cluster to group servers.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{clusterList.map((cluster) => (
|
||||
<button
|
||||
key={cluster.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedClusterId(cluster.id)}
|
||||
className={`rounded-md border p-3 text-left transition-colors ${
|
||||
selectedCluster?.id === cluster.id
|
||||
? 'border-warning bg-warning/10 text-foreground'
|
||||
: 'border-border bg-background hover:bg-muted/50'
|
||||
}`}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{clusterList.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-2 text-sm text-muted-foreground">
|
||||
No clusters yet.
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
items={clusterList.map((cluster) => ({
|
||||
label: cluster.name,
|
||||
value: cluster.id,
|
||||
}))}
|
||||
value={selectedClusterId}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) {
|
||||
setSelectedClusterId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Select a cluster"
|
||||
className="w-full sm:w-72"
|
||||
>
|
||||
<span className="block text-sm font-medium">{cluster.name}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{cluster.serversCount}{' '}
|
||||
{cluster.serversCount === 1 ? 'server' : 'servers'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SelectValue placeholder="Select a cluster" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end" sideOffset={4}>
|
||||
<SelectGroup>
|
||||
{clusterList.map((cluster) => (
|
||||
<SelectItem
|
||||
key={cluster.id}
|
||||
value={cluster.id}
|
||||
>
|
||||
{cluster.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="coolify"
|
||||
size="default"
|
||||
aria-label="Create cluster"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
className="sm:shrink-0"
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
Add cluster
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto">
|
||||
{selectedCluster ? (
|
||||
@@ -686,7 +993,7 @@ export default function Clusters({
|
||||
<Button
|
||||
type="button"
|
||||
variant="delete"
|
||||
size="sm"
|
||||
size="default"
|
||||
onClick={openDeleteClusterDialog}
|
||||
disabled={isDeletingCluster}
|
||||
>
|
||||
@@ -803,7 +1110,6 @@ export default function Clusters({
|
||||
<Button
|
||||
type="button"
|
||||
variant="coolify"
|
||||
size="sm"
|
||||
aria-label="Add server to cluster"
|
||||
onClick={() => {
|
||||
setServerBuilderEnabled(selectedCluster.builderEnabled);
|
||||
@@ -822,11 +1128,6 @@ export default function Clusters({
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{bootstrapServerError}
|
||||
</p>
|
||||
{selectedCluster.lastCliSummary ? (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-xs text-muted-foreground">
|
||||
{selectedCluster.lastCliSummary}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -840,157 +1141,44 @@ export default function Clusters({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{selectedCluster.servers.map((server) => {
|
||||
const isCheckingServer = checkingServers.has(server.id);
|
||||
const isBootstrappingServer = bootstrappingServers.has(server.id);
|
||||
const isDeletingServer = deletingServers.has(server.id);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={server.id}
|
||||
className="rounded-lg border border-border bg-background p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h4 className="break-words text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h4>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">
|
||||
{server.host}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
<span className="rounded-full border border-border bg-muted/40 px-2 py-1 text-xs text-muted-foreground">
|
||||
Bootstrap: {server.status}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isCheckingServer}
|
||||
onClick={() => void checkServer(server)}
|
||||
>
|
||||
{isCheckingServer
|
||||
? 'Checking...'
|
||||
: 'Check SSH'}
|
||||
</Button>
|
||||
{server.lastBootstrappedAt === null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBootstrappingServer}
|
||||
onClick={() => void bootstrapServer(server)}
|
||||
>
|
||||
{isBootstrappingServer
|
||||
? 'Bootstrapping...'
|
||||
: 'Bootstrap'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditServerDialog(server)}
|
||||
>
|
||||
Edit server
|
||||
</Button>
|
||||
{server.lastBootstrappedAt === null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={isDeletingServer}
|
||||
onClick={() => openDeleteServerDialog(server)}
|
||||
>
|
||||
{isDeletingServer
|
||||
? 'Deleting...'
|
||||
: 'Delete'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
{notInitializedServers.length > 0 ? (
|
||||
<section aria-labelledby="not-initialized-servers-heading">
|
||||
<div className="mb-3">
|
||||
<h4
|
||||
id="not-initialized-servers-heading"
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
Not initialized servers
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Bootstrap these servers before using them for workloads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Builder capacity
|
||||
</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.builderEnabled
|
||||
? server.builderCapacity
|
||||
: 'Disabled'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Builder CPU quota
|
||||
</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.builderCpuQuota}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">WireGuard IP</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.wireguardManagementIp ?? 'Not assigned'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">CLI node</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.nodeAddress ?? server.host}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Private key</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{server.privateKeyName ?? 'No key'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Last bootstrap
|
||||
</dt>
|
||||
<dd className="mt-1 break-words font-medium text-foreground">
|
||||
{formatDate(server.lastBootstrappedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Capabilities: {normalizeCapabilities(server.capabilities)}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="font-medium text-foreground">
|
||||
Latest SSH check
|
||||
{server.lastStatusCheck
|
||||
? `: ${server.lastStatusCheck}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{server.lastStatusCheckedAt
|
||||
? formatDate(server.lastStatusCheckedAt)
|
||||
: 'Never run'}
|
||||
</span>
|
||||
</div>
|
||||
{server.lastStatusOutput ? (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-muted-foreground">
|
||||
{server.lastStatusOutput}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Run Check SSH to verify connectivity and capture
|
||||
diagnostic output.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{notInitializedServers.map(renderServerCard)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{initializedServers.length > 0 ? (
|
||||
<section aria-labelledby="initialized-servers-heading">
|
||||
<div className="mb-3">
|
||||
<h4
|
||||
id="initialized-servers-heading"
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
Servers
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Initialized servers currently assigned to this cluster.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{initializedServers.map(renderServerCard)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1023,7 +1211,7 @@ export default function Clusters({
|
||||
<DialogTitle>Confirm deletion</DialogTitle>
|
||||
<DialogDescription>
|
||||
{serverPendingDelete
|
||||
? `Delete unbootstrapped server ${serverPendingDelete.name}? This only removes it from this cluster.`
|
||||
? `Delete server ${serverPendingDelete.name}? This removes it from this cluster inventory so you can add it again later.`
|
||||
: `Delete cluster ${clusterPendingDelete?.name ?? ''}? This cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -1088,10 +1276,10 @@ export default function Clusters({
|
||||
<FieldError message={errors.description?.[0]} />
|
||||
</Field>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/20">
|
||||
<div className="rounded-lg border border-border bg-muted/20 transition-colors focus-within:border-ring">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-foreground"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-foreground outline-none"
|
||||
onClick={() => setShowAdvancedConfiguration((value) => !value)}
|
||||
>
|
||||
<span>Advanced configuration</span>
|
||||
@@ -1361,7 +1549,7 @@ export default function Clusters({
|
||||
<select
|
||||
value={selectedPrivateKeyId}
|
||||
onChange={(event) => setSelectedPrivateKeyId(event.target.value)}
|
||||
className="appearance-none rounded-md border border-border bg-background bg-[length:1rem_1rem] bg-[position:right_0.75rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm outline-none transition focus:border-ring focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||
className="appearance-none rounded-md border border-border bg-background bg-[length:1rem_1rem] bg-[position:right_0.75rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50"
|
||||
aria-invalid={serverErrors.private_key_id ? true : undefined}
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 256 256' fill='none' stroke='%23ffffff' stroke-width='28' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m64 96 64 64 64-64'/%3E%3C/svg%3E")`,
|
||||
@@ -1377,10 +1565,10 @@ export default function Clusters({
|
||||
<FieldError message={serverErrors.private_key_id?.[0]} />
|
||||
</Field>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/20">
|
||||
<div className="rounded-lg border border-border bg-muted/20 transition-colors focus-within:border-ring">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-foreground"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-foreground outline-none"
|
||||
onClick={() => setShowAdvancedServerConfiguration((value) => !value)}
|
||||
>
|
||||
<span>Advanced server configuration</span>
|
||||
@@ -1392,11 +1580,11 @@ export default function Clusters({
|
||||
{showAdvancedServerConfiguration ? (
|
||||
<div className="grid grid-cols-1 gap-4 border-t border-border p-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>CLI node address</FieldLabel>
|
||||
<FieldLabel>Node address override</FieldLabel>
|
||||
<Input
|
||||
value={serverNodeAddress}
|
||||
onChange={(event) => setServerNodeAddress(event.target.value)}
|
||||
placeholder="Defaults to host"
|
||||
placeholder="Defaults to server IP"
|
||||
aria-invalid={serverErrors.node_address ? true : undefined}
|
||||
/>
|
||||
<FieldError message={serverErrors.node_address?.[0]} />
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { AppNavbar } from '@/components/app-navbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { csrfToken } from '@/lib/csrf';
|
||||
import type { V5DashboardProps } from '@/types';
|
||||
|
||||
type RealtimeTestProps = V5DashboardProps & {
|
||||
currentTeam: {
|
||||
id: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RealtimeTestEvent = {
|
||||
message: string;
|
||||
teamId: number;
|
||||
sentAt: string;
|
||||
};
|
||||
|
||||
type EchoChannel = {
|
||||
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
|
||||
subscribed?: (callback: () => void) => EchoChannel;
|
||||
error?: (callback: (error: unknown) => void) => EchoChannel;
|
||||
};
|
||||
|
||||
type EchoClient = {
|
||||
private: (channel: string) => EchoChannel;
|
||||
leave?: (channel: string) => void;
|
||||
leaveChannel?: (channel: string) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Echo?: EchoClient;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogPayload(payload: unknown): string {
|
||||
if (typeof payload === 'string') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
export default function RealtimeTest({ currentTeam, flux, projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null }: RealtimeTestProps) {
|
||||
const [message, setMessage] = useState('Hello from v5 realtime test');
|
||||
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
function addLog(label: string, payload?: unknown): void {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setLogs((currentLogs) => [
|
||||
`[${timestamp}] ${label}${payload === undefined ? '' : `\n${formatLogPayload(payload)}`}`,
|
||||
...currentLogs,
|
||||
]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTeam) {
|
||||
addLog('No current team was provided to the page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let attempts = 0;
|
||||
const channelName = `team.${currentTeam.id}`;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
|
||||
if (!window.Echo) {
|
||||
if (attempts === 1) {
|
||||
addLog('Waiting for window.Echo...');
|
||||
}
|
||||
|
||||
if (attempts >= 20) {
|
||||
window.clearInterval(interval);
|
||||
addLog('window.Echo was not available after 10 seconds.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(interval);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
addLog(`Subscribing to private-${channelName}`);
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.subscribed?.(() => addLog(`Subscribed to private-${channelName}`));
|
||||
channel.error?.((error: unknown) => addLog(`Subscription error on private-${channelName}`, error));
|
||||
channel.listen('.v5.realtime.test', (payload) => {
|
||||
const event = payload as RealtimeTestEvent;
|
||||
|
||||
addLog('Received .v5.realtime.test', event);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
|
||||
};
|
||||
}, [currentTeam]);
|
||||
|
||||
async function broadcastTestEvent(): Promise<void> {
|
||||
setIsBroadcasting(true);
|
||||
addLog('Sending POST /v5/realtime-test');
|
||||
|
||||
const response = await fetch('/v5/realtime-test', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
addLog(`POST /v5/realtime-test responded ${response.status}`, payload);
|
||||
setIsBroadcasting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Realtime test" />
|
||||
<div className="min-h-dvh bg-background text-foreground">
|
||||
<AppNavbar
|
||||
flux={flux}
|
||||
clusters={[]}
|
||||
projects={projects}
|
||||
selectedProjectUuid={selectedProjectUuid}
|
||||
selectedEnvironmentUuid={selectedEnvironmentUuid}
|
||||
/>
|
||||
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 pt-20 pb-8 lg:px-6">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">RealtimeTest</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-foreground">v5 realtime websocket test</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Opens a private team channel subscription and broadcasts a manual backend event named{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">v5.realtime.test</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Runtime state</h2>
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Team ID</dt>
|
||||
<dd className="font-medium text-foreground">{currentTeam?.id ?? 'Missing'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Echo</dt>
|
||||
<dd className="font-medium text-foreground">{typeof window !== 'undefined' && window.Echo ? 'Available' : 'Missing'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Channel</dt>
|
||||
<dd className="font-medium text-foreground">{currentTeam ? `private-team.${currentTeam.id}` : 'Missing'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4 lg:col-span-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Send test event</h2>
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||
<Input value={message} onChange={(event) => setMessage(event.target.value)} />
|
||||
<Button type="button" variant="coolify" disabled={isBroadcasting} onClick={() => void broadcastTestEvent()}>
|
||||
{isBroadcasting ? 'Broadcasting...' : 'Broadcast event'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Event log</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setLogs([])}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-3 min-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-background p-4 text-xs text-muted-foreground">
|
||||
{logs.length === 0 ? 'No logs yet.' : logs.join('\n\n')}
|
||||
</pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,6 @@ function persistSelection(projectUuid: string, environmentUuid: string): void {
|
||||
type AppNavbarProps = V5DashboardProps;
|
||||
|
||||
export function AppNavbar({
|
||||
flux,
|
||||
clusters = [],
|
||||
projects = [],
|
||||
selectedProjectUuid = null,
|
||||
selectedEnvironmentUuid = null,
|
||||
@@ -86,7 +84,7 @@ export function AppNavbar({
|
||||
className="flex shrink-0 items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label="Coolify dashboard"
|
||||
>
|
||||
<img src="/coolify-logo.svg" alt="Coolify" className="size-8" />
|
||||
<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />
|
||||
</Link>
|
||||
|
||||
<div className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2">
|
||||
@@ -141,13 +139,6 @@ export function AppNavbar({
|
||||
Clusters
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className="hidden rounded-md border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground lg:block"
|
||||
title={flux?.socket ?? flux?.message ?? undefined}
|
||||
>
|
||||
Flux: {flux?.label ?? 'Unknown'} · {clusters.length} clusters
|
||||
</div>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger
|
||||
className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"
|
||||
|
||||
@@ -5,21 +5,21 @@ import type * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
coolify:
|
||||
'border-coollabs bg-coollabs-50 text-coollabs-200 hover:bg-coollabs hover:text-white dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white dark:hover:bg-coollabs-100 dark:hover:text-white',
|
||||
'border-coollabs bg-coollabs-50 text-coollabs-200 hover:bg-coollabs hover:text-white focus-visible:border-coollabs-100 focus-visible:bg-coollabs focus-visible:text-white dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white dark:hover:bg-coollabs-100 dark:hover:text-white dark:focus-visible:border-coollabs-100 dark:focus-visible:bg-coollabs-100',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
delete: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
|
||||
delete: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -63,7 +63,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="dialog-footer" className={cn('flex justify-end gap-2', className)} {...props} />;
|
||||
return <div data-slot="dialog-footer" className={cn('mt-6 flex justify-end gap-2', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-2 text-xs text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ function Input({ className, ...props }: React.ComponentProps<'input'>) {
|
||||
<input
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||
'rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -30,11 +30,11 @@ function SelectTrigger({ className, size = 'default', variant = 'default', child
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variant === 'default' &&
|
||||
'border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs focus-visible:border-ring dark:bg-input/30 dark:hover:bg-input/50',
|
||||
variant === 'ghost' &&
|
||||
'h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-transparent dark:bg-transparent dark:hover:bg-transparent',
|
||||
'h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-ring dark:bg-transparent dark:hover:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'min-h-24 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||
'min-h-24 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type V5Server = {
|
||||
builderEnabled: boolean;
|
||||
builderCapacity: number;
|
||||
builderCpuQuota: string;
|
||||
uuid: string | null;
|
||||
nodeAddress: string | null;
|
||||
wireguardListenPortOverride: number | null;
|
||||
wireguardEndpointOverride: string | null;
|
||||
@@ -22,9 +23,10 @@ export type V5Server = {
|
||||
containerSubnets: Record<string, string> | string[];
|
||||
privateKeyName: string | null;
|
||||
lastBootstrappedAt: string | null;
|
||||
lastStatusCheck: string | null;
|
||||
lastStatusOutput: string | null;
|
||||
lastStatusCheckedAt: string | null;
|
||||
lastBootstrapAction: string | null;
|
||||
lastBootstrapStatus: string | null;
|
||||
lastBootstrapOutput: string | null;
|
||||
lastBootstrapRanAt: string | null;
|
||||
};
|
||||
|
||||
export type V5Cluster = {
|
||||
@@ -73,6 +75,9 @@ export type V5PrivateKey = {
|
||||
|
||||
export type V5DashboardProps = {
|
||||
flux: FluxStatus | null;
|
||||
currentTeam?: {
|
||||
id: number;
|
||||
} | null;
|
||||
clusters?: V5Cluster[];
|
||||
privateKeys?: V5PrivateKey[];
|
||||
projects?: V5Project[];
|
||||
|
||||
@@ -11,6 +11,28 @@
|
||||
@else
|
||||
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
|
||||
@endenv
|
||||
@auth
|
||||
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
|
||||
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
|
||||
<script>
|
||||
window.Pusher = Pusher;
|
||||
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
|
||||
window.Echo = new EchoConstructor({
|
||||
broadcaster: 'pusher',
|
||||
cluster: "{{ config('constants.pusher.host') }}" || window.location.hostname,
|
||||
key: "{{ config('constants.pusher.app_key') }}" || 'coolify',
|
||||
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
|
||||
wsPort: "{{ getRealtime() }}",
|
||||
wssPort: "{{ getRealtime() }}",
|
||||
forceTLS: false,
|
||||
encrypted: true,
|
||||
enableStats: false,
|
||||
enableLogging: true,
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
|
||||
});
|
||||
</script>
|
||||
@endauth
|
||||
@php
|
||||
$viteHotFile = public_path('hot');
|
||||
$viteDevServerUrl = null;
|
||||
|
||||
@@ -5,8 +5,11 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('v5.authenticated')->group(function () {
|
||||
Route::get('/', DashboardController::class)->name('dashboard');
|
||||
Route::get('/realtime-test', [DashboardController::class, 'realtimeTest'])->name('realtime-test');
|
||||
Route::post('/realtime-test', [DashboardController::class, 'broadcastRealtimeTest'])->name('realtime-test.broadcast');
|
||||
Route::post('/selection', [DashboardController::class, 'updateSelection'])->name('selection.update');
|
||||
Route::get('/clusters', [DashboardController::class, 'clustersIndex'])->name('clusters.index');
|
||||
Route::get('/clusters/{cluster}', [DashboardController::class, 'showCluster'])->name('clusters.show');
|
||||
Route::post('/clusters', [DashboardController::class, 'storeCluster'])->name('clusters.store');
|
||||
Route::delete('/clusters/{cluster}', [DashboardController::class, 'destroyCluster'])->name('clusters.destroy');
|
||||
Route::post('/clusters/{cluster}/servers', [DashboardController::class, 'storeServer'])->name('clusters.servers.store');
|
||||
|
||||
+85
-2
@@ -29,6 +29,7 @@ VERSION="$(read_coolify_env COOLIFY_COOLD_VERSION nightly)"
|
||||
CORROSION_VERSION="$(read_coolify_env COOLIFY_CORROSION_VERSION v1.0.0)"
|
||||
FLUX_URL="$(read_coolify_env COOLIFY_COOLD_VM_FLUX_URL http://host.lima.internal:6443)"
|
||||
BUILDER_CAPACITY="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)"
|
||||
START_TIMEOUT="$(read_coolify_env COOLIFY_COOLD_VM_START_TIMEOUT 300)"
|
||||
SSH_PORT="$(read_coolify_env COOLIFY_COOLD_VM_SSH_PORT 60002)"
|
||||
WG_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_IP "")"
|
||||
WG_PEER_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_IP "")"
|
||||
@@ -70,6 +71,7 @@ Environment:
|
||||
COOLIFY_CORROSION_VERSION corrosion release tag to install (default: v1.0.0)
|
||||
COOLIFY_COOLD_VM_FLUX_URL Flux gRPC URL visible from the VM (default: http://host.lima.internal:6443)
|
||||
COOLIFY_COOLD_VM_BUILDER_CAPACITY VM builder capacity to advertise (default: 2; set 0 to disable)
|
||||
COOLIFY_COOLD_VM_START_TIMEOUT Seconds to wait for Lima SSH/provisioning (default: 300)
|
||||
COOLIFY_COOLD_VM_SSH_PORT Host SSH port forwarded to this VM (default: 60002)
|
||||
COOLIFY_COOLD_VM_WG_IP Optional WireGuard mgmt IP for this host
|
||||
COOLIFY_COOLD_VM_CONTAINER_SUBNET Podman mesh subnet for this host
|
||||
@@ -103,6 +105,60 @@ lima_shell() {
|
||||
(cd /tmp && limactl shell "$INSTANCE" -- "$@")
|
||||
}
|
||||
|
||||
kill_matching_processes() {
|
||||
local pattern="$1"
|
||||
local pids
|
||||
|
||||
pids="$(pgrep -f "$pattern" 2>/dev/null || true)"
|
||||
if [ -z "$pids" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
kill $pids >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
kill -9 $pids >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup_lima_probe_processes() {
|
||||
kill_matching_processes "limactl shell ${INSTANCE} -- true"
|
||||
kill_matching_processes "ssh .*ControlPath=.*${INSTANCE}/ssh.sock"
|
||||
kill_matching_processes "ssh: .*/.lima/${INSTANCE}/ssh.sock"
|
||||
rm -f "$HOME/.lima/${INSTANCE}/ssh.sock"
|
||||
}
|
||||
|
||||
cleanup_lima_hostagent_processes() {
|
||||
kill_matching_processes "limactl hostagent .*${INSTANCE}"
|
||||
rm -f "$HOME/.lima/${INSTANCE}/ha.sock"
|
||||
}
|
||||
|
||||
lima_shell_timeout() {
|
||||
local timeout_seconds="$1"
|
||||
shift
|
||||
local pid
|
||||
local elapsed=0
|
||||
|
||||
lima_shell "$@" &
|
||||
pid="$!"
|
||||
|
||||
while kill -0 "$pid" >/dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$timeout_seconds" ]; then
|
||||
pkill -P "$pid" >/dev/null 2>&1 || true
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
pkill -P "$pid" >/dev/null 2>&1 || true
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
cleanup_lima_probe_processes
|
||||
return 124
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
vm_primary_ip() {
|
||||
lima_shell sh -lc "ip -4 route get 1.1.1.1 | awk '{print \$7; exit}'"
|
||||
}
|
||||
@@ -408,6 +464,9 @@ start_vm() {
|
||||
return
|
||||
fi
|
||||
|
||||
cleanup_lima_probe_processes
|
||||
cleanup_lima_hostagent_processes
|
||||
|
||||
if instance_exists; then
|
||||
limactl start --tty=false "$INSTANCE"
|
||||
else
|
||||
@@ -432,7 +491,16 @@ wait_for_lima_start() {
|
||||
elapsed=0
|
||||
|
||||
while kill -0 "$start_pid" 2>/dev/null; do
|
||||
if instance_exists && lima_shell true >/dev/null 2>&1; then
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Lima start timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
kill "$start_pid" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
kill -9 "$start_pid" >/dev/null 2>&1 || true
|
||||
wait "$start_pid" >/dev/null 2>&1 || true
|
||||
return 124
|
||||
fi
|
||||
|
||||
if instance_exists && lima_shell_timeout 5 true >/dev/null 2>&1; then
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
printf '==> [%3ss] Lima start: guest SSH ready, cloud-init %s
|
||||
' "$elapsed" "${status:-unknown}"
|
||||
@@ -460,11 +528,18 @@ wait_for_lima_start() {
|
||||
|
||||
wait_for_guest_provisioning() {
|
||||
echo "==> Waiting for guest SSH..."
|
||||
until instance_exists && lima_shell true >/dev/null 2>&1; do
|
||||
elapsed=0
|
||||
until instance_exists && lima_shell_timeout 5 true >/dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Guest SSH timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
return 124
|
||||
fi
|
||||
|
||||
message="$(latest_lima_message)"
|
||||
printf '==> Waiting for guest SSH: %s
|
||||
' "${message:-booting}"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
@@ -477,6 +552,13 @@ wait_for_guest_provisioning() {
|
||||
tail_pid=$!
|
||||
|
||||
while true; do
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Guest provisioning timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
kill "$tail_pid" >/dev/null 2>&1 || true
|
||||
wait "$tail_pid" 2>/dev/null || true
|
||||
return 124
|
||||
fi
|
||||
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
printf '==> Guest cloud-init: %s
|
||||
' "${status:-unknown}"
|
||||
@@ -486,6 +568,7 @@ wait_for_guest_provisioning() {
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
kill "$tail_pid" >/dev/null 2>&1 || true
|
||||
|
||||
+166
-1
@@ -258,6 +258,14 @@ coolify_ssh_user() {
|
||||
read_coolify_env COOLIFY_CLI_SSH_USER "$USER"
|
||||
}
|
||||
|
||||
coolify_bootstrap_concurrency() {
|
||||
read_coolify_env COOLIFY_COOLD_BOOTSTRAP_CONCURRENCY 1
|
||||
}
|
||||
|
||||
coolify_bootstrap_ssh_timeout() {
|
||||
read_coolify_env COOLIFY_COOLD_BOOTSTRAP_SSH_TIMEOUT 90s
|
||||
}
|
||||
|
||||
coolify_bootstrap_command() {
|
||||
local nodes
|
||||
local ssh_config
|
||||
@@ -275,6 +283,9 @@ $(coolify_cli_bin) init bootstrap \\
|
||||
--nodes "${nodes}" \\
|
||||
--ssh-config "${ssh_config}" \\
|
||||
--ssh-user "$(coolify_ssh_user)" \\
|
||||
--concurrency "$(coolify_bootstrap_concurrency)" \\
|
||||
--ssh-timeout "$(coolify_bootstrap_ssh_timeout)" \\
|
||||
--debug \\
|
||||
--wg-listen-port-overrides "${listen_overrides}" \\
|
||||
--wg-endpoint-overrides "${endpoint_overrides}" \\
|
||||
--coold-version "$(read_coolify_env COOLIFY_COOLD_VERSION nightly)" \\
|
||||
@@ -299,6 +310,9 @@ coolify_bootstrap() {
|
||||
--nodes "$nodes" \
|
||||
--ssh-config "$ssh_config" \
|
||||
--ssh-user "$(coolify_ssh_user)" \
|
||||
--concurrency "$(coolify_bootstrap_concurrency)" \
|
||||
--ssh-timeout "$(coolify_bootstrap_ssh_timeout)" \
|
||||
--debug \
|
||||
--wg-listen-port-overrides "$listen_overrides" \
|
||||
--wg-endpoint-overrides "$endpoint_overrides" \
|
||||
--coold-version "$(read_coolify_env COOLIFY_COOLD_VERSION nightly)" \
|
||||
@@ -306,6 +320,35 @@ coolify_bootstrap() {
|
||||
--yes
|
||||
}
|
||||
|
||||
diagnose_coold_bootstrap_failure() {
|
||||
local count
|
||||
local instance
|
||||
count="$(coold_vm_count)"
|
||||
|
||||
echo "==> Bootstrap failed; collecting quick VM diagnostics..." >&2
|
||||
for index in $(seq 1 "$count"); do
|
||||
instance="$(coold_vm_instance "$index")"
|
||||
echo "--- ${instance}: diagnostics ---" >&2
|
||||
run_with_timeout 45 env COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell <<'SH' >&2 || true
|
||||
set +e
|
||||
echo '[cloud-init]'
|
||||
cloud-init status 2>/dev/null || true
|
||||
echo '[systemd]'
|
||||
systemctl is-system-running 2>/dev/null || true
|
||||
echo '[apt/dpkg locks]'
|
||||
for lock in /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock; do
|
||||
fuser "$lock" 2>/dev/null && echo "busy: $lock" || true
|
||||
done
|
||||
echo '[failed units]'
|
||||
systemctl --failed --no-pager 2>/dev/null || true
|
||||
echo '[coolify services]'
|
||||
systemctl --no-pager --full status wg-quick@wg0 podman.socket corrosion coold 2>/dev/null | tail -n 120 || true
|
||||
echo '[disk]'
|
||||
df -h / /var 2>/dev/null || true
|
||||
SH
|
||||
done
|
||||
}
|
||||
|
||||
coolify_bootstrap_with_retry() {
|
||||
local attempt
|
||||
local attempts=5
|
||||
@@ -315,6 +358,8 @@ coolify_bootstrap_with_retry() {
|
||||
return
|
||||
fi
|
||||
|
||||
diagnose_coold_bootstrap_failure
|
||||
|
||||
if [ "$attempt" = "$attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
@@ -379,6 +424,32 @@ coold_vm() {
|
||||
scripts/coold-vm.sh "$@"
|
||||
}
|
||||
|
||||
coold_vm_up_with_retry() {
|
||||
local index="$1"
|
||||
local attempt
|
||||
local attempts=2
|
||||
local instance
|
||||
|
||||
instance="$(coold_vm_instance "$index")"
|
||||
|
||||
for attempt in $(seq 1 "$attempts"); do
|
||||
if coold_vm "$index" up; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$attempt" = "$attempts" ]; then
|
||||
echo "ERROR: ${instance} did not become ready after ${attempts} attempts." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "WARN: ${instance} did not become ready; deleting and retrying with a fresh Lima instance..." >&2
|
||||
limactl stop --force --tty=false "$instance" >/dev/null 2>&1 || true
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
limactl delete --force --tty=false "$instance" >/dev/null 2>&1 || true
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
done
|
||||
}
|
||||
|
||||
mint_host_jwt_for_host() {
|
||||
local host_id="$1"
|
||||
local attempts=60
|
||||
@@ -525,7 +596,7 @@ up() {
|
||||
if [ "$coold_vm_enabled" != "false" ]; then
|
||||
echo "==> Starting ${count} Coolify coold VM(s) before Spin..."
|
||||
for index in $(seq 1 "$count"); do
|
||||
coold_vm "$index" up
|
||||
coold_vm_up_with_retry "$index"
|
||||
done
|
||||
else
|
||||
echo "==> COOLIFY_COOLD_VM_ENABLED=false; skipping coold VM."
|
||||
@@ -610,6 +681,29 @@ down() {
|
||||
fi
|
||||
}
|
||||
|
||||
kill_matching_processes() {
|
||||
local pattern="$1"
|
||||
local pids
|
||||
|
||||
pids="$(pgrep -f "$pattern" 2>/dev/null || true)"
|
||||
if [ -z "$pids" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
kill $pids >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
kill -9 $pids >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup_lima_instance_processes() {
|
||||
local instance="$1"
|
||||
|
||||
kill_matching_processes "limactl hostagent .*${instance}"
|
||||
kill_matching_processes "ssh: .*/.lima/${instance}/ssh.sock"
|
||||
kill_matching_processes "ssh .*ControlPath=.*${instance}/ssh.sock"
|
||||
rm -f "$HOME/.lima/${instance}/ssh.sock" "$HOME/.lima/${instance}/ha.sock"
|
||||
}
|
||||
|
||||
clean_vms() {
|
||||
local count
|
||||
count="$(coold_vm_count)"
|
||||
@@ -619,11 +713,13 @@ clean_vms() {
|
||||
instance="$(coold_vm_instance "$index")"
|
||||
echo "==> Deleting ${instance}..."
|
||||
limactl stop --force --tty=false "$instance" >/dev/null 2>&1 || true
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
if ! run_with_timeout 60 limactl delete --force --tty=false "$instance"; then
|
||||
echo "WARN: limactl delete timed out for ${instance}; killing matching limactl clients." >&2
|
||||
pkill -f "limactl.*${instance}" >/dev/null 2>&1 || true
|
||||
rm -rf "$HOME/.lima/${instance}"
|
||||
fi
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -1088,12 +1184,74 @@ firewall() {
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
refresh_test_host_key() {
|
||||
echo "==> Refreshing /tmp/testhostkey inside coolify..."
|
||||
spin exec -T coolify php artisan tinker --execute='file_put_contents("/tmp/testhostkey", \App\Models\PrivateKey::query()->where("name", "Testing Host Key")->sole()->private_key); chmod("/tmp/testhostkey", 0600);'
|
||||
}
|
||||
|
||||
recreate_naked_lima_vm() {
|
||||
local instance="${1:-coolify-naked-test}"
|
||||
local config="${2:-.dev/lima/coolify-naked-test.yaml}"
|
||||
local attempt
|
||||
local attempts=2
|
||||
local start_timeout
|
||||
|
||||
start_timeout="$(read_coolify_env COOLIFY_NAKED_VM_START_TIMEOUT "$(read_coolify_env COOLIFY_COOLD_VM_START_TIMEOUT 300)")"
|
||||
|
||||
if [ ! -f "$config" ]; then
|
||||
echo "ERROR: naked Lima config not found: ${config}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Recreating naked Lima VM: ${instance}..."
|
||||
for attempt in $(seq 1 "$attempts"); do
|
||||
limactl stop --force --tty=false "$instance" >/dev/null 2>&1 || true
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
limactl delete --force --tty=false "$instance" >/dev/null 2>&1 || true
|
||||
cleanup_lima_instance_processes "$instance"
|
||||
|
||||
if run_with_timeout "$start_timeout" limactl start --tty=false --name="$instance" "$config"; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "WARN: ${instance} did not become ready on attempt ${attempt}/${attempts}; deleting and retrying..." >&2
|
||||
done
|
||||
|
||||
echo "ERROR: ${instance} did not become ready after ${attempts} attempts." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
fresh() {
|
||||
echo "==> Recreating coold Lima VMs and Coolify dev stack..."
|
||||
down --cleanup
|
||||
COOLIFY_DEV_FOLLOW_LOGS=false up
|
||||
|
||||
echo "==> Refreshing Coolify database with seed data..."
|
||||
spin exec -T coolify php artisan migrate:fresh --seed --force
|
||||
|
||||
if [ "$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)" != "false" ]; then
|
||||
echo "==> Re-syncing seeded v5 Lima servers after DB refresh..."
|
||||
sync_v5_dev_lima_servers
|
||||
fi
|
||||
|
||||
recreate_naked_lima_vm
|
||||
refresh_test_host_key
|
||||
|
||||
echo "==> Restarting Horizon so workers use the latest code..."
|
||||
spin exec -T coolify php artisan horizon:terminate || true
|
||||
|
||||
echo "==> Fresh dev environment is ready."
|
||||
limactl list | grep -E 'NAME|coold-dev|coolify-naked-test' || true
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/dev.sh <command> [spin args]
|
||||
|
||||
Commands:
|
||||
up Start the coold VM, Spin stack, and dev coold agent
|
||||
fresh Recreate coold/naked Lima VMs, refresh DB, seed, sync v5 dev servers
|
||||
up --naked
|
||||
Start the coold VM(s) and Spin stack only; skip host bootstrap so /v5 can bootstrap
|
||||
down Stop the dev coold agent and Spin stack
|
||||
@@ -1102,6 +1260,7 @@ Commands:
|
||||
shell [n] Open a shell inside coold VM n (default: 1)
|
||||
list Show Lima instances
|
||||
clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)
|
||||
naked-vm Recreate the naked Lima VM used for bootstrap testing
|
||||
corrosion <command> Inspect Corrosion state, config, logs, and registered containers
|
||||
firewall <command> Manage dev coold firewall allow rules
|
||||
example-nginx <command> Start/check example nginx containers with coold DNS
|
||||
@@ -1121,6 +1280,9 @@ case "$cmd" in
|
||||
down)
|
||||
down "$@"
|
||||
;;
|
||||
fresh)
|
||||
fresh
|
||||
;;
|
||||
shell)
|
||||
coold_vm "${1:-1}" shell
|
||||
;;
|
||||
@@ -1130,6 +1292,9 @@ case "$cmd" in
|
||||
clean-vms|clean-vm|reset-vms)
|
||||
down --cleanup
|
||||
;;
|
||||
naked-vm)
|
||||
recreate_naked_lima_vm
|
||||
;;
|
||||
corrosion)
|
||||
corrosion "$@"
|
||||
;;
|
||||
|
||||
@@ -46,6 +46,9 @@ it('supports a naked up mode that starts VMs and Spin but skips server bootstrap
|
||||
->and($script)->toContain('if [ "$naked" = "true" ]; then')
|
||||
->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring')
|
||||
->and($script)->toContain('coolify_bootstrap_with_retry')
|
||||
->and($script)->toContain('--concurrency "$(coolify_bootstrap_concurrency)"')
|
||||
->and($script)->toContain('--ssh-timeout "$(coolify_bootstrap_ssh_timeout)"')
|
||||
->and($script)->toContain('--debug')
|
||||
->and($script)->toContain('configure_flux_dev_for_vm "$index"')
|
||||
->and($script)->toContain('sync_v5_dev_lima_servers');
|
||||
});
|
||||
@@ -56,6 +59,9 @@ it('retries dev coolify bootstrap because fresh Lima setup can complete across p
|
||||
expect($script)->toContain('coolify_bootstrap_with_retry()')
|
||||
->and($script)->toContain('local attempts=5')
|
||||
->and($script)->toContain('if coolify_bootstrap; then')
|
||||
->and($script)->toContain('diagnose_coold_bootstrap_failure')
|
||||
->and($script)->toContain('systemctl --failed --no-pager')
|
||||
->and($script)->toContain('systemctl --no-pager --full status wg-quick@wg0 podman.socket corrosion coold')
|
||||
->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases');
|
||||
});
|
||||
|
||||
@@ -122,3 +128,40 @@ it('supports down cleanup as the preferred VM cleanup command', function () {
|
||||
->and($script)->toContain('down --cleanup')
|
||||
->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)');
|
||||
});
|
||||
|
||||
it('scripts a full fresh dev reset with bounded Lima startup retries', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
|
||||
|
||||
expect($script)->toContain('fresh()')
|
||||
->and($script)->toContain('down --cleanup')
|
||||
->and($script)->toContain('COOLIFY_DEV_FOLLOW_LOGS=false up')
|
||||
->and($script)->toContain('php artisan migrate:fresh --seed --force')
|
||||
->and($script)->toContain('sync_v5_dev_lima_servers')
|
||||
->and($script)->toContain('recreate_naked_lima_vm')
|
||||
->and($script)->toContain('COOLIFY_NAKED_VM_START_TIMEOUT')
|
||||
->and($script)->toContain('limactl start --tty=false --name="$instance" "$config"')
|
||||
->and($script)->toContain('cleanup_lima_instance_processes "$instance"')
|
||||
->and($script)->toContain('did not become ready after ${attempts} attempts')
|
||||
->and($script)->toContain('refresh_test_host_key')
|
||||
->and($script)->toContain('coold_vm_up_with_retry()')
|
||||
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_CONCURRENCY 1')
|
||||
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_SSH_TIMEOUT 90s')
|
||||
->and($script)->toContain('local attempts=2')
|
||||
->and($script)->toContain('deleting and retrying with a fresh Lima instance')
|
||||
->and($script)->toContain('cleanup_lima_instance_processes()')
|
||||
->and($script)->toContain('kill_matching_processes()')
|
||||
->and($script)->toContain('kill -9 $pids')
|
||||
->and($script)->toContain('limactl hostagent .*${instance}')
|
||||
->and($script)->toContain('ssh: .*/.lima/${instance}/ssh.sock')
|
||||
->and($vmScript)->toContain('COOLIFY_COOLD_VM_START_TIMEOUT')
|
||||
->and($vmScript)->toContain('lima_shell_timeout()')
|
||||
->and($vmScript)->toContain('cleanup_lima_probe_processes()')
|
||||
->and($vmScript)->toContain('cleanup_lima_hostagent_processes()')
|
||||
->and($vmScript)->toContain('kill_matching_processes()')
|
||||
->and($vmScript)->toContain('pkill -P "$pid"')
|
||||
->and($vmScript)->toContain('lima_shell_timeout 5 true')
|
||||
->and($vmScript)->toContain('Lima start timed out after ${START_TIMEOUT}s')
|
||||
->and($vmScript)->toContain('Guest SSH timed out after ${START_TIMEOUT}s')
|
||||
->and($vmScript)->toContain('Guest provisioning timed out after ${START_TIMEOUT}s');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('renders the Coolify dashboard icon smaller than the nav height', function () {
|
||||
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
|
||||
|
||||
expect($navbar)->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />')
|
||||
->and($navbar)->not->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-8" />');
|
||||
});
|
||||
|
||||
it('shows keyboard focus on ghost select dropdown triggers', function () {
|
||||
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
|
||||
|
||||
expect($selectComponent)
|
||||
->toContain("variant === 'ghost'")
|
||||
->toContain('focus-visible:border-ring')
|
||||
->not->toContain('focus-visible:border-transparent');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
it('uses yellow focus borders for normal v5 controls and purple focus borders for coolify buttons', function () {
|
||||
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
|
||||
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
|
||||
$inputComponent = file_get_contents(resource_path('js/v5/components/ui/input.tsx'));
|
||||
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
|
||||
$textareaComponent = file_get_contents(resource_path('js/v5/components/ui/textarea.tsx'));
|
||||
|
||||
expect($v5Css)
|
||||
->toContain(':focus-visible')
|
||||
->toContain('ring-2 ring-ring ring-offset-2')
|
||||
->toContain('--tw-ring-offset-color: var(--background)')
|
||||
->and($buttonComponent)
|
||||
->toContain('focus-visible:border-ring')
|
||||
->toContain('focus-visible:border-coollabs-100')
|
||||
->toContain('dark:focus-visible:border-coollabs-100')
|
||||
->not->toContain('dark:focus-visible:border-coollabs-50')
|
||||
->not->toContain('focus-visible:ring-')
|
||||
->not->toContain('focus-visible:border-destructive')
|
||||
->and($inputComponent)
|
||||
->toContain('focus:border-ring')
|
||||
->toContain('focus:ring-0')
|
||||
->not->toContain('focus:ring-1')
|
||||
->and($selectComponent)
|
||||
->toContain('focus-visible:border-ring')
|
||||
->not->toContain('focus-visible:ring-')
|
||||
->and($textareaComponent)
|
||||
->toContain('focus:border-ring')
|
||||
->toContain('focus:ring-0')
|
||||
->not->toContain('focus:ring-1');
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
it('does not render server capability summaries on the cluster page', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->not->toContain('Capabilities:')
|
||||
->not->toContain('normalizeCapabilities');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('keeps the cluster selector and add button the same height', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)->toContain('<SelectTrigger')
|
||||
->and($clustersPage)->toContain('className="w-full sm:w-72"')
|
||||
->and($clustersPage)->toContain('variant="coolify"')
|
||||
->and($clustersPage)->toContain('size="default"')
|
||||
->and($clustersPage)->not->toContain('variant="coolify"\n size="sm"');
|
||||
});
|
||||
+661
-117
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user