mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 17:55:59 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -8,4 +8,9 @@ createInertiaApp({
|
||||
extension: '.tsx',
|
||||
},
|
||||
strictMode: true,
|
||||
progress: {
|
||||
delay: 10,
|
||||
color: '#fcd452',
|
||||
showSpinner: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user