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 committed 2026-06-19 11:44:42 +02:00
1 parent 3ae87334bd
commit 24bbfe07c6
35 files changed
+2744 -639

No files matched your search

+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[];