import { useEffect, useMemo, useState } from "react" import { annotationsApi } from "@/api/annotations" import { labelsApi } from "@/api/labels" import { canAttachToPool, nodePoolsApi } from "@/api/node_pools" import { serversApi } from "@/api/servers" import { taintsApi } from "@/api/taints" import type { DtoNodePoolResponse } from "@/sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Ellipsis, LinkIcon, Pencil, Plus, Search, ServerIcon, Trash2 } from "lucide-react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" // --------------------------------------------- // Helpers & shared UI // --------------------------------------------- const ROLE_OPTIONS = ["master", "worker"] as const type Role = (typeof ROLE_OPTIONS)[number] function StatusBadge({ status }: { status?: string }) { const v = status === "ready" ? "default" : status === "provisioning" ? "secondary" : status === "failed" ? "destructive" : "outline" return ( {status || "unknown"} ) } // --------------------------------------------- // Reusable manage-many dialog // --------------------------------------------- type AssocItem = { id: string // common searchable/display fields name?: string key?: string value?: string effect?: string role?: string status?: string hostname?: string private_ip_address?: string public_ip_address?: string } function fuzzyIncludes(hay: string | undefined, q: string) { return (hay ?? "").toLowerCase().includes(q) } function ManageManyDialog(props: { open: boolean title: string onOpenChange: (v: boolean) => void items: AssocItem[] initialSelectedIds: Set onSave: (diff: { toAttach: string[]; toDetach: string[] }) => Promise | void columns: { header: string; render: (item: AssocItem) => React.ReactNode }[] allowItem?: (item: AssocItem) => boolean }) { const { open, title, onOpenChange, items, initialSelectedIds, onSave, columns, allowItem } = props const [q, setQ] = useState("") const [selected, setSelected] = useState>(new Set(initialSelectedIds)) const [saving, setSaving] = useState(false) useEffect(() => { setSelected(new Set(initialSelectedIds)) setQ("") }, [initialSelectedIds, open]) const filtered = useMemo(() => { const qq = q.trim().toLowerCase() return items.filter((it) => { if (allowItem && !allowItem(it)) return false if (!qq) return true return ( fuzzyIncludes(it.name, qq) || fuzzyIncludes(it.key, qq) || fuzzyIncludes(it.value, qq) || fuzzyIncludes(it.effect, qq) || fuzzyIncludes(it.hostname, qq) || fuzzyIncludes(it.private_ip_address, qq) || fuzzyIncludes(it.public_ip_address, qq) || fuzzyIncludes(it.role, qq) || fuzzyIncludes(it.status, qq) ) }) }, [items, q, allowItem]) const initial = initialSelectedIds const changed = Array.from(selected).some((id) => !initial.has(id)) || Array.from(initial).some((id) => !selected.has(id)) return ( {title}
setQ(e.target.value)} placeholder="Search…" className="pl-8" />
{columns.map((c, i) => ( {c.header} ))} {filtered.map((it) => { const id = it.id const checked = selected.has(id) return ( { const next = new Set(selected) if (e.target.checked) next.add(id) else next.delete(id) setSelected(next) }} /> {columns.map((c, i) => ( {c.render(it)} ))} ) })} {filtered.length === 0 && ( No items found. )}
Selected: {selected.size}
) } // --------------------------------------------- // Page // --------------------------------------------- const createNodePoolSchema = z.object({ name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), role: z.enum(ROLE_OPTIONS), }) type CreateNodePoolValues = z.infer const updateNodePoolSchema = createNodePoolSchema.partial() type UpdateNodePoolValues = z.infer export function NodePoolsPage() { const [filter, setFilter] = useState("") const [createOpen, setCreateOpen] = useState(false) const [updateOpen, setUpdateOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) // Manage Servers dialog state const [manageOpen, setManageOpen] = useState(false) const [managePool, setManagePool] = useState(null) const [selected, setSelected] = useState>(new Set()) const [initialSelected, setInitialSelected] = useState>(new Set()) const [serverFilter, setServerFilter] = useState("") // Manage Labels / Annotations / Taints dialog state const [manageLabelsOpen, setManageLabelsOpen] = useState(false) const [manageAnnotationsOpen, setManageAnnotationsOpen] = useState(false) const [manageTaintsOpen, setManageTaintsOpen] = useState(false) const [manageLATPool, setManageLATPool] = useState(null) const [labelsInitial, setLabelsInitial] = useState>(new Set()) const [annotationsInitial, setAnnotationsInitial] = useState>(new Set()) const [taintsInitial, setTaintsInitial] = useState>(new Set()) const qc = useQueryClient() // Queries const nodePoolQ = useQuery({ queryKey: ["node-pools"], queryFn: () => nodePoolsApi.listNodePools(), }) const serverQ = useQuery({ queryKey: ["servers"], queryFn: () => serversApi.listServers(), }) const annotationQ = useQuery({ queryKey: ["annotations"], queryFn: () => annotationsApi.listAnnotations(), }) const labelQ = useQuery({ queryKey: ["labels"], queryFn: () => labelsApi.listLabels(), }) const taintQ = useQuery({ queryKey: ["taints"], queryFn: () => taintsApi.listTaints(), }) // --- Create const createForm = useForm({ resolver: zodResolver(createNodePoolSchema), defaultValues: { name: "", role: "worker" }, }) const createMut = useMutation({ mutationFn: (values: CreateNodePoolValues) => nodePoolsApi.createNodePool(values), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["node-pools"] }) createForm.reset({ name: "", role: "worker" }) setCreateOpen(false) toast.success("Node pool created.") }, onError: (err: any) => toast.error(err?.message ?? "Unable to create node pool."), }) const onCreateSubmit = (values: CreateNodePoolValues) => createMut.mutate(values) // --- Update const updateForm = useForm({ resolver: zodResolver(updateNodePoolSchema), defaultValues: { name: undefined, role: undefined }, }) useEffect(() => { if (editing) { updateForm.reset({ name: editing.name, role: editing.role as Role }) } else { updateForm.reset({ name: undefined, role: undefined }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editing]) const updateMut = useMutation({ mutationFn: async (values: UpdateNodePoolValues) => { if (!editing) return const body: UpdateNodePoolValues = {} if (values.name !== editing.name) body.name = values.name if ((values.role as string) !== editing.role) body.role = values.role return await nodePoolsApi.updateNodePool(editing.id as unknown as string, body) }, onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["node-pools"] }) setUpdateOpen(false) setEditing(null) toast.success("Node pool updated.") }, onError: (err: any) => toast.error(err?.message ?? "Unable to update node pool."), }) const onUpdateSubmit = (values: UpdateNodePoolValues) => updateMut.mutate(values) // --- Delete const deleteMut = useMutation({ mutationFn: async () => { if (!deleting) return await nodePoolsApi.deleteNodePool(deleting.id as unknown as string) }, onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["node-pools"] }) setDeleteOpen(false) setDeleting(null) toast.success("Node pool deleted.") }, onError: (err: any) => toast.error(err?.message ?? "Unable to delete node pool."), }) // --- Filter table const filtered = useMemo(() => { const data = (nodePoolQ.data ?? []) as DtoNodePoolResponse[] const q = filter.trim().toLowerCase() return q ? data.filter( (p) => p.name?.toLowerCase().includes(q) || (p.role as string)?.toLowerCase().includes(q) ) : data }, [filter, nodePoolQ.data]) if (nodePoolQ.isLoading) return
Loading node pools…
if (nodePoolQ.error) return (
Error loading node pools.
          {JSON.stringify(nodePoolQ.error, null, 2)}
        
) return (

Node Pools

setFilter(e.target.value)} placeholder="Search node pools" className="w-64 pl-8" />
Create Node Pool
( Name )} /> ( Role )} />
Name Role Servers Annotations Labels Taints Actions {filtered.map((p) => { const serverCount = Array.isArray(p.servers) ? p.servers.length : 0 return ( {p.name} {p.role} {/* Servers cell */}
{(p.servers || []).slice(0, 6).map((s) => ( {s.hostname || s.private_ip_address} {s.role} {s.status && ( )} ))} {serverCount === 0 && ( No servers )} {serverCount > 6 && ( +{serverCount - 6} more )}
{/* Annotations */}
{(p.annotations || []).slice(0, 6).map((a: any) => ( {a.key}:{a.value} ))} {(p.annotations || []).length === 0 && ( No annotations )} {(p.annotations || []).length > 6 && ( +{(p.annotations || []).length - 6} more )}
{/* Labels */}
{(p.labels || []).slice(0, 6).map((l: any) => ( {l.key}:{l.value} ))} {(p.labels || []).length === 0 && ( No labels )} {(p.labels || []).length > 6 && ( +{(p.labels || []).length - 6} more )}
{/* Taints */}
{(p.taints || []).slice(0, 6).map((t: any) => ( {t.key}:{t.value} {t.effect ? ({t.effect}) : null} ))} {(p.taints || []).length === 0 && ( No taints )} {(p.taints || []).length > 6 && ( +{(p.taints || []).length - 6} more )}
{ setEditing(p) setUpdateOpen(true) }} > Edit { setDeleting(p) setDeleteOpen(true) }} > Delete
) })} {filtered.length === 0 && ( No node pools found. )}
{/* Edit dialog */} Edit Node Pool
( Name )} /> ( Role )} />
{/* Delete confirm */} Delete node pool

