// src/pages/ClustersPage.tsx import { useEffect, useMemo, useState } from "react" import { clustersApi } from "@/api/clusters" import { dnsApi } from "@/api/dns" import { loadBalancersApi } from "@/api/loadbalancers" import { nodePoolsApi } from "@/api/node_pools" import { serversApi } from "@/api/servers" import type { DtoClusterResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse, } from "@/sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench, } from "lucide-react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { truncateMiddle } from "@/lib/utils" import { Badge } from "@/components/ui/badge.tsx" import { Button } from "@/components/ui/button.tsx" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog.tsx" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form.tsx" import { Input } from "@/components/ui/input.tsx" import { Label } from "@/components/ui/label.tsx" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx" import { Textarea } from "@/components/ui/textarea.tsx" // --- Schemas --- const createClusterSchema = z.object({ name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), cluster_provider: z.string().trim().min(1, "Provider is required").max(120, "Max 120 chars"), region: z.string().trim().min(1, "Region is required").max(120, "Max 120 chars"), docker_image: z.string().trim().min(1, "Docker Image is required"), docker_tag: z.string().trim().min(1, "Docker Tag is required"), }) type CreateClusterInput = z.input const updateClusterSchema = createClusterSchema.partial() type UpdateClusterValues = z.infer // --- UI helpers --- function StatusBadge({ status }: { status?: string | null }) { const value = (status ?? "").toLowerCase() if (!value) { return ( unknown ) } if (value === "ready") { return ( ready ) } if (value === "failed") { return ( failed ) } if (value === "provisioning" || value === "pending" || value === "pre_pending") { return ( {value.replace("_", " ")} ) } if (value === "incomplete") { return ( incomplete ) } return ( {value} ) } function ClusterSummary({ c }: { c: DtoClusterResponse }) { return (
{c.cluster_provider && ( {c.cluster_provider} )} {c.region && ( {c.region} )}
{c.random_token && ( token: {truncateMiddle(c.random_token, 8)} )} {c.certificate_key && ( cert: {truncateMiddle(c.certificate_key, 8)} )}
) } export const ClustersPage = () => { const [filter, setFilter] = useState("") const [createOpen, setCreateOpen] = useState(false) const [updateOpen, setUpdateOpen] = useState(false) const [deleteId, setDeleteId] = useState(null) const [editingId, setEditingId] = useState(null) // Config dialog state const [configCluster, setConfigCluster] = useState(null) const [captainDomainId, setCaptainDomainId] = useState("") const [recordSetId, setRecordSetId] = useState("") const [appsLbId, setAppsLbId] = useState("") const [glueopsLbId, setGlueopsLbId] = useState("") const [bastionId, setBastionId] = useState("") const [nodePoolId, setNodePoolId] = useState("") const [kubeconfigText, setKubeconfigText] = useState("") const [busyKey, setBusyKey] = useState(null) const isBusy = (k: string) => busyKey === k const qc = useQueryClient() // --- Queries --- const clustersQ = useQuery({ queryKey: ["clusters"], queryFn: () => clustersApi.listClusters(), }) const lbsQ = useQuery({ queryKey: ["load-balancers"], queryFn: () => loadBalancersApi.listLoadBalancers(), }) const domainsQ = useQuery({ queryKey: ["domains"], queryFn: () => dnsApi.listDomains(), }) // record sets fetched per captain domain const recordSetsQ = useQuery({ queryKey: ["record-sets", captainDomainId], enabled: !!captainDomainId, queryFn: () => dnsApi.listRecordSetsByDomain(captainDomainId), }) const serversQ = useQuery({ queryKey: ["servers"], queryFn: () => serversApi.listServers(), }) const npQ = useQuery({ queryKey: ["node-pools"], queryFn: () => nodePoolsApi.listNodePools(), }) // --- Create --- const createForm = useForm({ resolver: zodResolver(createClusterSchema), defaultValues: { name: "", cluster_provider: "", region: "", docker_image: "", docker_tag: "", }, }) const createMut = useMutation({ mutationFn: (values: CreateClusterInput) => clustersApi.createCluster(values), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["clusters"] }) createForm.reset() setCreateOpen(false) toast.success("Cluster created successfully.") }, onError: (err: any) => { toast.error(err?.message ?? "There was an error while creating the cluster") }, }) const onCreateSubmit = (values: CreateClusterInput) => { createMut.mutate(values) } // --- Update basic details --- const updateForm = useForm({ resolver: zodResolver(updateClusterSchema), defaultValues: {}, }) const updateMut = useMutation({ mutationFn: ({ id, values }: { id: string; values: UpdateClusterValues }) => clustersApi.updateCluster(id, values), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["clusters"] }) updateForm.reset() setUpdateOpen(false) toast.success("Cluster updated successfully.") }, onError: (err: any) => { toast.error(err?.message ?? "There was an error while updating the cluster") }, }) const openEdit = (cluster: DtoClusterResponse) => { if (!cluster.id) return setEditingId(cluster.id) updateForm.reset({ name: cluster.name ?? "", cluster_provider: cluster.cluster_provider ?? "", region: cluster.region ?? "", docker_image: cluster.docker_image ?? "", docker_tag: cluster.docker_tag ?? "", }) setUpdateOpen(true) } // --- Delete --- const deleteMut = useMutation({ mutationFn: (id: string) => clustersApi.deleteCluster(id), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["clusters"] }) setDeleteId(null) toast.success("Cluster deleted successfully.") }, onError: (err: any) => { toast.error(err?.message ?? "There was an error while deleting the cluster") }, }) // --- Filter --- const filtered = useMemo(() => { const data: DtoClusterResponse[] = clustersQ.data ?? [] const q = filter.trim().toLowerCase() return q ? data.filter((c) => { return ( c.name?.toLowerCase().includes(q) || c.cluster_provider?.toLowerCase().includes(q) || c.region?.toLowerCase().includes(q) || c.status?.toLowerCase().includes(q) ) }) : data }, [filter, clustersQ.data]) // --- Config dialog helpers --- useEffect(() => { if (!configCluster) { setCaptainDomainId("") setRecordSetId("") setAppsLbId("") setGlueopsLbId("") setBastionId("") setNodePoolId("") setKubeconfigText("") return } // Prefill IDs from current attachments if (configCluster.captain_domain?.id) { setCaptainDomainId(configCluster.captain_domain.id) } if (configCluster.control_plane_record_set?.id) { setRecordSetId(configCluster.control_plane_record_set.id) } if (configCluster.apps_load_balancer?.id) { setAppsLbId(configCluster.apps_load_balancer.id) } if (configCluster.glueops_load_balancer?.id) { setGlueopsLbId(configCluster.glueops_load_balancer.id) } if (configCluster.bastion_server?.id) { setBastionId(configCluster.bastion_server.id) } }, [configCluster]) async function refreshConfigCluster() { if (!configCluster?.id) return try { const updated = await clustersApi.getCluster(configCluster.id) setConfigCluster(updated) await qc.invalidateQueries({ queryKey: ["clusters"] }) } catch { // ignore } } async function handleAttachCaptain() { if (!configCluster?.id) return if (!captainDomainId) { toast.error("Domain is required") return } setBusyKey("captain") try { await clustersApi.attachCaptainDomain(configCluster.id, { domain_id: captainDomainId, }) toast.success("Captain domain attached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach captain domain.") } finally { setBusyKey(null) } } async function handleDetachCaptain() { if (!configCluster?.id) return setBusyKey("captain") try { await clustersApi.detachCaptainDomain(configCluster.id) toast.success("Captain domain detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach captain domain.") } finally { setBusyKey(null) } } async function handleAttachRecordSet() { if (!configCluster?.id) return if (!recordSetId) { toast.error("Record set is required") return } setBusyKey("recordset") try { await clustersApi.attachControlPlaneRecordSet(configCluster.id, { record_set_id: recordSetId, }) toast.success("Control plane record set attached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach record set.") } finally { setBusyKey(null) } } async function handleDetachRecordSet() { if (!configCluster?.id) return setBusyKey("recordset") try { await clustersApi.detachControlPlaneRecordSet(configCluster.id) toast.success("Control plane record set detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach record set.") } finally { setBusyKey(null) } } async function handleAttachAppsLb() { if (!configCluster?.id) return if (!appsLbId) { toast.error("Load balancer is required") return } setBusyKey("apps-lb") try { await clustersApi.attachAppsLoadBalancer(configCluster.id, { load_balancer_id: appsLbId, }) toast.success("Apps load balancer attached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach apps load balancer.") } finally { setBusyKey(null) } } async function handleDetachAppsLb() { if (!configCluster?.id) return setBusyKey("apps-lb") try { await clustersApi.detachAppsLoadBalancer(configCluster.id) toast.success("Apps load balancer detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach apps load balancer.") } finally { setBusyKey(null) } } async function handleAttachGlueopsLb() { if (!configCluster?.id) return if (!glueopsLbId) { toast.error("Load balancer is required") return } setBusyKey("glueops-lb") try { await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, { load_balancer_id: glueopsLbId, }) toast.success("GlueOps load balancer attached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach GlueOps load balancer.") } finally { setBusyKey(null) } } async function handleDetachGlueopsLb() { if (!configCluster?.id) return setBusyKey("glueops-lb") try { await clustersApi.detachGlueOpsLoadBalancer(configCluster.id) toast.success("GlueOps load balancer detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach GlueOps load balancer.") } finally { setBusyKey(null) } } async function handleAttachBastion() { if (!configCluster?.id) return if (!bastionId) { toast.error("Server is required") return } setBusyKey("bastion") try { await clustersApi.attachBastion(configCluster.id, { server_id: bastionId, }) toast.success("Bastion server attached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach bastion server.") } finally { setBusyKey(null) } } async function handleDetachBastion() { if (!configCluster?.id) return setBusyKey("bastion") try { await clustersApi.detachBastion(configCluster.id) toast.success("Bastion server detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach bastion server.") } finally { setBusyKey(null) } } async function handleAttachNodePool() { if (!configCluster?.id) return if (!nodePoolId) { toast.error("Node pool is required") return } setBusyKey("nodepool") try { await clustersApi.attachNodePool(configCluster.id, nodePoolId) toast.success("Node pool attached.") setNodePoolId("") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to attach node pool.") } finally { setBusyKey(null) } } async function handleDetachNodePool(npId: string) { if (!configCluster?.id) return setBusyKey("nodepool") try { await clustersApi.detachNodePool(configCluster.id, npId) toast.success("Node pool detached.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to detach node pool.") } finally { setBusyKey(null) } } async function handleSetKubeconfig() { if (!configCluster?.id) return if (!kubeconfigText.trim()) { toast.error("Kubeconfig is required") return } setBusyKey("kubeconfig") try { await clustersApi.setKubeconfig(configCluster.id, { kubeconfig: kubeconfigText, }) toast.success("Kubeconfig updated.") setKubeconfigText("") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to set kubeconfig.") } finally { setBusyKey(null) } } async function handleClearKubeconfig() { if (!configCluster?.id) return setBusyKey("kubeconfig") try { await clustersApi.clearKubeconfig(configCluster.id) toast.success("Kubeconfig cleared.") await refreshConfigCluster() } catch (err: any) { toast.error(err?.message ?? "Failed to clear kubeconfig.") } finally { setBusyKey(null) } } if (clustersQ.isLoading) return
Loading clusters…
if (clustersQ.error) return
Error loading clusters.
const allLbs: DtoLoadBalancerResponse[] = lbsQ.data ?? [] const appsLbs = allLbs.filter((lb) => lb.kind === "public") const glueopsLbs = allLbs.filter((lb) => lb.kind === "glueops") return (

Clusters

setFilter(e.target.value)} placeholder="Search clusters" className="w-64 pl-8" />
Create Cluster
( Name )} /> ( Provider )} /> ( Region )} /> ( Docker Image )} /> ( Docker Tag )} />
Name Provider Region Status Docker Summary Actions {filtered.map((c: DtoClusterResponse) => ( {c.name} {c.cluster_provider} {c.region} {c.last_error && (
{truncateMiddle(c.last_error, 80)}
)}
{c.docker_image + ":" + c.docker_tag} {c.id && ( {truncateMiddle(c.id, 6)} )}
))} {filtered.length === 0 && ( No clusters match your search. )}
{/* Update dialog */} Edit Cluster
{ if (!editingId) return updateMut.mutate({ id: editingId, values }) })} > ( Name )} /> ( Provider )} /> ( Region )} /> ( Region )} /> ( Region )} />
{/* Configure dialog (attachments + kubeconfig + node pools) */} !open && setConfigCluster(null)}> Configure Cluster{configCluster?.name ? `: ${configCluster.name}` : ""} {configCluster && (
{/* Kubeconfig */}

Kubeconfig

Paste the kubeconfig for this cluster. It will be stored encrypted and never returned by the API.