annotations added to nodepools page

This commit is contained in:
allanice001
2025-09-04 03:09:17 +01:00
parent d831e911fd
commit d83efa4068
9 changed files with 1737 additions and 585 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -82,6 +82,16 @@ type TaintWithPools = TaintBrief & {
node_groups?: { id: string; name: string }[]
}
type AnnotationBrief = {
id: string
name: string
value: string
}
type AnnotationWithPools = AnnotationBrief & {
node_pools?: { id: string; name: string }[]
}
type NodePool = {
id: string
name: string
@@ -90,7 +100,7 @@ type NodePool = {
const CreatePoolSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
server_ids: z.array(z.uuid()).optional().default([]),
server_ids: z.array(z.string().uuid()).optional().default([]),
})
type CreatePoolInput = z.input<typeof CreatePoolSchema>
type CreatePoolValues = z.output<typeof CreatePoolSchema>
@@ -101,20 +111,25 @@ const UpdatePoolSchema = z.object({
type UpdatePoolValues = z.output<typeof UpdatePoolSchema>
const AttachServersSchema = z.object({
server_ids: z.array(z.uuid()).min(1, "Pick at least one server"),
server_ids: z.array(z.string().uuid()).min(1, "Pick at least one server"),
})
type AttachServersValues = z.output<typeof AttachServersSchema>
const AttachLabelsSchema = z.object({
label_ids: z.array(z.uuid()).min(1, "Pick at least one label"),
label_ids: z.array(z.string().uuid()).min(1, "Pick at least one label"),
})
type AttachLabelsValues = z.output<typeof AttachLabelsSchema>
const AttachTaintsSchema = z.object({
taint_ids: z.array(z.uuid()).min(1, "Pick at least one taint"),
taint_ids: z.array(z.string().uuid()).min(1, "Pick at least one taint"),
})
type AttachTaintsValues = z.output<typeof AttachTaintsSchema>
const AttachAnnotationsSchema = z.object({
annotation_ids: z.array(z.string().uuid()).min(1, "Pick at least one annotation"),
})
type AttachAnnotationsValues = z.output<typeof AttachAnnotationsSchema>
/* --------------------------------- Utils --------------------------------- */
function StatusBadge({ status }: { status?: string }) {
@@ -150,11 +165,14 @@ function labelKV(l: LabelBrief) {
}
function taintText(t: TaintBrief) {
// Kubernetes-ish: key[=value]:effect
const kv = t.value ? `${t.key}=${t.value}` : t.key
return `${kv}:${t.effect}`
}
function annotationKV(a: AnnotationBrief) {
return `${a.name}=${a.value}`
}
/* --------------------------------- Page ---------------------------------- */
export const NodePoolPage = () => {
@@ -162,11 +180,10 @@ export const NodePoolPage = () => {
const [pools, setPools] = useState<NodePool[]>([])
const [allServers, setAllServers] = useState<ServerBrief[]>([])
// Labels
// Labels / Taints / Annotations
const [allLabels, setAllLabels] = useState<LabelWithPools[]>([])
// Taints
const [allTaints, setAllTaints] = useState<TaintWithPools[]>([])
const [allAnnotations, setAllAnnotations] = useState<AnnotationWithPools[]>([])
const [err, setErr] = useState<string | null>(null)
const [q, setQ] = useState("")
@@ -189,22 +206,30 @@ export const NodePoolPage = () => {
const [taintsLoading, setTaintsLoading] = useState(false)
const [taintsErr, setTaintsErr] = useState<string | null>(null)
// Annotations dialog state
const [manageAnnotationsTarget, setManageAnnotationsTarget] = useState<NodePool | null>(null)
const [attachedAnnotations, setAttachedAnnotations] = useState<AnnotationBrief[]>([])
const [annotationsLoading, setAnnotationsLoading] = useState(false)
const [annotationsErr, setAnnotationsErr] = useState<string | null>(null)
/* ------------------------------- Data Load ------------------------------ */
async function loadAll() {
setLoading(true)
setErr(null)
try {
const [poolsData, serversData, labelsData, taintsData] = await Promise.all([
const [poolsData, serversData, labelsData, taintsData, annotationsData] = await Promise.all([
api.get<NodePool[]>("/api/v1/node-pools?include=servers"),
api.get<ServerBrief[]>("/api/v1/servers"),
api.get<LabelWithPools[]>("/api/v1/labels?include=node_pools"),
api.get<TaintWithPools[]>("/api/v1/taints?include=node_pools"),
api.get<AnnotationWithPools[]>("/api/v1/annotations?include=node_pools"),
])
setPools(poolsData || [])
setAllServers(serversData || [])
setAllLabels(labelsData || [])
setAllTaints(taintsData || [])
setAllAnnotations(annotationsData || [])
if (manageTarget) {
const refreshed = (poolsData || []).find((p) => p.id === manageTarget.id) || null
@@ -220,10 +245,15 @@ export const NodePoolPage = () => {
if (manageTaintsTarget) {
await loadAttachedTaints(manageTaintsTarget.id)
}
if (manageAnnotationsTarget) {
await loadAttachedAnnotations(manageAnnotationsTarget.id)
}
} catch (e) {
console.error(e)
const msg =
e instanceof ApiError ? e.message : "Failed to load node pools / servers / labels / taints"
e instanceof ApiError
? e.message
: "Failed to load node pools / servers / labels / taints / annotations"
setErr(msg)
} finally {
setLoading(false)
@@ -260,14 +290,28 @@ export const NodePoolPage = () => {
}
}
async function loadAttachedAnnotations(poolId: string) {
setAnnotationsLoading(true)
setAnnotationsErr(null)
try {
const data = await api.get<AnnotationBrief[]>(`/api/v1/node-pools/${poolId}/annotations`)
setAttachedAnnotations(data || [])
} catch (e) {
console.error(e)
const msg = e instanceof ApiError ? e.message : "Failed to load annotations for pool"
setAnnotationsErr(msg)
} finally {
setAnnotationsLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* ---------------------------- Labels/Taints per Pool --------------------------- */
/* --------------------- Labels/Taints/Annotations per Pool --------------------- */
// poolId -> LabelBrief[]
const labelsByPool = useMemo(() => {
const map = new Map<string, LabelBrief[]>()
for (const l of allLabels) {
@@ -280,7 +324,6 @@ export const NodePoolPage = () => {
return map
}, [allLabels])
// poolId -> TaintBrief[]
const taintsByPool = useMemo(() => {
const map = new Map<string, TaintBrief[]>()
for (const t of allTaints) {
@@ -293,6 +336,18 @@ export const NodePoolPage = () => {
return map
}, [allTaints])
const annotationsByPool = useMemo(() => {
const map = new Map<string, AnnotationBrief[]>()
for (const a of allAnnotations) {
for (const ng of a.node_pools || []) {
const arr = map.get(ng.id) || []
arr.push({ id: a.id, name: a.name, value: a.value })
map.set(ng.id, arr)
}
}
return map
}, [allAnnotations])
/* -------------------------------- Filters ------------------------------- */
const filtered = useMemo(() => {
@@ -318,9 +373,21 @@ export const NodePoolPage = () => {
kv.includes(needle)
)
})
return p.name.toLowerCase().includes(needle) || serversMatch || labelsMatch || taintsMatch
const annotationsMatch = (annotationsByPool.get(p.id) || []).some(
(a) =>
a.name.toLowerCase().includes(needle) ||
(a.value || "").toLowerCase().includes(needle) ||
`${a.name}=${a.value}`.toLowerCase().includes(needle)
)
return (
p.name.toLowerCase().includes(needle) ||
serversMatch ||
labelsMatch ||
taintsMatch ||
annotationsMatch
)
})
}, [pools, q, labelsByPool, taintsByPool])
}, [pools, q, labelsByPool, taintsByPool, annotationsByPool])
/* ------------------------------ Mutations ------------------------------- */
@@ -411,7 +478,7 @@ export const NodePoolPage = () => {
})
attachLabelsForm.reset({ label_ids: [] })
await loadAttachedLabels(manageLabelsTarget.id)
await loadAll() // refresh badges in table
await loadAll()
}
async function detachLabel(labelId: string) {
@@ -419,7 +486,7 @@ export const NodePoolPage = () => {
if (!confirm("Detach this label from the pool?")) return
await api.delete(`/api/v1/node-pools/${manageLabelsTarget.id}/labels/${labelId}`)
await loadAttachedLabels(manageLabelsTarget.id)
await loadAll() // refresh badges in table
await loadAll()
}
// Attach / Detach Taints
@@ -441,7 +508,7 @@ export const NodePoolPage = () => {
})
attachTaintsForm.reset({ taint_ids: [] })
await loadAttachedTaints(manageTaintsTarget.id)
await loadAll() // refresh taint badges in table
await loadAll()
}
async function detachTaint(taintId: string) {
@@ -452,6 +519,36 @@ export const NodePoolPage = () => {
await loadAll()
}
// Attach / Detach Annotations
const attachAnnotationsForm = useForm<AttachAnnotationsValues>({
resolver: zodResolver(AttachAnnotationsSchema),
defaultValues: { annotation_ids: [] },
})
function openManageAnnotations(p: NodePool) {
setManageAnnotationsTarget(p)
attachAnnotationsForm.reset({ annotation_ids: [] })
void loadAttachedAnnotations(p.id)
}
const submitAttachAnnotations = async (values: AttachAnnotationsValues) => {
if (!manageAnnotationsTarget) return
await api.post(`/api/v1/node-pools/${manageAnnotationsTarget.id}/annotations`, {
annotation_ids: values.annotation_ids,
})
attachAnnotationsForm.reset({ annotation_ids: [] })
await loadAttachedAnnotations(manageAnnotationsTarget.id)
await loadAll() // refresh badges in table
}
async function detachAnnotation(annotationId: string) {
if (!manageAnnotationsTarget) return
if (!confirm("Detach this annotation from the pool?")) return
await api.delete(`/api/v1/node-pools/${manageAnnotationsTarget.id}/annotations/${annotationId}`)
await loadAttachedAnnotations(manageAnnotationsTarget.id)
await loadAll() // refresh badges in table
}
/* --------------------------------- Render -------------------------------- */
if (loading) return <div className="p-6">Loading node pools</div>
@@ -468,7 +565,7 @@ export const NodePoolPage = () => {
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search pools, servers, labels, taints…"
placeholder="Search pools, servers, labels, taints, annotations…"
className="w-72 pl-8"
/>
</div>
@@ -579,6 +676,7 @@ export const NodePoolPage = () => {
{filtered.map((p) => {
const labels = labelsByPool.get(p.id) || []
const taints = taintsByPool.get(p.id) || []
const annotations = annotationsByPool.get(p.id) || []
return (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
@@ -612,15 +710,30 @@ export const NodePoolPage = () => {
</Button>
</TableCell>
{/* Annotations placeholder */}
{/* Annotations cell */}
<TableCell>
<div className="flex flex-wrap gap-2">Annotations</div>
<Button variant="outline" size="sm" disabled>
<div className="mb-2 flex flex-wrap gap-2">
{annotations.slice(0, 6).map((a) => (
<Badge key={a.id} variant="outline" className="font-mono">
<Tag className="mr-1 h-3 w-3" />
{annotationKV(a)}
</Badge>
))}
{annotations.length === 0 && (
<span className="text-muted-foreground">No annotations</span>
)}
{annotations.length > 6 && (
<span className="text-muted-foreground">
+{annotations.length - 6} more
</span>
)}
</div>
<Button variant="outline" size="sm" onClick={() => openManageAnnotations(p)}>
<LinkIcon className="mr-2 h-4 w-4" /> Manage Annotations
</Button>
</TableCell>
{/* Labels cell with badges */}
{/* Labels cell */}
<TableCell>
<div className="mb-2 flex flex-wrap gap-2">
{labels.slice(0, 6).map((l) => (
@@ -808,7 +921,6 @@ export const NodePoolPage = () => {
<FormItem>
<FormLabel>Attach more servers</FormLabel>
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
{/* options */}
{(() => {
const attachedIds = new Set(
(manageTarget?.servers || []).map((s) => s.id)
@@ -1122,6 +1234,140 @@ export const NodePoolPage = () => {
</div>
</DialogContent>
</Dialog>
{/* Manage annotations dialog */}
<Dialog
open={!!manageAnnotationsTarget}
onOpenChange={(o) => !o && setManageAnnotationsTarget(null)}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Manage annotations for{" "}
<span className="font-mono">{manageAnnotationsTarget?.name}</span>
</DialogTitle>
</DialogHeader>
{/* Attached annotations list */}
<div className="space-y-3">
<div className="text-sm font-medium">Attached annotations</div>
{annotationsLoading ? (
<div className="text-muted-foreground rounded-md border p-3 text-sm">Loading</div>
) : annotationsErr ? (
<div className="rounded-md border p-3 text-sm text-red-500">{annotationsErr}</div>
) : (
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
<TableHead className="w-[120px] text-right">Detach</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attachedAnnotations.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-sm">{a.name}</TableCell>
<TableCell className="font-mono text-sm">{a.value}</TableCell>
<TableCell>
<div className="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => detachAnnotation(a.id)}
>
<UnlinkIcon className="mr-2 h-4 w-4" /> Detach
</Button>
</div>
</TableCell>
</TableRow>
))}
{attachedAnnotations.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground py-8 text-center">
No annotations attached yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
{/* Attach annotations */}
<div className="pt-4">
<Form {...attachAnnotationsForm}>
<form
onSubmit={attachAnnotationsForm.handleSubmit(submitAttachAnnotations)}
className="space-y-3"
>
<FormField
control={attachAnnotationsForm.control}
name="annotation_ids"
render={({ field }) => (
<FormItem>
<FormLabel>Attach more annotations</FormLabel>
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
{(() => {
const attachedIds = new Set(attachedAnnotations.map((a) => a.id))
const attachable = (
allAnnotations as unknown as AnnotationBrief[]
).filter((a) => !attachedIds.has(a.id))
if (attachable.length === 0) {
return (
<div className="text-muted-foreground p-2 text-sm">
No more annotations available to attach
</div>
)
}
return attachable.map((a) => {
const checked = field.value?.includes(a.id) || false
return (
<label
key={a.id}
className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = new Set(field.value || [])
if (v === true) next.add(a.id)
else next.delete(a.id)
field.onChange(Array.from(next))
}}
/>
<div className="leading-tight">
<div className="text-sm font-medium">{annotationKV(a)}</div>
<div className="text-muted-foreground text-xs">
{truncateMiddle(a.id, 8)}
</div>
</div>
</label>
)
})
})()}
</div>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="submit" disabled={attachAnnotationsForm.formState.isSubmitting}>
<LinkIcon className="mr-2 h-4 w-4" />{" "}
{attachAnnotationsForm.formState.isSubmitting
? "Attaching…"
: "Attach selected"}
</Button>
</DialogFooter>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
</div>
)
}