import { useEffect, useMemo, useState } from "react"; import { actionsApi } from "@/api/actions"; 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 { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, 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"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; // --- 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 // --- Data normalization helpers (fixes rows.some is not a function) --- function asArray(res: any): T[] { if (Array.isArray(res)) return res as T[] if (Array.isArray(res?.data)) return res.data as T[] if (Array.isArray(res?.body)) return res.body as T[] if (Array.isArray(res?.result)) return res.result as T[] return [] } function asObject(res: any): T { // for get endpoints that might return {data: {...}} if (res?.data && typeof res.data === "object") return res.data as T return res as T } // --- 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 RunStatusBadge({ status }: { status?: string | null }) { const s = (status ?? "").toLowerCase() if (!s) return ( unknown ) if (s === "succeeded" || s === "success") { return ( succeeded ) } if (s === "failed" || s === "error") { return ( failed ) } if (s === "queued" || s === "running") { return ( {s} ) } return ( {s} ) } function fmtTime(v: any): string { if (!v) return "-" try { const d = v instanceof Date ? v : new Date(v) if (Number.isNaN(d.getTime())) return "-" return d.toLocaleString() } catch { return "-" } } 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) // Configure 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: async () => asArray(await clustersApi.listClusters()), }) const lbsQ = useQuery({ queryKey: ["load-balancers"], queryFn: async () => asArray(await loadBalancersApi.listLoadBalancers()), }) const domainsQ = useQuery({ queryKey: ["domains"], queryFn: async () => asArray(await dnsApi.listDomains()), }) const recordSetsQ = useQuery({ queryKey: ["record-sets", captainDomainId], enabled: !!captainDomainId, queryFn: async () => asArray(await dnsApi.listRecordSetsByDomain(captainDomainId)), }) const serversQ = useQuery({ queryKey: ["servers"], queryFn: async () => asArray(await serversApi.listServers()), }) const npQ = useQuery({ queryKey: ["node-pools"], queryFn: async () => asArray(await nodePoolsApi.listNodePools()), }) const actionsQ = useQuery({ queryKey: ["actions"], queryFn: async () => asArray(await actionsApi.listActions()), }) const runsQ = useQuery({ queryKey: ["cluster-runs", configCluster?.id], enabled: !!configCluster?.id, queryFn: async () => asArray(await clustersApi.listClusterRuns(configCluster!.id!)), refetchInterval: (data) => { // IMPORTANT: data might not be array if queryFn isn't normalizing. But it is here anyway. const rows = Array.isArray(data) ? data : [] const active = rows.some((r: any) => { const s = String(r?.status ?? "").toLowerCase() return s === "queued" || s === "running" }) return active ? 2000 : false }, }) const actionLabelByTarget = useMemo(() => { const m = new Map() ;(actionsQ.data ?? []).forEach((a) => { if (a.make_target) m.set(a.make_target, a.label ?? a.make_target) }) return m }, [actionsQ.data]) const runDisplayName = (r: DtoClusterRunResponse) => actionLabelByTarget.get(r.action ?? "") ?? r.action ?? "unknown" // --- 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"), }) // --- 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"), }) // --- Run Action --- const runActionMut = useMutation({ mutationFn: ({ clusterID, actionID }: { clusterID: string; actionID: string }) => clustersApi.runClusterAction(clusterID, actionID), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster?.id] }) toast.success("Action enqueued.") }, onError: (err: any) => toast.error(err?.message ?? "Failed to enqueue action."), }) async function handleRunAction(actionID: string) { if (!configCluster?.id) return setBusyKey(`run:${actionID}`) try { await runActionMut.mutateAsync({ clusterID: configCluster.id, actionID }) } finally { setBusyKey(null) } } // --- 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 } 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 updatedRaw = await clustersApi.getCluster(configCluster.id) const updated = asObject(updatedRaw) setConfigCluster(updated) await qc.invalidateQueries({ queryKey: ["clusters"] }) await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] }) } catch { // ignore } } async function handleAttachCaptain() { if (!configCluster?.id) return if (!captainDomainId) return toast.error("Domain is required") 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) return toast.error("Record set is required") 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) return toast.error("Load balancer is required") 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) return toast.error("Load balancer is required") 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) return toast.error("Server is required") 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) return toast.error("Node pool is required") 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()) return toast.error("Kubeconfig is required") 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
createMut.mutate(v))} > ( 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 )} /> ( Docker Image )} /> ( Docker Tag )} />
{/* Configure dialog (attachments + kubeconfig + node pools + actions/runs) */} !open && setConfigCluster(null)}> Configure Cluster{configCluster?.name ? `: ${configCluster.name}` : ""} {configCluster && (
{/* Cluster Actions */}

Cluster Actions

Run admin-configured actions on this cluster. Actions are executed asynchronously.

{actionsQ.isLoading ? (

Loading actions…

) : (actionsQ.data ?? []).length === 0 ? (

No actions configured yet. Create actions in Admin → Actions.

) : (
{(actionsQ.data ?? []).map((a: DtoActionResponse) => (
{a.label} {a.make_target && ( {a.make_target} )}
{a.description && (

{a.description}

)}
))}
)}
{runsQ.isLoading ? (

Loading runs…

) : (runsQ.data ?? []).length === 0 ? (

No runs yet for this cluster.

) : (
Action Status Created Finished Error {(runsQ.data ?? []).slice(0, 20).map((r) => (
{runDisplayName(r)} {r.id && ( {truncateMiddle(r.id, 8)} )}
{fmtTime((r as any).created_at)} {fmtTime((r as any).finished_at)} {r.error ? truncateMiddle(r.error, 80) : "-"}
))}
)}
{/* Kubeconfig */}

Kubeconfig

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