coolify/resources/js/v5/Pages/Clusters.tsx
Andras Bacsai 6ae45684f9 feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies,
add agent token rotation/revocation, status reconciliation jobs,
ingress firewall syncing, and canvas connection APIs.

Add migrations for V5 status tracking, server capabilities, resource
connection aliases, and revoked agent tokens.
2026-07-06 17:40:37 +02:00

2576 lines
141 KiB
TypeScript

import { Head } from '@inertiajs/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 { CanvasNotice } from '@/components/canvas/canvas-notice';
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';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { apiRequest } from '@/lib/api';
import { usePendingIds } from '@/lib/use-pending-ids';
import { useTeamChannel } from '@/lib/use-team-channel';
import type { V5Cluster, V5DashboardProps, V5Server } from '@/types';
type ClusterFormErrors = {
name?: string[];
description?: string[];
wireguard_interface?: string[];
wireguard_management_pool?: string[];
wireguard_listen_port?: string[];
container_network_pool?: string[];
container_network_prefix?: string[];
namespaces?: string[];
default_deny_containers?: string[];
coold_version?: string[];
corrosion_version?: string[];
corrosion_gossip_port?: string[];
corrosion_api_port?: string[];
builder_enabled?: string[];
builder_capacity?: string[];
builder_cpu_quota?: string[];
builder_memory_max?: string[];
builder_timeout_secs?: string[];
};
type StoreClusterResponse = {
cluster: V5Cluster;
};
type ServerFormErrors = {
name?: string[];
host?: string[];
ssh_user?: string[];
ssh_port?: string[];
private_key_uuid?: string[];
node_address?: string[];
builder_enabled?: string[];
builder_capacity?: string[];
builder_cpu_quota?: string[];
ingress_type?: string[];
wireguard_listen_port_override?: string[];
wireguard_endpoint_override?: string[];
};
type StoreServerResponse = {
cluster: V5Cluster;
};
type UpdateServerResponse = {
cluster: V5Cluster;
};
type CheckServerResponse = {
status: string;
output: string;
checkedAt: string;
};
type DeleteServerResponse = {
cluster: V5Cluster;
};
type CooldLogsResponse = {
output: string;
fetchedAt: string;
source: 'flux' | 'ssh';
};
type CorrosionTablesResponse = {
output: string;
fetchedAt: string;
source: 'flux' | 'ssh';
};
type FirewallRule = {
id?: string;
namespace?: string;
src?: string;
dst?: string;
proto?: string;
port?: number;
};
type FirewallRulesResponse = {
rules: FirewallRule[];
fetchedAt: string;
};
type CorrosionTableDump = {
limit?: number;
tables?: CorrosionTable[];
};
type CorrosionTable = {
name: string;
columns: string[];
rows: unknown[][];
};
type BootstrapServerResponse = {
cluster?: V5Cluster;
message?: string;
};
type ServerConnectionNotice = {
message: string;
description: string;
variant: 'danger' | 'success';
};
type V5ClusterUpdatedEvent = {
cluster: V5Cluster | null;
};
type ParsedBootstrapLogSummary = {
label: string;
value: string;
tone: 'success' | 'muted';
};
type ParsedBootstrapLogs = {
summary: ParsedBootstrapLogSummary[];
visibleOutput: string;
rawOutput: string;
};
function formatCorrosionCell(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function parseCorrosionTables(output: string): CorrosionTableDump | null {
if (!output.trim()) {
return null;
}
try {
const parsed = JSON.parse(output) as CorrosionTableDump;
return Array.isArray(parsed.tables) ? parsed : null;
} catch {
return null;
}
}
function jsonValueToString(value: unknown): string {
if (value === null || value === undefined || value === '') {
return 'n/a';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function initialJsonEnd(output: string): number | null {
const trimmedStart = output.search(/\S/);
if (trimmedStart === -1 || output[trimmedStart] !== '{') {
return null;
}
let depth = 0;
let inString = false;
let isEscaped = false;
for (let index = trimmedStart; index < output.length; index += 1) {
const character = output[index];
if (isEscaped) {
isEscaped = false;
continue;
}
if (character === '\\') {
isEscaped = inString;
continue;
}
if (character === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (character === '{') {
depth += 1;
}
if (character === '}') {
depth -= 1;
}
if (depth === 0) {
return index + 1;
}
}
return null;
}
function hideRawBootstrapPlan(output: string): string {
const visibleLines: string[] = [];
let isSkippingPlan = false;
output.split('\n').forEach((line) => {
if (line.trim() === 'Plan:') {
isSkippingPlan = true;
return;
}
if (isSkippingPlan) {
const trimmedLine = line.trim();
if (trimmedLine === '' || trimmedLine.startsWith('[') || line.startsWith(' ')) {
return;
}
isSkippingPlan = false;
}
visibleLines.push(line);
});
return visibleLines.join('\n').trim();
}
function parseBootstrapOutput(output: string | null): ParsedBootstrapLogs {
const rawOutput = output?.trim() || 'No install logs captured yet.';
const jsonEnd = initialJsonEnd(rawOutput);
if (jsonEnd === null) {
return {
summary: [],
visibleOutput: rawOutput,
rawOutput,
};
}
try {
const parsed = JSON.parse(rawOutput.slice(0, jsonEnd)) as {
results?: Array<{
action?: { action?: unknown; host?: unknown };
status?: unknown;
detail?: unknown;
}>;
verified?: Array<Record<string, unknown>>;
};
const summary = [
...(parsed.results ?? []).map((result) => ({
label: jsonValueToString(result.action?.action ?? result.action?.host ?? 'Action'),
value: `${jsonValueToString(result.status)}${result.detail ? ` · ${jsonValueToString(result.detail)}` : ''}`,
tone: result.status === 'ok' ? ('success' as const) : ('muted' as const),
})),
...(parsed.verified ?? []).map((node) => ({
label: `Verified ${jsonValueToString(node.host)}`,
value: [
`status ${jsonValueToString(node.status)}`,
`wg ${jsonValueToString(node.wireguard_ip)}`,
`peers ${jsonValueToString(node.peer_count)}`,
].join(' · '),
tone: node.status === 'ok' ? ('success' as const) : ('muted' as const),
})),
];
const remainingOutput = rawOutput.slice(jsonEnd).trim();
return {
summary,
visibleOutput: hideRawBootstrapPlan(remainingOutput),
rawOutput,
};
} catch {
return {
summary: [],
visibleOutput: rawOutput,
rawOutput,
};
}
}
function statusLabel(status: string): string {
return status
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function statusBadgeClass(status: string): string {
if (status === 'installed' || status === 'running') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400';
}
if (['queued', 'starting', 'bootstrapping'].includes(status)) {
return 'border-warning/30 bg-warning/10 text-warning';
}
if (['unreachable', 'failed', 'error'].includes(status)) {
return 'border-destructive/30 bg-destructive/10 text-destructive';
}
return 'border-border bg-muted/40 text-muted-foreground';
}
const clusterDefaults = {
wireguardInterface: 'wg0',
wireguardManagementPool: '100.64.0.0/16',
wireguardListenPort: '51820',
containerNetworkPool: '10.210.0.0/16',
containerNetworkPrefix: '24',
namespaces: 'default',
defaultDenyContainers: true,
cooldVersion: 'nightly',
corrosionVersion: 'v1.0.0',
corrosionGossipPort: '8787',
corrosionApiPort: '8080',
builderEnabled: true,
builderCapacity: '2',
builderCpuQuota: '200%',
builderMemoryMax: '2G',
builderTimeoutSecs: '1800',
};
const ingressTypes = [
{
label: 'Caddy',
value: 'caddy',
},
];
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 diagnosticsSourceLabel(source: 'flux' | 'ssh' | null): string {
if (source === 'ssh') {
return 'SSH';
}
if (source === 'flux') {
return 'Flux';
}
return 'Unknown';
}
export default function Clusters({
flux,
currentTeam = null,
clusters = [],
privateKeys = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}: V5DashboardProps) {
const [clusterList, setClusterList] = useState<V5Cluster[]>(clusters);
const [selectedClusterId, setSelectedClusterId] = useState<string>(clusters[0]?.id ?? '');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [wireguardInterface, setWireguardInterface] = useState(clusterDefaults.wireguardInterface);
const [wireguardManagementPool, setWireguardManagementPool] = useState(clusterDefaults.wireguardManagementPool);
const [wireguardListenPort, setWireguardListenPort] = useState(clusterDefaults.wireguardListenPort);
const [containerNetworkPool, setContainerNetworkPool] = useState(clusterDefaults.containerNetworkPool);
const [containerNetworkPrefix, setContainerNetworkPrefix] = useState(clusterDefaults.containerNetworkPrefix);
const [namespaces, setNamespaces] = useState(clusterDefaults.namespaces);
const [defaultDenyContainers, setDefaultDenyContainers] = useState(clusterDefaults.defaultDenyContainers);
const [cooldVersion, setCooldVersion] = useState(clusterDefaults.cooldVersion);
const [corrosionVersion, setCorrosionVersion] = useState(clusterDefaults.corrosionVersion);
const [corrosionGossipPort, setCorrosionGossipPort] = useState(clusterDefaults.corrosionGossipPort);
const [corrosionApiPort, setCorrosionApiPort] = useState(clusterDefaults.corrosionApiPort);
const [builderEnabled, setBuilderEnabled] = useState(clusterDefaults.builderEnabled);
const [builderCapacity, setBuilderCapacity] = useState(clusterDefaults.builderCapacity);
const [builderCpuQuota, setBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [builderMemoryMax, setBuilderMemoryMax] = useState(clusterDefaults.builderMemoryMax);
const [builderTimeoutSecs, setBuilderTimeoutSecs] = useState(clusterDefaults.builderTimeoutSecs);
const [errors, setErrors] = useState<ClusterFormErrors>({});
const [serverName, setServerName] = useState('');
const [serverHost, setServerHost] = useState('');
const [serverSshUser, setServerSshUser] = useState('root');
const [serverSshPort, setServerSshPort] = useState('22');
const [selectedPrivateKeyId, setSelectedPrivateKeyId] = useState('');
const [serverNodeAddress, setServerNodeAddress] = useState('');
const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true);
const [serverIngressEnabled, setServerIngressEnabled] = useState(false);
const [serverIngressType, setServerIngressType] = useState('caddy');
const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2');
const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState('');
const [wireguardEndpointOverride, setWireguardEndpointOverride] = useState('');
const [serverErrors, setServerErrors] = useState<ServerFormErrors>({});
const [editingServer, setEditingServer] = useState<V5Server | null>(null);
const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true);
const [editServerIngressEnabled, setEditServerIngressEnabled] = useState(false);
const [editServerIngressType, setEditServerIngressType] = useState('caddy');
const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2');
const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [editServerErrors, setEditServerErrors] = useState<ServerFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isServerSubmitting, setIsServerSubmitting] = useState(false);
const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false);
const checkingServers = usePendingIds<string>();
const [serverConnectionNotice, setServerConnectionNotice] = useState<ServerConnectionNotice | null>(null);
const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false);
const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState<string | null>(null);
const bootstrappingServers = usePendingIds<string>();
const [bootstrapServerError, setBootstrapServerError] = useState<string | null>(null);
const deletingServers = usePendingIds<string>();
const [isDeletingCluster, setIsDeletingCluster] = useState(false);
const [deleteClusterError, setDeleteClusterError] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [clusterPendingDelete, setClusterPendingDelete] = useState<V5Cluster | null>(null);
const [serverPendingDelete, setServerPendingDelete] = useState<V5Server | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isAddServerDialogOpen, setIsAddServerDialogOpen] = useState(false);
const [isEditServerDialogOpen, setIsEditServerDialogOpen] = useState(false);
const [isCooldLogsDialogOpen, setIsCooldLogsDialogOpen] = useState(false);
const [cooldLogsServer, setCooldLogsServer] = useState<V5Server | null>(null);
const [cooldLogsOutput, setCooldLogsOutput] = useState('');
const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState<string | null>(null);
const [cooldLogsSource, setCooldLogsSource] = useState<'flux' | 'ssh' | null>(null);
const [cooldLogsError, setCooldLogsError] = useState<string | null>(null);
const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false);
const [isCorrosionTablesDialogOpen, setIsCorrosionTablesDialogOpen] = useState(false);
const [corrosionTablesServer, setCorrosionTablesServer] = useState<V5Server | null>(null);
const [corrosionTablesOutput, setCorrosionTablesOutput] = useState('');
const [corrosionTablesFetchedAt, setCorrosionTablesFetchedAt] = useState<string | null>(null);
const [corrosionTablesSource, setCorrosionTablesSource] = useState<'flux' | 'ssh' | null>(null);
const [corrosionTablesError, setCorrosionTablesError] = useState<string | null>(null);
const [isLoadingCorrosionTables, setIsLoadingCorrosionTables] = useState(false);
const [isFirewallRulesDialogOpen, setIsFirewallRulesDialogOpen] = useState(false);
const [firewallRulesServer, setFirewallRulesServer] = useState<V5Server | null>(null);
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([]);
const [firewallRulesFetchedAt, setFirewallRulesFetchedAt] = useState<string | null>(null);
const [firewallRulesError, setFirewallRulesError] = useState<string | null>(null);
const [isLoadingFirewallRules, setIsLoadingFirewallRules] = useState(false);
const [showAdvancedConfiguration, setShowAdvancedConfiguration] = useState(false);
const [showAdvancedServerConfiguration, setShowAdvancedServerConfiguration] = useState(false);
const selectedCluster = useMemo(
() => clusterList.find((cluster) => cluster.id === selectedClusterId) ?? clusterList[0] ?? null,
[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;
const bootstrapLogsServer = useMemo(
() => clusterList.flatMap((cluster) => cluster.servers).find((server) => server.id === bootstrapLogsServerId) ?? null,
[bootstrapLogsServerId, clusterList],
);
const parsedBootstrapLogs = parseBootstrapOutput(bootstrapLogsServer?.lastBootstrapOutput ?? null);
useTeamChannel(currentTeam?.id ?? null, '.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)),
);
});
useEffect(() => {
if (!selectedCluster || !hasBootstrapInProgress) {
return;
}
let isCancelled = false;
async function refreshCluster(): Promise<void> {
if (!selectedCluster) {
return;
}
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}`, { method: 'GET' }).catch(() => null);
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);
setErrors({});
const response = await apiRequest('/v5/clusters', {
method: 'POST',
body: {
name,
description: description.trim() === '' ? null : description,
wireguard_interface: wireguardInterface,
wireguard_management_pool: wireguardManagementPool,
wireguard_listen_port: Number(wireguardListenPort),
container_network_pool: containerNetworkPool,
container_network_prefix: Number(containerNetworkPrefix),
namespaces: namespaces
.split(',')
.map((namespace) => namespace.trim())
.filter(Boolean),
default_deny_containers: defaultDenyContainers,
coold_version: cooldVersion,
corrosion_version: corrosionVersion,
corrosion_gossip_port: Number(corrosionGossipPort),
corrosion_api_port: Number(corrosionApiPort),
builder_enabled: builderEnabled,
builder_capacity: Number(builderCapacity),
builder_cpu_quota: builderCpuQuota,
builder_memory_max: builderMemoryMax,
builder_timeout_secs: Number(builderTimeoutSecs),
},
}).catch(() => null);
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('');
resetAdvancedConfiguration();
setIsCreateDialogOpen(false);
setIsSubmitting(false);
}
async function createServer(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!selectedCluster) {
return;
}
setIsServerSubmitting(true);
setServerErrors({});
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers`, {
method: 'POST',
body: {
name: serverName,
host: serverHost,
ssh_user: serverSshUser,
ssh_port: Number(serverSshPort),
private_key_uuid: selectedPrivateKeyId === '' ? null : selectedPrivateKeyId,
node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress,
builder_enabled: serverBuilderEnabled,
ingress_enabled: serverIngressEnabled,
ingress_type: serverIngressEnabled ? serverIngressType : null,
builder_capacity: Number(serverBuilderCapacity),
builder_cpu_quota: serverBuilderCpuQuota,
wireguard_listen_port_override:
wireguardListenPortOverride.trim() === '' ? null : Number(wireguardListenPortOverride),
wireguard_endpoint_override: wireguardEndpointOverride.trim() === '' ? null : wireguardEndpointOverride,
},
}).catch(() => null);
if (response?.status === 422) {
const payload = (await response.json()) as {
errors?: ServerFormErrors;
};
setServerErrors(payload.errors ?? {});
setIsServerSubmitting(false);
return;
}
if (!response?.ok) {
setServerErrors({
name: ['Unable to add this server. Please try again.'],
});
setIsServerSubmitting(false);
return;
}
const payload = (await response.json()) as StoreServerResponse;
setClusterList((currentClusters) =>
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
);
resetServerForm();
setIsAddServerDialogOpen(false);
setIsServerSubmitting(false);
}
async function updateServer(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!selectedCluster || !editingServer) {
return;
}
setIsServerUpdateSubmitting(true);
setEditServerErrors({});
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, {
method: 'PATCH',
body: {
builder_enabled: editServerBuilderEnabled,
ingress_enabled: editServerIngressEnabled,
ingress_type: editServerIngressEnabled ? editServerIngressType : null,
builder_capacity: Number(editServerBuilderCapacity),
builder_cpu_quota: editServerBuilderCpuQuota,
},
}).catch(() => null);
if (response?.status === 422) {
const payload = (await response.json()) as {
errors?: ServerFormErrors;
};
setEditServerErrors(payload.errors ?? {});
setIsServerUpdateSubmitting(false);
return;
}
if (!response?.ok) {
const payload = (await response?.json().catch(() => null)) as { message?: string } | null;
setEditServerErrors({
builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'],
});
setIsServerUpdateSubmitting(false);
return;
}
const payload = (await response.json()) as UpdateServerResponse;
setClusterList((currentClusters) =>
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
);
resetEditServerForm();
setIsEditServerDialogOpen(false);
setIsServerUpdateSubmitting(false);
}
async function checkServer(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
checkingServers.start(server.id);
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, {
method: 'POST',
}).catch(() => null);
const payload = (await response?.json().catch(() => null)) as (CheckServerResponse & { message?: string }) | null;
setServerConnectionNotice({
message: response?.ok
? `Connection check for ${server.name}: ${payload?.status ?? 'unknown'}`
: `Connection check failed for ${server.name}`,
description: response?.ok
? (payload?.output ?? 'No output returned.')
: (payload?.message ?? 'Unable to check server connection.'),
variant: response?.ok ? 'success' : 'danger',
});
checkingServers.finish(server.id);
}
async function bootstrapServer(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
bootstrappingServers.start(server.id);
setBootstrapServerError(null);
openBootstrapLogs(server);
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, {
method: 'POST',
}).catch(() => null);
const payload = ((await response?.json().catch(() => null)) ?? {}) as BootstrapServerResponse;
if (payload.cluster) {
setClusterList((currentClusters) =>
currentClusters.map((cluster) => (cluster.id === payload.cluster?.id ? payload.cluster : cluster)),
);
}
if (!response?.ok) {
setBootstrapServerError(payload.message ?? 'Unable to queue bootstrap for this server.');
}
bootstrappingServers.finish(server.id);
}
function openBootstrapLogs(server: V5Server): void {
setBootstrapLogsServerId(server.id);
setIsBootstrapLogsDialogOpen(true);
}
async function loadCooldLogs(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
setCooldLogsServer(server);
setIsCooldLogsDialogOpen(true);
setIsLoadingCooldLogs(true);
setCooldLogsError(null);
setCooldLogsOutput('');
setCooldLogsFetchedAt(null);
setCooldLogsSource(null);
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, {
method: 'GET',
}).catch(() => null);
const payload = (await response?.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null;
if (!response?.ok) {
setCooldLogsError(payload?.message ?? 'Unable to load coold logs.');
setIsLoadingCooldLogs(false);
return;
}
setCooldLogsOutput(payload?.output ?? '');
setCooldLogsFetchedAt(payload?.fetchedAt ?? null);
setCooldLogsSource(payload?.source ?? null);
setIsLoadingCooldLogs(false);
}
async function loadCorrosionTables(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
setCorrosionTablesServer(server);
setIsCorrosionTablesDialogOpen(true);
setIsLoadingCorrosionTables(true);
setCorrosionTablesError(null);
setCorrosionTablesOutput('');
setCorrosionTablesFetchedAt(null);
setCorrosionTablesSource(null);
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, {
method: 'GET',
}).catch(() => null);
const payload = (await response?.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null;
if (!response?.ok) {
setCorrosionTablesError(payload?.message ?? 'Unable to load Corrosion tables.');
setIsLoadingCorrosionTables(false);
return;
}
setCorrosionTablesOutput(payload?.output ?? '');
setCorrosionTablesFetchedAt(payload?.fetchedAt ?? null);
setCorrosionTablesSource(payload?.source ?? null);
setIsLoadingCorrosionTables(false);
}
async function loadFirewallRules(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
setFirewallRulesServer(server);
setIsFirewallRulesDialogOpen(true);
setIsLoadingFirewallRules(true);
setFirewallRulesError(null);
setFirewallRules([]);
setFirewallRulesFetchedAt(null);
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, {
method: 'GET',
}).catch(() => null);
const payload = (await response?.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null;
if (!response?.ok) {
setFirewallRulesError(payload?.message ?? 'Unable to load firewall rules.');
setIsLoadingFirewallRules(false);
return;
}
setFirewallRules(Array.isArray(payload?.rules) ? payload.rules : []);
setFirewallRulesFetchedAt(payload?.fetchedAt ?? null);
setIsLoadingFirewallRules(false);
}
function openDeleteClusterDialog(): void {
if (!selectedCluster || selectedCluster.serversCount !== 0) {
setDeleteClusterError('Only empty clusters can be deleted.');
return;
}
setDeleteClusterError(null);
setClusterPendingDelete(selectedCluster);
setServerPendingDelete(null);
setIsDeleteDialogOpen(true);
}
function openDeleteServerDialog(server: V5Server): void {
if (!selectedCluster) {
return;
}
setDeleteClusterError(null);
setClusterPendingDelete(selectedCluster);
setServerPendingDelete(server);
setIsDeleteDialogOpen(true);
}
async function deleteServer(cluster: V5Cluster, server: V5Server): Promise<void> {
deletingServers.start(server.id);
setDeleteClusterError(null);
const response = await apiRequest(`/v5/clusters/${cluster.id}/servers/${server.id}`, {
method: 'DELETE',
}).catch(() => null);
if (!response?.ok) {
const payload = (await response?.json().catch(() => null)) as { message?: string } | null;
setDeleteClusterError(
payload?.message ??
(response?.status === 422
? 'Delete or move applications from this server before deleting it.'
: 'Unable to delete this server. Please try again.'),
);
deletingServers.finish(server.id);
return;
}
const payload = (await response.json()) as DeleteServerResponse;
setClusterList((currentClusters) =>
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
);
deletingServers.finish(server.id);
setIsDeleteDialogOpen(false);
setClusterPendingDelete(null);
setServerPendingDelete(null);
}
function openEditServerDialog(server: V5Server): void {
setEditingServer(server);
setEditServerBuilderEnabled(server.builderEnabled);
setEditServerIngressEnabled(server.ingressEnabled);
setEditServerIngressType(server.ingressType ?? 'caddy');
setEditServerBuilderCapacity(String(server.builderCapacity));
setEditServerBuilderCpuQuota(server.builderCpuQuota);
setEditServerErrors({});
setIsEditServerDialogOpen(true);
}
async function deleteCluster(cluster: V5Cluster): Promise<void> {
if (cluster.serversCount !== 0) {
setDeleteClusterError('Only empty clusters can be deleted.');
return;
}
setIsDeletingCluster(true);
setDeleteClusterError(null);
const response = await apiRequest(`/v5/clusters/${cluster.id}`, {
method: 'DELETE',
}).catch(() => null);
if (response?.status === 422) {
const payload = (await response.json()) as { message?: string };
setDeleteClusterError(payload.message ?? 'Only empty clusters can be deleted.');
setIsDeletingCluster(false);
return;
}
if (!response?.ok) {
setDeleteClusterError('Unable to delete this cluster. Please try again.');
setIsDeletingCluster(false);
return;
}
const nextClusters = clusterList.filter((remainingCluster) => remainingCluster.id !== cluster.id);
setClusterList(nextClusters);
setSelectedClusterId(nextClusters[0]?.id ?? '');
setIsDeletingCluster(false);
setIsDeleteDialogOpen(false);
setClusterPendingDelete(null);
setServerPendingDelete(null);
}
async function confirmDelete(): Promise<void> {
if (!clusterPendingDelete) {
return;
}
if (serverPendingDelete) {
await deleteServer(clusterPendingDelete, serverPendingDelete);
return;
}
await deleteCluster(clusterPendingDelete);
}
function resetAdvancedConfiguration(): void {
setWireguardInterface(clusterDefaults.wireguardInterface);
setWireguardManagementPool(clusterDefaults.wireguardManagementPool);
setWireguardListenPort(clusterDefaults.wireguardListenPort);
setContainerNetworkPool(clusterDefaults.containerNetworkPool);
setContainerNetworkPrefix(clusterDefaults.containerNetworkPrefix);
setNamespaces(clusterDefaults.namespaces);
setDefaultDenyContainers(clusterDefaults.defaultDenyContainers);
setCooldVersion(clusterDefaults.cooldVersion);
setCorrosionVersion(clusterDefaults.corrosionVersion);
setCorrosionGossipPort(clusterDefaults.corrosionGossipPort);
setCorrosionApiPort(clusterDefaults.corrosionApiPort);
setBuilderEnabled(clusterDefaults.builderEnabled);
setBuilderCapacity(clusterDefaults.builderCapacity);
setBuilderCpuQuota(clusterDefaults.builderCpuQuota);
setBuilderMemoryMax(clusterDefaults.builderMemoryMax);
setBuilderTimeoutSecs(clusterDefaults.builderTimeoutSecs);
setShowAdvancedConfiguration(false);
}
function resetServerForm(): void {
setServerName('');
setServerHost('');
setServerSshUser('root');
setServerSshPort('22');
setSelectedPrivateKeyId('');
setServerNodeAddress('');
setServerBuilderEnabled(selectedCluster?.builderEnabled ?? true);
setServerIngressEnabled(false);
setServerIngressType('caddy');
setServerBuilderCapacity(String(selectedCluster?.builderCapacity ?? 2));
setServerBuilderCpuQuota(selectedCluster?.builderCpuQuota ?? clusterDefaults.builderCpuQuota);
setWireguardListenPortOverride('');
setWireguardEndpointOverride('');
setServerErrors({});
setShowAdvancedServerConfiguration(false);
}
function resetEditServerForm(): void {
setEditingServer(null);
setEditServerBuilderEnabled(true);
setEditServerIngressEnabled(false);
setEditServerIngressType('caddy');
setEditServerBuilderCapacity('2');
setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota);
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 hasBootstrapLogs = server.lastBootstrapOutput !== null && server.lastBootstrapOutput.trim() !== '';
const canShowBootstrapLogs = hasBootstrapLogs || isBootstrapInProgress;
return (
<article key={server.id} className="rounded-lg border border-border bg-background p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<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>
{server.status === 'unreachable' && server.lastStatusOutput ? (
<p className="mt-2 break-words text-xs text-destructive">{server.lastStatusOutput}</p>
) : null}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 sm:flex-wrap">
<span
className={`inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium ${statusBadgeClass(server.status)}`}
title={server.lastStatusOutput ?? undefined}
>
{statusLabel(server.status)}
</span>
{!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 onClick={() => openBootstrapLogs(server)}>
View install logs
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => void loadCooldLogs(server)}>
Coold logs
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void loadCorrosionTables(server)}>
Corrosion tables
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void loadFirewallRules(server)}>
Firewall rules
</DropdownMenuItem>
<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">Caddy ingress</dt>
<dd className="mt-1 break-words font-medium text-foreground">
{server.ingressEnabled ? 'Enabled' : 'Disabled'}
</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">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>
</article>
);
}
return (
<>
<Head title="Clusters" />
<div className="min-h-dvh overflow-y-auto bg-background text-foreground lg:h-dvh lg:overflow-hidden">
<AppNavbar
flux={flux}
clusters={clusterList}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
{serverConnectionNotice ? (
<CanvasNotice
message={serverConnectionNotice.message}
description={serverConnectionNotice.description}
variant={serverConnectionNotice.variant}
onDismiss={() => setServerConnectionNotice(null)}
/>
) : null}
<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="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>
<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"
>
<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>
</div>
<section className="overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto">
{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="flex flex-wrap items-center gap-2">
{selectedCluster.serversCount === 0 ? (
<Button
type="button"
variant="delete"
size="default"
onClick={openDeleteClusterDialog}
disabled={isDeletingCluster}
>
{isDeletingCluster ? 'Deleting...' : 'Delete cluster'}
</Button>
) : null}
</div>
</div>
{deleteClusterError ? (
<p className="mt-3 text-sm text-destructive">{deleteClusterError}</p>
) : null}
</div>
<div className="flex-1 p-5">
<div className="mb-5 grid grid-cols-1 gap-3 xl:grid-cols-3">
<div className="rounded-lg border border-border bg-background p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
WireGuard mesh
</p>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Interface</dt>
<dd className="font-medium text-foreground">
{selectedCluster.wireguardInterface}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Management pool</dt>
<dd className="font-medium text-foreground">
{selectedCluster.wireguardManagementPool}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Listen port</dt>
<dd className="font-medium text-foreground">
{selectedCluster.wireguardListenPort}
</dd>
</div>
</dl>
</div>
<div className="rounded-lg border border-border bg-background p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Container networking
</p>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Pool</dt>
<dd className="font-medium text-foreground">
{selectedCluster.containerNetworkPool}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Prefix</dt>
<dd className="font-medium text-foreground">
/{selectedCluster.containerNetworkPrefix}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Namespaces</dt>
<dd className="font-medium text-foreground">
{selectedCluster.namespaces.join(', ')}
</dd>
</div>
</dl>
</div>
<div className="rounded-lg border border-border bg-background p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Cluster state
</p>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">coold</dt>
<dd className="font-medium text-foreground">
{selectedCluster.cooldVersion}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Corrosion</dt>
<dd className="font-medium text-foreground">
{selectedCluster.corrosionVersion}
</dd>
</div>
</dl>
</div>
</div>
<section className="mb-5 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>
<h3 className="text-base font-semibold text-foreground">
Firewall rules
</h3>
<p className="text-sm text-muted-foreground">
Inspect defined coold allow rules persisted on each initialized server.
</p>
</div>
</div>
{initializedServers.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-sm text-muted-foreground">
Initialize a server to inspect its firewall rules.
</p>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-3 xl:grid-cols-2">
{initializedServers.map((server) => (
<div
key={`firewall-rules-${server.id}`}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-card p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{server.name}
</p>
<p className="truncate text-xs text-muted-foreground">
Host ID: {server.wireguardManagementIp ?? server.nodeAddress ?? 'Not assigned'}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadFirewallRules(server)}
>
View rules
</Button>
</div>
))}
</div>
)}
</section>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<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>
<Button
type="button"
variant="coolify"
aria-label="Add server to cluster"
onClick={() => {
setServerBuilderEnabled(selectedCluster.builderEnabled);
setServerBuilderCapacity(String(selectedCluster.builderCapacity));
setServerBuilderCpuQuota(selectedCluster.builderCpuQuota);
setIsAddServerDialogOpen(true);
}}
>
<span className="text-base leading-none">+</span>
Add server
</Button>
</div>
{bootstrapServerError ? (
<div className="mb-3 rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm font-medium text-destructive">
{bootstrapServerError}
</p>
</div>
) : null}
{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="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>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{notInitializedServers.map(renderServerCard)}
</div>
</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>
</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={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open);
if (!open) {
setDeleteClusterError(null);
setClusterPendingDelete(null);
setServerPendingDelete(null);
}
}}
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
<DialogDescription>
{serverPendingDelete
? `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>
{deleteClusterError ? (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{deleteClusterError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="delete"
disabled={isDeletingCluster || deletingServers.hasAny}
onClick={() => void confirmDelete()}
>
{isDeletingCluster || deletingServers.hasAny ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isCreateDialogOpen}
onOpenChange={(open) => {
setIsCreateDialogOpen(open);
if (!open) {
setErrors({});
}
}}
>
<DialogContent className="max-w-3xl">
<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}>
<Field>
<FieldLabel>Name</FieldLabel>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Production Mesh"
aria-invalid={errors.name ? true : undefined}
/>
<FieldError message={errors.name?.[0]} />
</Field>
<Field>
<FieldLabel>Description</FieldLabel>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="What this cluster is used for"
aria-invalid={errors.description ? true : undefined}
/>
<FieldError message={errors.description?.[0]} />
</Field>
<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 outline-none"
onClick={() => setShowAdvancedConfiguration((value) => !value)}
>
<span>Advanced configuration</span>
<span className="text-xs text-muted-foreground">
{showAdvancedConfiguration ? 'Hide' : 'Show'}
</span>
</button>
{showAdvancedConfiguration ? (
<div className="grid grid-cols-1 gap-4 border-t border-border p-4 sm:grid-cols-2">
<Field>
<FieldLabel>WireGuard interface</FieldLabel>
<Input
value={wireguardInterface}
onChange={(event) => setWireguardInterface(event.target.value)}
aria-invalid={errors.wireguard_interface ? true : undefined}
/>
<FieldError message={errors.wireguard_interface?.[0]} />
</Field>
<Field>
<FieldLabel>WireGuard pool</FieldLabel>
<Input
value={wireguardManagementPool}
onChange={(event) =>
setWireguardManagementPool(event.target.value)
}
aria-invalid={
errors.wireguard_management_pool ? true : undefined
}
/>
<FieldError message={errors.wireguard_management_pool?.[0]} />
</Field>
<Field>
<FieldLabel>WireGuard listen port</FieldLabel>
<Input
value={wireguardListenPort}
onChange={(event) => setWireguardListenPort(event.target.value)}
inputMode="numeric"
aria-invalid={errors.wireguard_listen_port ? true : undefined}
/>
<FieldError message={errors.wireguard_listen_port?.[0]} />
</Field>
<Field>
<FieldLabel>Container pool</FieldLabel>
<Input
value={containerNetworkPool}
onChange={(event) =>
setContainerNetworkPool(event.target.value)
}
aria-invalid={errors.container_network_pool ? true : undefined}
/>
<FieldError message={errors.container_network_pool?.[0]} />
</Field>
<Field>
<FieldLabel>Container prefix</FieldLabel>
<Input
value={containerNetworkPrefix}
onChange={(event) =>
setContainerNetworkPrefix(event.target.value)
}
inputMode="numeric"
aria-invalid={
errors.container_network_prefix ? true : undefined
}
/>
<FieldError message={errors.container_network_prefix?.[0]} />
</Field>
<Field>
<FieldLabel>Namespaces</FieldLabel>
<Input
value={namespaces}
onChange={(event) => setNamespaces(event.target.value)}
placeholder="default,preview"
aria-invalid={errors.namespaces ? true : undefined}
/>
<FieldError message={errors.namespaces?.[0]} />
</Field>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={defaultDenyContainers}
onChange={(event) =>
setDefaultDenyContainers(event.target.checked)
}
/>
<FieldLabel>Default-deny containers</FieldLabel>
</Field>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={builderEnabled}
onChange={(event) => setBuilderEnabled(event.target.checked)}
/>
<FieldLabel>Enable builders</FieldLabel>
</Field>
<Field>
<FieldLabel>coold version</FieldLabel>
<Input
value={cooldVersion}
onChange={(event) => setCooldVersion(event.target.value)}
aria-invalid={errors.coold_version ? true : undefined}
/>
<FieldError message={errors.coold_version?.[0]} />
</Field>
<Field>
<FieldLabel>Corrosion version</FieldLabel>
<Input
value={corrosionVersion}
onChange={(event) => setCorrosionVersion(event.target.value)}
aria-invalid={errors.corrosion_version ? true : undefined}
/>
<FieldError message={errors.corrosion_version?.[0]} />
</Field>
<Field>
<FieldLabel>Corrosion gossip port</FieldLabel>
<Input
value={corrosionGossipPort}
onChange={(event) => setCorrosionGossipPort(event.target.value)}
inputMode="numeric"
aria-invalid={errors.corrosion_gossip_port ? true : undefined}
/>
<FieldError message={errors.corrosion_gossip_port?.[0]} />
</Field>
<Field>
<FieldLabel>Corrosion API port</FieldLabel>
<Input
value={corrosionApiPort}
onChange={(event) => setCorrosionApiPort(event.target.value)}
inputMode="numeric"
aria-invalid={errors.corrosion_api_port ? true : undefined}
/>
<FieldError message={errors.corrosion_api_port?.[0]} />
</Field>
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={builderCapacity}
onChange={(event) => setBuilderCapacity(event.target.value)}
inputMode="numeric"
aria-invalid={errors.builder_capacity ? true : undefined}
/>
<FieldError message={errors.builder_capacity?.[0]} />
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={builderCpuQuota}
onChange={(event) => setBuilderCpuQuota(event.target.value)}
aria-invalid={errors.builder_cpu_quota ? true : undefined}
/>
<FieldError message={errors.builder_cpu_quota?.[0]} />
</Field>
<Field>
<FieldLabel>Builder memory max</FieldLabel>
<Input
value={builderMemoryMax}
onChange={(event) => setBuilderMemoryMax(event.target.value)}
aria-invalid={errors.builder_memory_max ? true : undefined}
/>
<FieldError message={errors.builder_memory_max?.[0]} />
</Field>
<Field>
<FieldLabel>Builder timeout</FieldLabel>
<Input
value={builderTimeoutSecs}
onChange={(event) => setBuilderTimeoutSecs(event.target.value)}
inputMode="numeric"
aria-invalid={errors.builder_timeout_secs ? true : undefined}
/>
<FieldError message={errors.builder_timeout_secs?.[0]} />
</Field>
</div>
) : null}
</div>
<DialogFooter>
<Button type="submit" variant="coolify" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create cluster'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={isAddServerDialogOpen}
onOpenChange={(open) => {
setIsAddServerDialogOpen(open);
if (!open) {
resetServerForm();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add server</DialogTitle>
<DialogDescription>
Add a remote server to this WireGuard cluster. Generated mesh values are saved after bootstrap or extend runs.
</DialogDescription>
</DialogHeader>
<form className="mt-5 flex flex-col gap-4" onSubmit={createServer}>
<Field>
<FieldLabel>Name</FieldLabel>
<Input
value={serverName}
onChange={(event) => setServerName(event.target.value)}
placeholder="prod-01"
aria-invalid={serverErrors.name ? true : undefined}
/>
<FieldError message={serverErrors.name?.[0]} />
</Field>
<Field>
<FieldLabel>Host</FieldLabel>
<Input
value={serverHost}
onChange={(event) => setServerHost(event.target.value)}
placeholder="203.0.113.10"
aria-invalid={serverErrors.host ? true : undefined}
/>
<FieldError message={serverErrors.host?.[0]} />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Bootstrap SSH user</FieldLabel>
<Input
value={serverSshUser}
onChange={(event) => setServerSshUser(event.target.value)}
aria-invalid={serverErrors.ssh_user ? true : undefined}
/>
<FieldError message={serverErrors.ssh_user?.[0]} />
</Field>
<Field>
<FieldLabel>Bootstrap SSH port</FieldLabel>
<Input
value={serverSshPort}
onChange={(event) => setServerSshPort(event.target.value)}
inputMode="numeric"
aria-invalid={serverErrors.ssh_port ? true : undefined}
/>
<FieldError message={serverErrors.ssh_port?.[0]} />
</Field>
</div>
<Field>
<FieldLabel>Private key</FieldLabel>
<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-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50"
aria-invalid={serverErrors.private_key_uuid ? 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")`,
}}
>
<option value="">Select a private key</option>
{privateKeys.map((privateKey) => (
<option key={privateKey.id} value={privateKey.id}>
{privateKey.name}
</option>
))}
</select>
<FieldError message={serverErrors.private_key_uuid?.[0]} />
</Field>
<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 outline-none"
onClick={() => setShowAdvancedServerConfiguration((value) => !value)}
>
<span>Advanced server configuration</span>
<span className="text-xs text-muted-foreground">
{showAdvancedServerConfiguration ? 'Hide' : 'Show'}
</span>
</button>
{showAdvancedServerConfiguration ? (
<div className="grid grid-cols-1 gap-4 border-t border-border p-4 sm:grid-cols-2">
<Field>
<FieldLabel>Node address override</FieldLabel>
<Input
value={serverNodeAddress}
onChange={(event) => setServerNodeAddress(event.target.value)}
placeholder="Defaults to server IP"
aria-invalid={serverErrors.node_address ? true : undefined}
/>
<FieldError message={serverErrors.node_address?.[0]} />
</Field>
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverBuilderEnabled}
onChange={(event) =>
setServerBuilderEnabled(event.target.checked)
}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={serverBuilderCapacity}
onChange={(event) =>
setServerBuilderCapacity(event.target.value)
}
inputMode="numeric"
aria-invalid={
serverErrors.builder_capacity ? true : undefined
}
/>
<FieldError message={serverErrors.builder_capacity?.[0]} />
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={serverBuilderCpuQuota}
onChange={(event) =>
setServerBuilderCpuQuota(event.target.value)
}
placeholder="200%"
aria-invalid={
serverErrors.builder_cpu_quota ? true : undefined
}
/>
<FieldError message={serverErrors.builder_cpu_quota?.[0]} />
</Field>
</div>
</section>
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverIngressEnabled}
onChange={(event) =>
setServerIngressEnabled(event.target.checked)
}
/>
<FieldLabel>Enable ingress on this server</FieldLabel>
</Field>
<Field className="mt-4">
<FieldLabel>Ingress type</FieldLabel>
<Select
items={ingressTypes}
value={serverIngressType}
onValueChange={(value) => {
if (value !== null) {
setServerIngressType(value);
}
}}
disabled={!serverIngressEnabled}
>
<SelectTrigger
aria-label="Select ingress type"
className="h-10 w-full rounded-md px-3 text-sm"
aria-invalid={
serverErrors.ingress_type ? true : undefined
}
>
<SelectValue placeholder="Select ingress type" />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
sideOffset={4}
>
<SelectGroup>
{ingressTypes.map((ingressType) => (
<SelectItem
key={ingressType.value}
value={ingressType.value}
>
{ingressType.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError message={serverErrors.ingress_type?.[0]} />
</Field>
</section>
<Field>
<FieldLabel>WireGuard listen override</FieldLabel>
<Input
value={wireguardListenPortOverride}
onChange={(event) =>
setWireguardListenPortOverride(event.target.value)
}
inputMode="numeric"
placeholder="Optional"
aria-invalid={
serverErrors.wireguard_listen_port_override
? true
: undefined
}
/>
<FieldError
message={serverErrors.wireguard_listen_port_override?.[0]}
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel>WireGuard endpoint override</FieldLabel>
<Input
value={wireguardEndpointOverride}
onChange={(event) =>
setWireguardEndpointOverride(event.target.value)
}
placeholder="host.example.com:51821"
aria-invalid={
serverErrors.wireguard_endpoint_override ? true : undefined
}
/>
<FieldError
message={serverErrors.wireguard_endpoint_override?.[0]}
/>
</Field>
</div>
) : null}
</div>
<DialogFooter>
<Button
type="submit"
variant="coolify"
disabled={isServerSubmitting || !selectedCluster}
>
{isServerSubmitting ? 'Adding...' : 'Add server'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={isBootstrapLogsDialogOpen}
onOpenChange={(open) => {
setIsBootstrapLogsDialogOpen(open);
if (!open) {
setBootstrapLogsServerId(null);
}
}}
>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>Install logs</DialogTitle>
<DialogDescription>
Bootstrap output for {bootstrapLogsServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex flex-col gap-1 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>
Status: {bootstrapLogsServer?.lastBootstrapStatus ?? 'unknown'}
{['queued', 'running'].includes(bootstrapLogsServer?.lastBootstrapStatus ?? '')
? ' · Auto-refreshing while bootstrap runs'
: ''}
</p>
<p>{formatDate(bootstrapLogsServer?.lastBootstrapRanAt ?? null)}</p>
</div>
{parsedBootstrapLogs.summary.length > 0 ? (
<div className="rounded-lg border border-border bg-muted/20 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Action results</p>
<div className="grid gap-2 sm:grid-cols-2">
{parsedBootstrapLogs.summary.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="rounded-md border border-border bg-background p-3"
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-foreground">{item.label}</p>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
item.tone === 'success'
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-muted text-muted-foreground'
}`}
>
{item.tone === 'success' ? 'OK' : 'Info'}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.value}</p>
</div>
))}
</div>
</div>
) : null}
{parsedBootstrapLogs.visibleOutput ? (
<pre className="max-h-[70dvh] max-w-full overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
{parsedBootstrapLogs.visibleOutput}
</pre>
) : null}
{parsedBootstrapLogs.summary.length > 0 ? (
<details className="rounded-lg border border-border bg-muted/20 p-4">
<summary className="cursor-pointer text-sm font-medium text-foreground">
Raw JSON output
</summary>
<pre className="mt-3 max-h-80 max-w-full overflow-auto whitespace-pre-wrap rounded-md bg-black p-3 font-mono text-xs leading-relaxed text-white">
{parsedBootstrapLogs.rawOutput}
</pre>
</details>
) : null}
</div>
</DialogContent>
</Dialog>
<Dialog
open={isCooldLogsDialogOpen}
onOpenChange={(open) => {
setIsCooldLogsDialogOpen(open);
if (!open) {
setCooldLogsServer(null);
setCooldLogsOutput('');
setCooldLogsFetchedAt(null);
setCooldLogsSource(null);
setCooldLogsError(null);
}
}}
>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>coold logs</DialogTitle>
<DialogDescription>
Latest journalctl entries for {cooldLogsServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
{cooldLogsFetchedAt ? `Fetched ${formatDate(cooldLogsFetchedAt)}` : 'Last 200 lines'}
</p>
{cooldLogsSource ? (
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground">
Source: {diagnosticsSourceLabel(cooldLogsSource)}
</span>
) : null}
</div>
{cooldLogsServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingCooldLogs}
onClick={() => void loadCooldLogs(cooldLogsServer)}
>
{isLoadingCooldLogs ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{cooldLogsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{cooldLogsError}
</div>
) : null}
<pre className="max-h-[32rem] max-w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap wrap-anywhere rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
{isLoadingCooldLogs ? 'Loading coold logs...' : cooldLogsOutput || 'No coold logs returned.'}
</pre>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isCorrosionTablesDialogOpen}
onOpenChange={(open) => {
setIsCorrosionTablesDialogOpen(open);
if (!open) {
setCorrosionTablesServer(null);
setCorrosionTablesOutput('');
setCorrosionTablesFetchedAt(null);
setCorrosionTablesSource(null);
setCorrosionTablesError(null);
}
}}
>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>Corrosion tables</DialogTitle>
<DialogDescription>
Corrosion table snapshots for {corrosionTablesServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
{corrosionTablesFetchedAt
? `Fetched ${formatDate(corrosionTablesFetchedAt)}`
: 'First 200 rows per table'}
</p>
{corrosionTablesSource ? (
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground">
Source: {diagnosticsSourceLabel(corrosionTablesSource)}
</span>
) : null}
</div>
{corrosionTablesServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingCorrosionTables}
onClick={() => void loadCorrosionTables(corrosionTablesServer)}
>
{isLoadingCorrosionTables ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{corrosionTablesError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{corrosionTablesError}
</div>
) : null}
<div className="max-h-[32rem] overflow-auto rounded-lg border border-border bg-card p-4">
{isLoadingCorrosionTables ? (
<p className="text-sm text-muted-foreground">Loading Corrosion tables...</p>
) : (() => {
const dump = parseCorrosionTables(corrosionTablesOutput);
if (!dump || !dump.tables?.length) {
return (
<pre className="font-mono text-xs leading-relaxed text-muted-foreground">
{corrosionTablesOutput || 'No Corrosion tables returned.'}
</pre>
);
}
return (
<div className="flex flex-col gap-6">
{dump.tables.map((table) => (
<section key={table.name} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">{table.name}</h3>
<span className="text-xs text-muted-foreground">
{table.rows.length} row{table.rows.length === 1 ? '' : 's'}
</span>
</div>
<div className="overflow-auto rounded-md border border-border">
<table className="min-w-full divide-y divide-border text-left text-xs">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
{table.columns.map((column) => (
<th key={column} className="whitespace-nowrap px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{table.rows.length > 0 ? (
table.rows.map((row, rowIndex) => (
<tr key={`${table.name}-${rowIndex}`}>
{table.columns.map((column, columnIndex) => (
<td
key={`${table.name}-${rowIndex}-${column}`}
className="max-w-80 truncate px-3 py-2 font-mono text-muted-foreground"
title={formatCorrosionCell(row[columnIndex])}
>
{formatCorrosionCell(row[columnIndex])}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={Math.max(table.columns.length, 1)}
className="px-3 py-4 text-center text-muted-foreground"
>
No rows
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
))}
</div>
);
})()}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isFirewallRulesDialogOpen}
onOpenChange={(open) => {
setIsFirewallRulesDialogOpen(open);
if (!open) {
setFirewallRulesServer(null);
setFirewallRules([]);
setFirewallRulesFetchedAt(null);
setFirewallRulesError(null);
}
}}
>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Firewall rules</DialogTitle>
<DialogDescription>
Defined coold allow rules for {firewallRulesServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{firewallRulesFetchedAt
? `Fetched ${formatDate(firewallRulesFetchedAt)}`
: 'Rules currently persisted by coold'}
</p>
{firewallRulesServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingFirewallRules}
onClick={() => void loadFirewallRules(firewallRulesServer)}
>
{isLoadingFirewallRules ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{firewallRulesError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{firewallRulesError}
</div>
) : null}
<div className="max-h-[32rem] overflow-auto rounded-lg border border-border">
<table className="min-w-full divide-y divide-border text-left text-xs">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="whitespace-nowrap px-3 py-2 font-medium">Rule ID</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Namespace</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Source</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Destination</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Protocol</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">Port</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoadingFirewallRules ? (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
Loading firewall rules...
</td>
</tr>
) : firewallRules.length > 0 ? (
firewallRules.map((rule, index) => (
<tr key={rule.id ?? `firewall-rule-${index}`}>
<td className="max-w-80 truncate px-3 py-2 font-mono text-muted-foreground" title={rule.id}>
{rule.id ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.namespace ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 font-mono text-muted-foreground">
{rule.src ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 font-mono text-muted-foreground">
{rule.dst ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.proto ?? '—'}
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{rule.port ?? '—'}
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
No firewall rules defined.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isEditServerDialogOpen}
onOpenChange={(open) => {
setIsEditServerDialogOpen(open);
if (!open) {
resetEditServerForm();
}
}}
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Edit server</DialogTitle>
<DialogDescription>
Update builder scheduling limits and ingress for {editingServer?.name ?? 'this server'}.
Networking and bootstrap settings stay locked after creation.
</DialogDescription>
</DialogHeader>
<form className="mt-5 flex flex-col gap-4" onSubmit={updateServer}>
<section className="rounded-lg border border-border bg-muted/20 p-4">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerBuilderEnabled}
onChange={(event) => setEditServerBuilderEnabled(event.target.checked)}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={editServerBuilderCapacity}
onChange={(event) => setEditServerBuilderCapacity(event.target.value)}
inputMode="numeric"
aria-invalid={editServerErrors.builder_capacity ? true : undefined}
/>
<FieldError message={editServerErrors.builder_capacity?.[0]} />
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={editServerBuilderCpuQuota}
onChange={(event) => setEditServerBuilderCpuQuota(event.target.value)}
placeholder="200%"
aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined}
/>
<FieldError message={editServerErrors.builder_cpu_quota?.[0]} />
</Field>
</div>
</section>
<section className="rounded-lg border border-border bg-muted/20 p-4">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerIngressEnabled}
onChange={(event) => setEditServerIngressEnabled(event.target.checked)}
/>
<FieldLabel>Enable ingress on this server</FieldLabel>
</Field>
<Field className="mt-4">
<FieldLabel>Ingress type</FieldLabel>
<Select
items={ingressTypes}
value={editServerIngressType}
onValueChange={(value) => {
if (value !== null) {
setEditServerIngressType(value);
}
}}
disabled={!editServerIngressEnabled}
>
<SelectTrigger
aria-label="Select ingress type"
className="h-10 w-full rounded-md px-3 text-sm"
aria-invalid={editServerErrors.ingress_type ? true : undefined}
>
<SelectValue placeholder="Select ingress type" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{ingressTypes.map((ingressType) => (
<SelectItem
key={ingressType.value}
value={ingressType.value}
>
{ingressType.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError message={editServerErrors.ingress_type?.[0]} />
</Field>
</section>
<p className="text-xs text-muted-foreground">
Host, bootstrap credentials, node address, and WireGuard overrides are not
editable here.
</p>
<DialogFooter>
<Button
type="submit"
variant="coolify"
disabled={isServerUpdateSubmitting || !selectedCluster || !editingServer}
>
{isServerUpdateSubmitting ? 'Saving...' : 'Save server'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</main>
</div>
</>
);
}