feat(v5): move clusters to dedicated page and remove CLI services

Extract clusters out of Home into a standalone Clusters page with its
own controller action and route. Delete CoolifyCliBootstrap and
CoolifyCliVersion services along with the coolifyCliVersion endpoint.

Add Dialog component, warning CSS token, Inertia progress bar, and a
Clusters nav link. Extend V5Server type with ssh/builder/key fields.
This commit is contained in:
Andras Bacsai
2026-06-16 20:52:38 +02:00
parent 327866cf90
commit 4c41817cda
13 changed files with 732 additions and 416 deletions
+69 -81
View File
@@ -4,18 +4,15 @@ namespace App\Http\Controllers\V5;
use App\Http\Controllers\Controller;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Coold\CoolifyCliBootstrap;
use App\Services\Coold\CoolifyCliVersion;
use App\Services\Flux\FluxHealth;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -33,16 +30,25 @@ class HomeController extends Controller
return Inertia::render('Home', [
'flux' => $fluxHealth->check(),
'clusters' => $this->clusters($currentTeam),
'projects' => $projects,
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
]);
}
public function coolifyCliVersion(CoolifyCliVersion $coolifyCliVersion): JsonResponse
public function clustersIndex(Request $request, FluxHealth $fluxHealth): Response
{
return response()->json($coolifyCliVersion->check());
$currentTeam = $request->attributes->get('v5.currentTeam');
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
return Inertia::render('Clusters', [
'flux' => $fluxHealth->check(),
'clusters' => $this->clusters($currentTeam),
'projects' => $projects,
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
]);
}
public function updateSelection(Request $request): \Illuminate\Http\Response
@@ -76,77 +82,43 @@ class HomeController extends Controller
return response()->noContent();
}
public function bootstrapCoolify(Request $request, CoolifyCliBootstrap $coolifyCliBootstrap): JsonResponse
public function storeCluster(Request $request): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
$validated = $request->validate([
'host' => ['required', 'string', 'max:255'],
'ssh_user' => ['required', 'string', 'max:64'],
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
'private_key_uuid' => ['required', 'string'],
'wg_listen_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'wg_endpoint' => ['nullable', 'string', 'max:255'],
'enable_builder' => ['boolean'],
'builder_capacity' => ['nullable', 'integer', 'min:0', 'max:100'],
]);
if (! $currentTeam instanceof Team) {
abort(403);
}
$privateKey = PrivateKey::query()
->where('team_id', $currentTeam->id)
->where('uuid', $validated['private_key_uuid'])
->first();
if (! $privateKey instanceof PrivateKey) {
return response()->json([
'successful' => false,
'label' => 'Private key unavailable',
'message' => 'The selected private key is not available for the current team.',
'output' => null,
'errorOutput' => null,
'exitCode' => null,
], 403);
}
$result = $coolifyCliBootstrap->run($validated, $privateKey);
if ($result['successful']) {
$this->recordBootstrappedServer($request->user(), $currentTeam, $privateKey, $validated);
}
return response()->json($result, $result['successful'] ? 200 : 500);
}
/**
* @param array{host: string, ssh_user: string, ssh_port: int, enable_builder?: bool, builder_capacity?: int|null} $input
*/
private function recordBootstrappedServer(User $user, Team $team, PrivateKey $privateKey, array $input): void
{
$builderEnabled = (bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true));
$builderCapacity = $builderEnabled ? (int) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2)) : 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
V5Server::query()->updateOrCreate([
'team_id' => $team->id,
'host' => $input['host'],
'ssh_port' => $input['ssh_port'],
], [
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => $input['host'],
'ssh_user' => $input['ssh_user'],
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'last_bootstrapped_at' => now(),
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id),
],
'description' => ['nullable', 'string', 'max:1000'],
]);
$cluster = V5Cluster::query()->create([
'team_id' => $currentTeam->id,
'created_by_user_id' => $request->user()->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
]);
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return response()->json([
'cluster' => $this->serializeCluster($cluster),
], 201);
}
/**
* @return array<int, array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, status: string, capabilities: array<int, string>}>}>
* @return array<int, array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>}>
*/
private function clusters(mixed $currentTeam): array
{
@@ -156,26 +128,42 @@ class HomeController extends Controller
return V5Cluster::query()
->where('team_id', $currentTeam->id)
->with(['servers' => fn ($query) => $query->orderBy('name')])
->with(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')])
->withCount('servers')
->orderBy('name')
->get()
->map(fn (V5Cluster $cluster) => [
'id' => (string) $cluster->id,
'name' => $cluster->name,
'description' => $cluster->description,
'serversCount' => $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 ?? [],
])->all(),
])
->map(fn (V5Cluster $cluster) => $this->serializeCluster($cluster))
->all();
}
/**
* @return array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>}
*/
private function serializeCluster(V5Cluster $cluster): array
{
return [
'id' => (string) $cluster->id,
'name' => $cluster->name,
'description' => $cluster->description,
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
'servers' => $cluster->servers->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'sshUser' => $server->ssh_user,
'sshPort' => $server->ssh_port,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'privateKeyName' => $server->privateKey?->name,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
])->all(),
];
}
/**
* @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}
-122
View File
@@ -1,122 +0,0 @@
<?php
namespace App\Services\Coold;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\Process;
use Throwable;
class CoolifyCliBootstrap
{
/**
* @param array{host: string, ssh_user: string, ssh_port: int, wg_listen_port?: int|null, wg_endpoint?: string|null, enable_builder?: bool, builder_capacity?: int|null} $input
* @return array{successful: bool, label: string, message: string, output: string|null, errorOutput: string|null, exitCode: int|null}
*/
public function run(array $input, PrivateKey $privateKey): array
{
$binary = $this->stringConfig('coold.coolify_cli_bin', '/usr/local/bin/coolify');
$sshKeyPath = $this->writeTemporaryPrivateKey($privateKey);
try {
$result = Process::timeout(300)->run($this->command($binary, $input, $sshKeyPath));
} catch (Throwable $exception) {
return [
'successful' => false,
'label' => 'Bootstrap failed',
'message' => $exception->getMessage(),
'output' => null,
'errorOutput' => null,
'exitCode' => null,
];
} finally {
@unlink($sshKeyPath);
}
$output = trim($result->output());
$errorOutput = trim($result->errorOutput());
return [
'successful' => $result->successful(),
'label' => $result->successful() ? 'Bootstrap finished' : 'Bootstrap failed',
'message' => $result->successful()
? 'coolify init bootstrap completed successfully.'
: ($errorOutput !== '' ? $errorOutput : 'coolify init bootstrap failed.'),
'output' => $output !== '' ? $output : null,
'errorOutput' => $errorOutput !== '' ? $errorOutput : null,
'exitCode' => $result->exitCode(),
];
}
private function command(string $binary, array $input, string $sshKeyPath): string
{
$node = sprintf('%s:%d', $input['host'], $input['ssh_port']);
$parts = [
$binary,
'init',
'bootstrap',
'--nodes',
$node,
'--ssh-key',
$sshKeyPath,
'--ssh-user',
$input['ssh_user'],
];
if (! empty($input['wg_listen_port'])) {
$this->appendOptional($parts, '--wg-listen-port-overrides', sprintf('%s=%d', $node, $input['wg_listen_port']));
}
if (! empty($input['wg_endpoint'])) {
$this->appendOptional($parts, '--wg-endpoint-overrides', sprintf('%s=%s', $node, $input['wg_endpoint']));
}
$this->appendOptional($parts, '--coold-version', $this->stringConfig('coold.coold_version', 'nightly'));
$this->appendOptional($parts, '--corrosion-version', $this->stringConfig('coold.corrosion_version', 'v1.0.0'));
if ((bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true))) {
$parts[] = '--enable-builder';
$this->appendOptional($parts, '--builder-capacity', (string) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2)));
}
$parts[] = '--yes';
return collect($parts)
->map(fn (string $part) => escapeshellarg($part))
->implode(' ');
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$directory = storage_path('app/private/coolify-cli');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
$path = tempnam($directory, 'ssh-key-');
file_put_contents($path, $privateKey->private_key);
chmod($path, 0600);
return $path;
}
/**
* @param array<int, string> $parts
*/
private function appendOptional(array &$parts, string $option, string $value): void
{
if ($value === '') {
return;
}
$parts[] = $option;
$parts[] = $value;
}
private function stringConfig(string $key, ?string $default = null): string
{
$value = config($key, $default);
return is_string($value) ? trim($value) : '';
}
}
-55
View File
@@ -1,55 +0,0 @@
<?php
namespace App\Services\Coold;
use Illuminate\Support\Facades\Process;
use Throwable;
class CoolifyCliVersion
{
/**
* @return array{available: bool, label: string, version: string|null, message: string, binary: string|null}
*/
public function check(): array
{
$binary = config('coold.coolify_cli_bin', '/usr/local/bin/coolify');
if (! is_string($binary) || $binary === '') {
return $this->unavailable(null, 'coolify binary is not configured.');
}
try {
$result = Process::timeout(5)->run(escapeshellarg($binary).' --version');
} catch (Throwable $exception) {
return $this->unavailable($binary, $exception->getMessage());
}
if (! $result->successful()) {
return $this->unavailable($binary, trim($result->errorOutput()) ?: 'coolify version check failed.');
}
$version = trim($result->output());
return [
'available' => true,
'label' => 'Installed',
'version' => $version !== '' ? $version : null,
'message' => $version !== '' ? "Installed version: {$version}." : 'coolify is installed.',
'binary' => $binary,
];
}
/**
* @return array{available: false, label: string, version: null, message: string, binary: string|null}
*/
private function unavailable(?string $binary, string $message): array
{
return [
'available' => false,
'label' => 'Unavailable',
'version' => null,
'message' => $message,
'binary' => $binary,
];
}
}
+3
View File
@@ -28,6 +28,7 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-warning: var(--warning);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -72,6 +73,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
@@ -107,6 +109,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
+338
View File
@@ -0,0 +1,338 @@
import { Head } from '@inertiajs/react';
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { csrfToken } from '@/lib/csrf';
import type { V5Cluster, V5HomeProps } from '@/types';
type ClusterFormErrors = {
name?: string[];
description?: string[];
};
type StoreClusterResponse = {
cluster: V5Cluster;
};
function formatDate(value: string | null): string {
if (value === null) {
return 'Never';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function normalizeCapabilities(capabilities: string[]): string {
if (capabilities.length === 0) {
return 'No capabilities';
}
return capabilities.join(', ');
}
export default function Clusters({
flux,
clusters = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}: V5HomeProps) {
const [clusterList, setClusterList] = useState<V5Cluster[]>(clusters);
const [selectedClusterId, setSelectedClusterId] = useState<string>(clusters[0]?.id ?? '');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [errors, setErrors] = useState<ClusterFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const selectedCluster = useMemo(
() => clusterList.find((cluster) => cluster.id === selectedClusterId) ?? clusterList[0] ?? null,
[clusterList, selectedClusterId],
);
async function createCluster(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setIsSubmitting(true);
setErrors({});
const response = await fetch('/v5/clusters', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({
name,
description: description.trim() === '' ? null : description,
}),
});
if (response.status === 422) {
const payload = (await response.json()) as { errors?: ClusterFormErrors };
setErrors(payload.errors ?? {});
setIsSubmitting(false);
return;
}
if (!response.ok) {
setErrors({
name: ['Unable to create this cluster. Please try again.'],
});
setIsSubmitting(false);
return;
}
const payload = (await response.json()) as StoreClusterResponse;
const nextClusters = [...clusterList, payload.cluster].sort((first, second) => first.name.localeCompare(second.name));
setClusterList(nextClusters);
setSelectedClusterId(payload.cluster.id);
setName('');
setDescription('');
setIsCreateDialogOpen(false);
setIsSubmitting(false);
}
return (
<>
<Head title="Clusters" />
<div className="h-dvh overflow-hidden bg-background text-foreground">
<AppNavbar
flux={flux}
clusters={clusterList}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
<main className="flex h-full min-h-0 overflow-hidden px-6 pt-16">
<section className="grid min-h-0 w-full grid-cols-1 gap-4 py-6 lg:grid-cols-[20rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col rounded-lg border border-border bg-card">
<div className="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<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="outline"
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'
}`}
>
<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>
)}
</div>
</aside>
<section className="min-h-0 overflow-y-auto rounded-lg border border-border bg-card">
{selectedCluster ? (
<div className="flex min-h-full flex-col">
<div className="border-b border-border p-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Cluster details
</p>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-2xl font-semibold text-foreground">{selectedCluster.name}</h2>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{selectedCluster.description ?? 'No description provided.'}
</p>
</div>
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{selectedCluster.serversCount}{' '}
{selectedCluster.serversCount === 1 ? 'server' : 'servers'}
</div>
</div>
</div>
<div className="flex-1 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-foreground">Servers in this cluster</h3>
<p className="text-sm text-muted-foreground">
Connection and builder details for each server assigned to this cluster.
</p>
</div>
</div>
{selectedCluster.servers.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm font-medium text-foreground">No servers assigned</p>
<p className="mt-1 text-sm text-muted-foreground">
Servers will appear here after they are added to this cluster.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{selectedCluster.servers.map((server) => (
<article
key={server.id}
className="rounded-lg border border-border bg-background p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-foreground">
{server.name}
</h4>
<p className="mt-1 text-xs text-muted-foreground">{server.host}</p>
</div>
<span className="rounded-full border border-border bg-muted/40 px-2 py-1 text-xs text-muted-foreground">
{server.status}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">SSH</dt>
<dd className="mt-1 font-medium text-foreground">
{server.sshUser}@{server.host}:{server.sshPort}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Builder capacity</dt>
<dd className="mt-1 font-medium text-foreground">
{server.builderEnabled ? server.builderCapacity : 'Disabled'}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Private key</dt>
<dd className="mt-1 font-medium text-foreground">
{server.privateKeyName ?? 'No key'}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Last bootstrap</dt>
<dd className="mt-1 font-medium text-foreground">
{formatDate(server.lastBootstrappedAt)}
</dd>
</div>
</dl>
<p className="mt-4 text-xs text-muted-foreground">
Capabilities: {normalizeCapabilities(server.capabilities)}
</p>
</article>
))}
</div>
)}
</div>
</div>
) : (
<div className="flex min-h-full items-center justify-center p-8 text-center">
<div>
<p className="text-sm font-medium text-foreground">No cluster selected</p>
<p className="mt-1 text-sm text-muted-foreground">
Create a cluster to start organizing servers.
</p>
</div>
</div>
)}
</section>
<Dialog
open={isCreateDialogOpen}
onOpenChange={(open) => {
setIsCreateDialogOpen(open);
if (!open) {
setErrors({});
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create cluster</DialogTitle>
<DialogDescription>
Create an empty cluster now. Servers can be assigned after they exist.
</DialogDescription>
</DialogHeader>
<form className="mt-5 flex flex-col gap-4" onSubmit={createCluster}>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-foreground">Name</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="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"
placeholder="Production Mesh"
/>
{errors.name ? <span className="text-xs text-destructive">{errors.name[0]}</span> : null}
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-foreground">Description</span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
className="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"
placeholder="What this cluster is used for"
/>
{errors.description ? (
<span className="text-xs text-destructive">{errors.description[0]}</span>
) : null}
</label>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>Cancel</DialogClose>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create cluster'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</main>
</div>
</>
);
}
-2
View File
@@ -5,7 +5,6 @@ import type { V5HomeProps } from '@/types';
export default function Home({
flux,
clusters = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
@@ -17,7 +16,6 @@ export default function Home({
<div className="h-dvh overflow-hidden bg-background text-foreground">
<AppNavbar
flux={flux}
clusters={clusters}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
+5
View File
@@ -8,4 +8,9 @@ createInertiaApp({
extension: '.tsx',
},
strictMode: true,
progress: {
delay: 10,
color: '#fcd452',
showSpinner: false,
},
});
+10 -2
View File
@@ -1,3 +1,4 @@
import { Link } from '@inertiajs/react';
import { useMemo, useState } from 'react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -76,13 +77,13 @@ export function AppNavbar({
return (
<header className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background">
<nav className="flex h-16 items-center gap-4 px-6" aria-label="Main navigation">
<a
<Link
href="/v5"
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 home"
>
<img src="/coolify-logo.svg" alt="Coolify" className="size-8" />
</a>
</Link>
<div className="flex min-w-0 items-center gap-2">
<Select
@@ -129,6 +130,13 @@ export function AppNavbar({
</div>
<div className="ml-auto flex items-center gap-3">
<Link
href="/v5/clusters"
className="rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
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}
+72
View File
@@ -0,0 +1,72 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn('fixed inset-0 bg-background/80 backdrop-blur-sm', className)}
{...props}
/>
);
}
function DialogContent({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed left-1/2 top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-lg outline-none',
className,
)}
{...props}
/>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-1.5', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-footer" className={cn('flex justify-end gap-2', className)} {...props} />;
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle };
+6
View File
@@ -9,8 +9,14 @@ export type V5Server = {
id: string;
name: string;
host: string;
sshUser: string;
sshPort: number;
status: string;
capabilities: string[];
builderEnabled: boolean;
builderCapacity: number;
privateKeyName: string | null;
lastBootstrappedAt: string | null;
};
export type V5Cluster = {
+5
View File
@@ -6,6 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title inertia>{{ config('app.name') }} v5</title>
@env('local')
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/png" />
@else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
@php
$viteHotFile = public_path('hot');
$viteDevServerUrl = null;
+2 -2
View File
@@ -6,6 +6,6 @@ use Illuminate\Support\Facades\Route;
Route::middleware('v5.authenticated')->group(function () {
Route::get('/', HomeController::class)->name('home');
Route::post('/selection', [HomeController::class, 'updateSelection'])->name('selection.update');
Route::get('/coolify/version', [HomeController::class, 'coolifyCliVersion'])->name('coolify.version');
Route::post('/coolify/bootstrap', [HomeController::class, 'bootstrapCoolify'])->name('coolify.bootstrap');
Route::get('/clusters', [HomeController::class, 'clustersIndex'])->name('clusters.index');
Route::post('/clusters', [HomeController::class, 'storeCluster'])->name('clusters.store');
});
+222 -152
View File
@@ -16,7 +16,6 @@ use App\Services\Flux\FluxHealth;
use Database\Seeders\V5DevLimaSeeder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
@@ -35,7 +34,11 @@ beforeEach(function () {
it('registers the v5 home route', function () {
expect(Route::has('v5.home'))->toBeTrue()
->and(Route::has('v5.selection.update'))->toBeTrue();
->and(Route::has('v5.selection.update'))->toBeTrue()
->and(Route::has('v5.clusters.index'))->toBeTrue()
->and(Route::has('v5.clusters.store'))->toBeTrue()
->and(Route::has('v5.coolify.version'))->toBeFalse()
->and(Route::has('v5.coolify.bootstrap'))->toBeFalse();
});
it('uses separated v5 middleware groups', function () {
@@ -142,6 +145,8 @@ it('redirects guests to the shared login', function () {
});
it('serves the v5 inertia shell', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
@@ -166,6 +171,8 @@ it('serves the v5 inertia shell', function () {
->get('/v5')
->assertSuccessful()
->assertSee('<html lang="en" class="dark">', false)
->assertSee('coolify-logo-dev-transparent.png', false)
->assertDontSee('coolify-logo.svg', false)
->assertSee('v5-app', false)
->assertSee('Home', false)
->assertDontSee('v5-ready', false)
@@ -174,9 +181,11 @@ it('serves the v5 inertia shell', function () {
->assertDontSee('privateKeys', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertSee('"clusters":[]', false)
->assertDontSee('"clusters":', false)
->assertDontSee('cooldServers', false)
->assertDontSee('coold-dev')
->assertDontSee('Create cluster')
->assertDontSee('Cluster details')
->assertDontSee('100.64.0.1')
->assertDontSee('Current team')
->assertDontSee('Your teams')
@@ -186,7 +195,25 @@ it('serves the v5 inertia shell', function () {
->assertDontSee('Shared team details');
});
it('shows v5 clusters with their servers on the inertia shell', function () {
it('serves the production v5 favicon outside local environments', function () {
app()->detectEnvironment(fn () => 'production');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('coolify-logo.svg', false)
->assertDontSee('coolify-logo-dev-transparent.png', false);
});
it('shows v5 clusters with their servers on the cluster page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
@@ -218,13 +245,128 @@ it('shows v5 clusters with their servers on the inertia shell', function () {
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Clusters', false)
->assertSee('"clusters":[', false)
->assertSee('"name":"Development-Lima"', false)
->assertSee('"serversCount":1', false)
->assertSee('"name":"coold-dev"', false)
->assertSee('"host":"lima-coold-dev"', false);
->assertSee('"host":"lima-coold-dev"', false)
->assertSee('"sshUser":"developer"', false)
->assertSee('"sshPort":22', false)
->assertSee('"builderEnabled":true', false)
->assertSee('"builderCapacity":2', false)
->assertSee('"privateKeyName":"Lima Key"', false)
->assertSee('"lastBootstrappedAt":"', false);
});
it('creates a v5 cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Primary production cluster.',
])
->assertCreated()
->assertJsonPath('cluster.name', 'Production Mesh')
->assertJsonPath('cluster.description', 'Primary production cluster.')
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(Cluster::query()
->where('team_id', $team->id)
->where('created_by_user_id', $user->id)
->where('name', 'Production Mesh')
->where('description', 'Primary production cluster.')
->exists())->toBeTrue();
});
it('validates v5 cluster creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => '',
'description' => str_repeat('a', 1001),
])
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'description']);
});
it('rejects duplicate v5 cluster names in the same team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => null,
])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
it('allows the same v5 cluster name in another team without leaking it', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Production Mesh',
'description' => 'Other team cluster.',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Current team cluster.',
])
->assertCreated()
->assertJsonPath('cluster.description', 'Current team cluster.');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Current team cluster.')
->assertDontSee('Other team cluster.');
});
it('shares existing projects and environments with the v5 home page', function () {
@@ -318,17 +460,28 @@ it('rejects persisted v5 selections outside the current team', function () {
it('defines the v5 home page as a shadcn styled canvas shell', function () {
$homePage = file_get_contents(resource_path('js/v5/Pages/Home.tsx'));
$app = file_get_contents(resource_path('js/v5/app.tsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
expect(file_exists($navbarPath))->toBeTrue();
$navbar = file_get_contents($navbarPath);
expect($app)
->toContain('progress: {')
->toContain('delay: 250')
->toContain("color: '#fcd452'")
->toContain('showSpinner: false')
->not->toContain('TopNavigationLoadingIndicator')
->not->toContain('withApp:');
expect($homePage)
->toContain('Magic')
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain('function csrfToken()')
->not->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
->toContain('h-dvh overflow-hidden bg-background text-foreground')
@@ -338,14 +491,15 @@ it('defines the v5 home page as a shadcn styled canvas shell', function () {
->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground')
->toContain('This is where the magic happens.')
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->not->toContain("fetch('/v5/selection'")
->not->toContain("'X-CSRF-TOKEN': csrfToken()");
->not->toContain("fetch('/v5/selection'");
expect($navbar)
->toContain("import { Link } from '@inertiajs/react';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain('function csrfToken()')
->toContain('export function AppNavbar')
->toContain('/coolify-logo.svg')
->toContain('<Link')
->toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"')
->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"')
->toContain('bg-muted/40')
@@ -357,8 +511,12 @@ it('defines the v5 home page as a shadcn styled canvas shell', function () {
->toContain('sideOffset={4}')
->toContain('Select a project')
->toContain('Select an environment')
->toContain('href="/v5"')
->toContain('href="/v5/clusters"')
->toContain('Clusters')
->toContain("fetch('/v5/selection'")
->toContain("'X-CSRF-TOKEN': csrfToken()")
->not->toContain('<a')
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain('<Button')
->not->toContain("import { Separator } from '@/components/ui/separator';")
@@ -374,6 +532,61 @@ it('defines the v5 home page as a shadcn styled canvas shell', function () {
->not->toContain('className="w-[12rem]"')
->not->toContain('<h1>Coolify v5</h1>')
->not->toContain('<h2 id="clusters-heading">Clusters</h2>');
expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse();
});
it('defines the v5 cluster management page and create cluster form', function () {
$clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx');
$clustersPage = file_get_contents($clustersPagePath);
$types = file_get_contents(resource_path('js/v5/types.ts'));
expect(file_exists($clustersPagePath))->toBeTrue();
expect($clustersPage)
->toContain("import { Button } from '@/components/ui/button';")
->toContain("} from '@/components/ui/dialog';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->toContain("fetch('/v5/clusters'")
->toContain('aria-label="Create cluster"')
->toContain('setIsCreateDialogOpen(true)')
->toContain('Add cluster')
->toContain('border-warning bg-warning/10 text-foreground')
->not->toContain('border-primary bg-primary/10 text-foreground')
->toContain('<Dialog')
->toContain('<DialogTitle>Create cluster</DialogTitle>')
->toContain('<DialogDescription>')
->toContain('<DialogFooter>')
->toContain('<DialogClose')
->toContain('Create cluster')
->toContain('Cluster details')
->toContain('Servers in this cluster')
->toContain('selectedCluster')
->toContain('builderCapacity')
->toContain('privateKeyName')
->toContain('lastBootstrappedAt')
->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]')
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]')
->not->toContain('New cluster')
->not->toContain('<aside className="rounded-lg border border-border bg-card p-5">')
->not->toContain('This is where the magic happens.');
expect(file_get_contents(resource_path('js/v5/components/ui/dialog.tsx')))
->toContain('@base-ui/react/dialog')
->toContain('DialogTitle')
->toContain('DialogDescription');
expect(file_get_contents(resource_path('css/v5/app.css')))
->toContain('--color-warning: var(--warning);')
->toContain('--warning: #fcd452;');
expect($types)
->toContain('sshUser: string;')
->toContain('sshPort: number;')
->toContain('builderEnabled: boolean;')
->toContain('builderCapacity: number;')
->toContain('privateKeyName: string | null;')
->toContain('lastBootstrappedAt: string | null;');
});
it('defines a ghost variant for compact v5 select triggers', function () {
@@ -530,149 +743,6 @@ it('renders flux status as a compact summary', function () {
->not->toContain('Socket: {flux.socket}');
});
it('checks the installed coolify version', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Process::fake([
'*' => Process::result(output: 'coolify nightly-20260616', exitCode: 0),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->getJson('/v5/coolify/version')
->assertSuccessful()
->assertJson([
'available' => true,
'label' => 'Installed',
'version' => 'coolify nightly-20260616',
'message' => 'Installed version: coolify nightly-20260616.',
'binary' => '/usr/local/bin/coolify',
]);
});
it('shows when coolify version check fails', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Process::fake([
'*' => Process::result(errorOutput: 'not found', exitCode: 127),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->getJson('/v5/coolify/version')
->assertSuccessful()
->assertJson([
'available' => false,
'label' => 'Unavailable',
'version' => null,
'message' => 'not found',
'binary' => '/usr/local/bin/coolify',
]);
});
it('rejects coolify bootstrap when the selected private key is not owned by the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$privateKey = createV5PrivateKey($otherTeam, 'Other Key');
Process::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/coolify/bootstrap', [
'host' => '192.0.2.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_uuid' => $privateKey->uuid,
])
->assertForbidden()
->assertJson([
'successful' => false,
'label' => 'Private key unavailable',
]);
Process::assertDidntRun(fn () => true);
});
it('runs coolify bootstrap from dynamic UI input and a selected team private key', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Bootstrap Key');
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
Config::set('coold.dev_builder_capacity', 2);
Process::fake([
'*' => Process::result(output: 'Bootstrapping mesh...', exitCode: 0),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/coolify/bootstrap', [
'host' => '192.0.2.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'private_key_uuid' => $privateKey->uuid,
'wg_listen_port' => 51821,
'wg_endpoint' => 'example.test:51821',
'enable_builder' => true,
'builder_capacity' => 3,
])
->assertSuccessful()
->assertJson([
'successful' => true,
'label' => 'Bootstrap finished',
'message' => 'coolify init bootstrap completed successfully.',
'output' => 'Bootstrapping mesh...',
'exitCode' => 0,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertDontSee('"host":"192.0.2.10"', false)
->assertDontSee('"capabilities":["coold","builder"]', false);
expect(V5Server::query()->where('host', '192.0.2.10')->where('status', 'installed')->exists())->toBeTrue();
$sshKeyPath = null;
Process::assertRan(function ($process) use (&$sshKeyPath) {
preg_match("/'--ssh-key' '([^']+)'/", $process->command, $matches);
$sshKeyPath = $matches[1] ?? null;
return $process->timeout === 300
&& str_contains($process->command, "'/usr/local/bin/coolify' 'init' 'bootstrap'")
&& str_contains($process->command, "'--nodes' '192.0.2.10:2222'")
&& str_contains($process->command, "'--ssh-user' 'ubuntu'")
&& str_contains($process->command, "'--wg-listen-port-overrides' '192.0.2.10:2222=51821'")
&& str_contains($process->command, "'--wg-endpoint-overrides' '192.0.2.10:2222=example.test:51821'")
&& str_contains($process->command, "'--coold-version' 'nightly'")
&& str_contains($process->command, "'--corrosion-version' 'v1.0.0'")
&& str_contains($process->command, "'--enable-builder'")
&& str_contains($process->command, "'--builder-capacity' '3'")
&& str_contains($process->command, "'--yes'")
&& ! str_contains($process->command, 'COOLIFY_CLI_NODES');
});
expect($sshKeyPath)->not->toBeNull()
->and(file_exists($sshKeyPath))->toBeFalse();
});
it('syncs dev Lima VMs into v5 clusters and servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();