This will permanently delete{" "} {deleting?.name}.

{/* Manage Servers dialog */} Manage Servers{managePool ? ` — ${managePool.name}` : ""}
setServerFilter(e.target.value)} placeholder="Search by hostname, IP or role…" className="pl-8" />
Hostname Private IP Public IP Role Status {(serverQ.data ?? []) .filter((s: any) => { if (managePool?.role && !canAttachToPool(managePool.role as string, s.role)) { return false } const q = serverFilter.trim().toLowerCase() if (!q) return true return ( (s.hostname ?? "").toLowerCase().includes(q) || (s.private_ip_address ?? "").toLowerCase().includes(q) || (s.public_ip_address ?? "").toLowerCase().includes(q) || (s.role ?? "").toLowerCase().includes(q) ) }) .map((s: any) => { const id = s.id as string const checked = selected.has(id) return ( { const next = new Set(selected) if (e.target.checked) next.add(id) else next.delete(id) setSelected(next) }} /> {s.hostname || "—"} {s.private_ip_address || "—"} {s.public_ip_address || "—"} {s.role || "—"} ) })} {(serverQ.data ?? []).length === 0 && ( {serverQ.isLoading ? "Loading servers…" : "No servers found."} )}
Selected: {selected.size}
{/* Manage Labels */} { setManageLabelsOpen(o) if (!o) setManageLATPool(null) }} title={`Manage Labels${manageLATPool ? ` — ${manageLATPool.name}` : ""}`} items={(labelQ.data ?? []).map((l: any) => ({ id: l.id as string, key: l.key, value: l.value, name: `${l.key}:${l.value}`, }))} initialSelectedIds={labelsInitial} columns={[ { header: "Key", render: (it) => {it.key} }, { header: "Value", render: (it) => it.value ?? "—" }, ]} onSave={async ({ toAttach, toDetach }) => { if (!manageLATPool) return const poolId = manageLATPool.id as unknown as string try { if (toAttach.length > 0) { await nodePoolsApi.attachNodePoolLabels(poolId, { label_ids: toAttach }) } for (const id of toDetach) { await nodePoolsApi.detachNodePoolLabels(poolId, id) } await qc.invalidateQueries({ queryKey: ["node-pools"] }) toast.success("Labels updated for node pool.") } catch (err: any) { toast.error(err?.message ?? "Failed to update labels.") throw err } }} /> {/* Manage Annotations */} { setManageAnnotationsOpen(o) if (!o) setManageLATPool(null) }} title={`Manage Annotations${manageLATPool ? ` — ${manageLATPool.name}` : ""}`} items={(annotationQ.data ?? []).map((a: any) => ({ id: a.id as string, key: a.key, value: a.value, name: `${a.key}:${a.value}`, }))} initialSelectedIds={annotationsInitial} columns={[ { header: "Key", render: (it) => {it.key} }, { header: "Value", render: (it) => it.value ?? "—" }, ]} onSave={async ({ toAttach, toDetach }) => { if (!manageLATPool) return const poolId = manageLATPool.id as unknown as string try { if (toAttach.length > 0) { await nodePoolsApi.attachNodePoolAnnotations(poolId, { annotation_ids: toAttach }) } for (const id of toDetach) { await nodePoolsApi.detachNodePoolAnnotations(poolId, id) } await qc.invalidateQueries({ queryKey: ["node-pools"] }) toast.success("Annotations updated for node pool.") } catch (err: any) { toast.error(err?.message ?? "Failed to update annotations.") throw err } }} /> {/* Manage Taints */} { setManageTaintsOpen(o) if (!o) setManageLATPool(null) }} title={`Manage Taints${manageLATPool ? ` — ${manageLATPool.name}` : ""}`} items={(taintQ.data ?? []).map((t: any) => ({ id: t.id as string, key: t.key, value: t.value, effect: t.effect, name: `${t.key}:${t.value}`, }))} initialSelectedIds={taintsInitial} columns={[ { header: "Key", render: (it) => {it.key} }, { header: "Value", render: (it) => it.value ?? "—" }, { header: "Effect", render: (it) => it.effect ?? "—" }, ]} onSave={async ({ toAttach, toDetach }) => { if (!manageLATPool) return const poolId = manageLATPool.id as unknown as string try { if (toAttach.length > 0) { await nodePoolsApi.attachNodePoolTaints(poolId, { taint_ids: toAttach }) } for (const id of toDetach) { await nodePoolsApi.detachNodePoolTaints(poolId, id) } await qc.invalidateQueries({ queryKey: ["node-pools"] }) toast.success("Taints updated for node pool.") } catch (err: any) { toast.error(err?.message ?? "Failed to update taints.") throw err } }} />
) }