diff --git a/app/Http/Controllers/V5/HomeController.php b/app/Http/Controllers/V5/HomeController.php index 0c0b7c027..f5fc3eb53 100644 --- a/app/Http/Controllers/V5/HomeController.php +++ b/app/Http/Controllers/V5/HomeController.php @@ -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}>}> + * @return array, 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, 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}> $projects * @return array{0: array{uuid: string, name: string, environments: array}|null, 1: array{uuid: string, name: string}|null} diff --git a/app/Services/Coold/CoolifyCliBootstrap.php b/app/Services/Coold/CoolifyCliBootstrap.php deleted file mode 100644 index 3e9749a8b..000000000 --- a/app/Services/Coold/CoolifyCliBootstrap.php +++ /dev/null @@ -1,122 +0,0 @@ -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 $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) : ''; - } -} diff --git a/app/Services/Coold/CoolifyCliVersion.php b/app/Services/Coold/CoolifyCliVersion.php deleted file mode 100644 index c8bef86f5..000000000 --- a/app/Services/Coold/CoolifyCliVersion.php +++ /dev/null @@ -1,55 +0,0 @@ -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, - ]; - } -} diff --git a/resources/css/v5/app.css b/resources/css/v5/app.css index 124e6e4e1..84c33f859 100644 --- a/resources/css/v5/app.css +++ b/resources/css/v5/app.css @@ -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); diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx new file mode 100644 index 000000000..291b140bc --- /dev/null +++ b/resources/js/v5/Pages/Clusters.tsx @@ -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(clusters); + const [selectedClusterId, setSelectedClusterId] = useState(clusters[0]?.id ?? ''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [errors, setErrors] = useState({}); + 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): Promise { + 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 ( + <> + + +
+ + +
+
+ + +
+ {selectedCluster ? ( +
+
+

+ Cluster details +

+
+
+

{selectedCluster.name}

+

+ {selectedCluster.description ?? 'No description provided.'} +

+
+
+ {selectedCluster.serversCount}{' '} + {selectedCluster.serversCount === 1 ? 'server' : 'servers'} +
+
+
+ +
+
+
+

Servers in this cluster

+

+ Connection and builder details for each server assigned to this cluster. +

+
+
+ + {selectedCluster.servers.length === 0 ? ( +
+

No servers assigned

+

+ Servers will appear here after they are added to this cluster. +

+
+ ) : ( +
+ {selectedCluster.servers.map((server) => ( +
+
+
+

+ {server.name} +

+

{server.host}

+
+ + {server.status} + +
+ +
+
+
SSH
+
+ {server.sshUser}@{server.host}:{server.sshPort} +
+
+
+
Builder capacity
+
+ {server.builderEnabled ? server.builderCapacity : 'Disabled'} +
+
+
+
Private key
+
+ {server.privateKeyName ?? 'No key'} +
+
+
+
Last bootstrap
+
+ {formatDate(server.lastBootstrappedAt)} +
+
+
+ +

+ Capabilities: {normalizeCapabilities(server.capabilities)} +

+
+ ))} +
+ )} +
+
+ ) : ( +
+
+

No cluster selected

+

+ Create a cluster to start organizing servers. +

+
+
+ )} +
+ + { + setIsCreateDialogOpen(open); + + if (!open) { + setErrors({}); + } + }} + > + + + Create cluster + + Create an empty cluster now. Servers can be assigned after they exist. + + + +
+ + +