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:
Andras Bacsai
2026-06-19 11:44:42 +02:00
parent 3ae87334bd
commit 24bbfe07c6
35 changed files with 2744 additions and 639 deletions
+104
View File
@@ -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(),
];
}
}
+45
View File
@@ -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,
];
}
}
+119 -102
View File
@@ -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,
];
}
}
+447
View File
@@ -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(',');
}
}
+6
View File
@@ -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',
];
}
+14 -1
View File
@@ -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']);
@@ -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',
]);
});
}
};
+8 -5
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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]} />
+199
View File
@@ -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>
</>
);
}
+1 -10
View File
@@ -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"
+4 -4
View File
@@ -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: {
+1 -1
View File
@@ -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,
}
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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}
+8 -3
View File
@@ -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[];
+22
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
+17
View File
@@ -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');
});
+32
View File
@@ -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"');
});
File diff suppressed because it is too large Load Diff