From d83efa4068fb4476dba0a9bbf079b90f85781f0a Mon Sep 17 00:00:00 2001 From: allanice001 Date: Thu, 4 Sep 2025 03:09:17 +0100 Subject: [PATCH] annotations added to nodepools page --- docs/docs.go | 261 +++++ docs/swagger.json | 261 +++++ docs/swagger.yaml | 169 ++++ internal/api/routes.go | 5 + internal/handlers/nodepools/dto.go | 10 + internal/handlers/nodepools/funcs.go | 16 + internal/handlers/nodepools/nodepools.go | 181 ++++ ui/src/pages/core/annotations-page.tsx | 1125 +++++++++++----------- ui/src/pages/core/nodepool-page.tsx | 294 +++++- 9 files changed, 1737 insertions(+), 585 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index fce082e..dad3aff 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2417,6 +2417,242 @@ const docTemplate = `{ } } }, + "/api/v1/node-pools/{id}/annotations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "List annotations attached to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/nodepools.annotationBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Attach annotations to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Annotation IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/nodepools.attachAnnotationsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid annotation_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/node-pools/{id}/annotations/{annotationId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Detach one annotation from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "annotationId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools/{id}/labels": { "get": { "security": [ @@ -5271,6 +5507,31 @@ const docTemplate = `{ } } }, + "nodepools.annotationBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "nodepools.attachAnnotationsRequest": { + "type": "object", + "properties": { + "annotation_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "nodepools.attachLabelsRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 4609747..7910ecb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2413,6 +2413,242 @@ } } }, + "/api/v1/node-pools/{id}/annotations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "List annotations attached to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/nodepools.annotationBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Attach annotations to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Annotation IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/nodepools.attachAnnotationsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid annotation_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/node-pools/{id}/annotations/{annotationId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Detach one annotation from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "annotationId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools/{id}/labels": { "get": { "security": [ @@ -5267,6 +5503,31 @@ } } }, + "nodepools.annotationBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "nodepools.attachAnnotationsRequest": { + "type": "object", + "properties": { + "annotation_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "nodepools.attachLabelsRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f52bf77..5bc372a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -319,6 +319,22 @@ definitions: updated_at: type: string type: object + nodepools.annotationBrief: + properties: + id: + type: string + name: + type: string + value: + type: string + type: object + nodepools.attachAnnotationsRequest: + properties: + annotation_ids: + items: + type: string + type: array + type: object nodepools.attachLabelsRequest: properties: label_ids: @@ -2163,6 +2179,159 @@ paths: summary: Update node pool (org scoped) tags: - node-pools + /api/v1/node-pools/{id}/annotations: + get: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/nodepools.annotationBrief' + type: array + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: List annotations attached to a node pool (org scoped) + tags: + - node-pools + post: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + - description: Annotation IDs to attach + in: body + name: body + required: true + schema: + $ref: '#/definitions/nodepools.attachAnnotationsRequest' + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id / invalid annotation_ids + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: attach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Attach annotations to a node pool (org scoped) + tags: + - node-pools + /api/v1/node-pools/{id}/annotations/{annotationId}: + delete: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + - description: Annotation ID (UUID) + in: path + name: annotationId + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: detach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Detach one annotation from a node pool (org scoped) + tags: + - node-pools /api/v1/node-pools/{id}/labels: get: consumes: diff --git a/internal/api/routes.go b/internal/api/routes.go index 63df585..1dd573b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -118,6 +118,11 @@ func RegisterRoutes(r chi.Router) { np.Get("/{id}/labels", nodepools.ListNodePoolLabels) np.Post("/{id}/labels", nodepools.AttachNodePoolLabels) np.Delete("/{id}/labels/{labelId}", nodepools.DetachNodePoolLabel) + + // annotations + np.Get("/{id}/annotations", nodepools.ListNodePoolAnnotations) + np.Post("/{id}/annotations", nodepools.AttachNodePoolAnnotations) + np.Delete("/{id}/annotations/{annotationId}", nodepools.DetachNodePoolAnnotation) }) v1.Route("/taints", func(t chi.Router) { diff --git a/internal/handlers/nodepools/dto.go b/internal/handlers/nodepools/dto.go index e834455..8192b51 100644 --- a/internal/handlers/nodepools/dto.go +++ b/internal/handlers/nodepools/dto.go @@ -51,3 +51,13 @@ type taintBrief struct { Value string `json:"value"` Effect string `json:"effect"` } + +type annotationBrief struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Value string `json:"value"` +} + +type attachAnnotationsRequest struct { + AnnotationIDs []string `json:"annotation_ids"` +} diff --git a/internal/handlers/nodepools/funcs.go b/internal/handlers/nodepools/funcs.go index b48168b..36d5212 100644 --- a/internal/handlers/nodepools/funcs.go +++ b/internal/handlers/nodepools/funcs.go @@ -78,3 +78,19 @@ func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { } return nil } + +func ensureAnnotationsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return errors.New("empty ids") + } + var count int64 + if err := db.DB.Model(&models.Annotation{}). + Where("organization_id = ? AND id IN ?", orgID, ids). + Count(&count).Error; err != nil { + return err + } + if count != int64(len(ids)) { + return errors.New("some annotations not in org") + } + return nil +} diff --git a/internal/handlers/nodepools/nodepools.go b/internal/handlers/nodepools/nodepools.go index 39236e9..31399e6 100644 --- a/internal/handlers/nodepools/nodepools.go +++ b/internal/handlers/nodepools/nodepools.go @@ -806,3 +806,184 @@ func DetachNodePoolLabel(w http.ResponseWriter, r *http.Request) { } response.NoContent(w) } + +// ListNodePoolAnnotations godoc +// @Summary List annotations attached to a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Security BearerAuth +// @Success 200 {array} annotationBrief +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "fetch failed" +// @Router /api/v1/node-pools/{id}/annotations [get] +func ListNodePoolAnnotations(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + poolID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID). + Preload("Annotations"). + First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + out := make([]annotationBrief, 0, len(ng.Annotations)) + for _, a := range ng.Annotations { + out = append(out, annotationBrief{ + ID: a.ID, + Name: a.Name, + Value: a.Value, + }) + } + _ = response.JSON(w, http.StatusOK, out) +} + +// AttachNodePoolAnnotations godoc +// @Summary Attach annotations to a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Param body body attachAnnotationsRequest true "Annotation IDs to attach" +// @Security BearerAuth +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id / invalid annotation_ids" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "attach failed" +// @Router /api/v1/node-pools/{id}/annotations [post] +func AttachNodePoolAnnotations(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + poolID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID).First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var in attachAnnotationsRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.AnnotationIDs) == 0 { + http.Error(w, "invalid annotation_ids", http.StatusBadRequest) + return + } + + ids, err := parseUUIDs(in.AnnotationIDs) + if err != nil { + http.Error(w, "invalid annotation_ids", http.StatusBadRequest) + return + } + if err := ensureAnnotationsBelongToOrg(ac.OrganizationID, ids); err != nil { + http.Error(w, "invalid annotation_ids for this organization", http.StatusBadRequest) + return + } + + var annotations []models.Annotation + if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID). + Find(&annotations).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&ng).Association("Annotations").Append(&annotations); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// DetachNodePoolAnnotation godoc +// @Summary Detach one annotation from a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Param annotationId path string true "Annotation ID (UUID)" +// @Security BearerAuth +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "detach failed" +// @Router /api/v1/node-pools/{id}/annotations/{annotationId} [delete] +func DetachNodePoolAnnotation(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + poolID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + annID, err := uuid.Parse(chi.URLParam(r, "annotationId")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID).First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var ann models.Annotation + if err := db.DB.Where("id = ? AND organization_id = ?", annID, ac.OrganizationID). + First(&ann).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + if err := db.DB.Model(&ng).Association("Annotations").Delete(&ann); err != nil { + http.Error(w, "detach failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} diff --git a/ui/src/pages/core/annotations-page.tsx b/ui/src/pages/core/annotations-page.tsx index 3151068..65d95e3 100644 --- a/ui/src/pages/core/annotations-page.tsx +++ b/ui/src/pages/core/annotations-page.tsx @@ -1,625 +1,628 @@ import { useEffect, useMemo, useState } from "react" -import { z } from "zod" -import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { - LinkIcon, - Pencil, - Plus, - RefreshCw, - Search, - Trash, - UnlinkIcon, - ServerIcon, + LinkIcon, + Pencil, + Plus, + RefreshCw, + Search, + ServerIcon, + Trash, + UnlinkIcon, } from "lucide-react" +import { useForm } from "react-hook-form" +import { z } from "zod" import { api, ApiError } from "@/lib/api" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@/components/ui/dialog" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@/components/ui/table" /* ----------------------------- Types & Schemas ---------------------------- */ type NodePoolBrief = { - id: string - name: string + id: string + name: string } type Annotation = { - id: string - name: string - value: string - node_pools?: NodePoolBrief[] + id: string + name: string + value: string + node_pools?: NodePoolBrief[] } const CreateSchema = z.object({ - name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), - value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), - node_pool_ids: z.array(z.string().uuid()).optional().default([]), + name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), + value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), + node_pool_ids: z.array(z.string().uuid()).optional().default([]), }) type CreateInput = z.input type CreateValues = z.output const UpdateSchema = z.object({ - name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), - value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), + name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), + value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), }) type UpdateValues = z.output const AttachPoolsSchema = z.object({ - node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"), + node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"), }) type AttachPoolsValues = z.output /* --------------------------------- Utils --------------------------------- */ function truncateMiddle(str: string, keep = 12) { - if (!str || str.length <= keep * 2 + 3) return str - return `${str.slice(0, keep)}…${str.slice(-keep)}` + if (!str || str.length <= keep * 2 + 3) return str + return `${str.slice(0, keep)}…${str.slice(-keep)}` } /* --------------------------------- Page ---------------------------------- */ export const AnnotationsPage = () => { - const [loading, setLoading] = useState(true) - const [err, setErr] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) - const [annotations, setAnnotations] = useState([]) - const [allPools, setAllPools] = useState([]) + const [annotations, setAnnotations] = useState([]) + const [allPools, setAllPools] = useState([]) - const [q, setQ] = useState("") + const [q, setQ] = useState("") - // Dialog state - const [createOpen, setCreateOpen] = useState(false) - const [editTarget, setEditTarget] = useState(null) - const [managePoolsTarget, setManagePoolsTarget] = useState(null) + // Dialog state + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [managePoolsTarget, setManagePoolsTarget] = useState(null) - // Attached pools (for manage dialog) - const [attachedPools, setAttachedPools] = useState([]) - const [attachedLoading, setAttachedLoading] = useState(false) - const [attachedErr, setAttachedErr] = useState(null) + // Attached pools (for manage dialog) + const [attachedPools, setAttachedPools] = useState([]) + const [attachedLoading, setAttachedLoading] = useState(false) + const [attachedErr, setAttachedErr] = useState(null) - /* ------------------------------- Data Load ------------------------------ */ + /* ------------------------------- Data Load ------------------------------ */ - async function loadAll() { - setLoading(true) - setErr(null) - try { - const [ann, pools] = await Promise.all([ - api.get("/api/v1/annotations?include=node_pools"), - api.get("/api/v1/node-pools"), - ]) - setAnnotations(ann || []) - setAllPools(pools || []) + async function loadAll() { + setLoading(true) + setErr(null) + try { + const [ann, pools] = await Promise.all([ + api.get("/api/v1/annotations?include=node_pools"), + api.get("/api/v1/node-pools"), + ]) + setAnnotations(ann || []) + setAllPools(pools || []) - // keep dialog targets in sync - if (editTarget) { - const refreshed = (ann || []).find((a) => a.id === editTarget.id) || null - setEditTarget(refreshed) - } - if (managePoolsTarget) { - const refreshed = (ann || []).find((a) => a.id === managePoolsTarget.id) || null - setManagePoolsTarget(refreshed) - if (refreshed) { - void loadAttachedPools(refreshed.id) - } - } - } catch (e) { - console.error(e) - const msg = - e instanceof ApiError ? e.message : "Failed to load annotations / node pools" - setErr(msg) - } finally { - setLoading(false) + // keep dialog targets in sync + if (editTarget) { + const refreshed = (ann || []).find((a) => a.id === editTarget.id) || null + setEditTarget(refreshed) + } + if (managePoolsTarget) { + const refreshed = (ann || []).find((a) => a.id === managePoolsTarget.id) || null + setManagePoolsTarget(refreshed) + if (refreshed) { + void loadAttachedPools(refreshed.id) } + } + } catch (e) { + console.error(e) + const msg = e instanceof ApiError ? e.message : "Failed to load annotations / node pools" + setErr(msg) + } finally { + setLoading(false) } + } - async function loadAttachedPools(annotationId: string) { - setAttachedLoading(true) - setAttachedErr(null) - try { - const data = await api.get( - `/api/v1/annotations/${annotationId}/node_pools` - ) - setAttachedPools(data || []) - } catch (e) { - console.error(e) - const msg = - e instanceof ApiError ? e.message : "Failed to load pools for annotation" - setAttachedErr(msg) - } finally { - setAttachedLoading(false) - } + async function loadAttachedPools(annotationId: string) { + setAttachedLoading(true) + setAttachedErr(null) + try { + const data = await api.get(`/api/v1/annotations/${annotationId}/node_pools`) + setAttachedPools(data || []) + } catch (e) { + console.error(e) + const msg = e instanceof ApiError ? e.message : "Failed to load pools for annotation" + setAttachedErr(msg) + } finally { + setAttachedLoading(false) } + } - useEffect(() => { - void loadAll() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + useEffect(() => { + void loadAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) - /* -------------------------------- Filters ------------------------------- */ + /* -------------------------------- Filters ------------------------------- */ - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase() - if (!needle) return annotations - return annotations.filter( - (a) => - a.name.toLowerCase().includes(needle) || - a.value.toLowerCase().includes(needle) || - (a.node_pools || []).some((p) => p.name.toLowerCase().includes(needle)) - ) - }, [annotations, q]) - - /* ------------------------------ Mutations ------------------------------- */ - - async function deleteAnnotation(id: string) { - if (!confirm("Delete this annotation? This cannot be undone.")) return - await api.delete(`/api/v1/annotations/${id}`) - await loadAll() - } - - // Create - const createForm = useForm({ - resolver: zodResolver(CreateSchema), - defaultValues: { name: "", value: "", node_pool_ids: [] }, - }) - - const submitCreate = async (values: CreateValues) => { - const payload: any = { name: values.name.trim(), value: values.value.trim() } - if (values.node_pool_ids && values.node_pool_ids.length > 0) { - payload.node_pool_ids = values.node_pool_ids - } - await api.post("/api/v1/annotations", payload) - setCreateOpen(false) - createForm.reset({ name: "", value: "", node_pool_ids: [] }) - await loadAll() - } - - // Edit - const editForm = useForm({ - resolver: zodResolver(UpdateSchema), - defaultValues: { name: "", value: "" }, - }) - - function openEdit(a: Annotation) { - setEditTarget(a) - editForm.reset({ name: a.name, value: a.value }) - } - - const submitEdit = async (values: UpdateValues) => { - if (!editTarget) return - await api.patch(`/api/v1/annotations/${editTarget.id}`, { - name: values.name.trim(), - value: values.value.trim(), - }) - setEditTarget(null) - await loadAll() - } - - // Manage pools (attach/detach) - const attachPoolsForm = useForm({ - resolver: zodResolver(AttachPoolsSchema), - defaultValues: { node_pool_ids: [] }, - }) - - function openManagePools(a: Annotation) { - setManagePoolsTarget(a) - attachPoolsForm.reset({ node_pool_ids: [] }) - void loadAttachedPools(a.id) - } - - const submitAttachPools = async (values: AttachPoolsValues) => { - if (!managePoolsTarget) return - await api.post(`/api/v1/annotations/${managePoolsTarget.id}/node_pools`, { - node_pool_ids: values.node_pool_ids, - }) - attachPoolsForm.reset({ node_pool_ids: [] }) - await loadAttachedPools(managePoolsTarget.id) - await loadAll() - } - - async function detachPool(poolId: string) { - if (!managePoolsTarget) return - if (!confirm("Detach this node pool from the annotation?")) return - await api.delete(`/api/v1/annotations/${managePoolsTarget.id}/node_pools/${poolId}`) - await loadAttachedPools(managePoolsTarget.id) - await loadAll() - } - - /* --------------------------------- Render -------------------------------- */ - - if (loading) return
Loading annotations…
- if (err) return
{err}
- - return ( -
-
-

Annotations

- -
-
- - setQ(e.target.value)} - placeholder="Search name, value, pool…" - className="w-72 pl-8" - /> -
- - - - - - - - - - Create annotation - - -
- - ( - - Name - - - - - - )} - /> - ( - - Value - - - - - - )} - /> - ( - - Initial node pools (optional) -
- {allPools.length === 0 && ( -
No node pools available
- )} - {allPools.map((p) => { - const checked = field.value?.includes(p.id) || false - return ( - - ) - })} -
- -
- )} - /> - - - - - - - -
-
-
-
- -
-
- - - - Name - Value - Node Pools - Actions - - - - {filtered.map((a) => { - const pools = a.node_pools || [] - return ( - - {a.name} - {a.value} - - -
- {pools.slice(0, 6).map((p) => ( - - {p.name} - - ))} - {pools.length === 0 && ( - No node pools - )} - {pools.length > 6 && ( - +{pools.length - 6} more - )} -
- -
- - -
- - - - - - - deleteAnnotation(a.id)}> - Confirm delete - - - -
-
-
- ) - })} - - {filtered.length === 0 && ( - - - No annotations match your search. - - - )} -
-
-
-
- - {/* Edit dialog */} - !o && setEditTarget(null)}> - - - Edit annotation - - -
- - ( - - Name - - - - - - )} - /> - ( - - Value - - - - - - )} - /> - - - - - - - -
-
- - {/* Manage node pools dialog */} - !o && setManagePoolsTarget(null)}> - - - - Manage node pools for{" "} - {managePoolsTarget?.name} - - - - {/* Attached pools list */} -
-
Attached node pools
- - {attachedLoading ? ( -
Loading…
- ) : attachedErr ? ( -
{attachedErr}
- ) : ( -
- - - - Name - Detach - - - - {attachedPools.map((p) => ( - - {p.name} - -
- -
-
-
- ))} - {attachedPools.length === 0 && ( - - - No node pools attached yet. - - - )} -
-
-
- )} -
- - {/* Attach pools */} -
-
- - ( - - Attach more node pools -
- {(() => { - const attachedIds = new Set(attachedPools.map((p) => p.id)) - const attachable = allPools.filter((p) => !attachedIds.has(p.id)) - if (attachable.length === 0) { - return ( -
- No more node pools available to attach -
- ) - } - return attachable.map((p) => { - const checked = field.value?.includes(p.id) || false - return ( - - ) - }) - })()} -
- -
- )} - /> - - - - - - -
-
-
-
+ const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + if (!needle) return annotations + return annotations.filter( + (a) => + a.name.toLowerCase().includes(needle) || + a.value.toLowerCase().includes(needle) || + (a.node_pools || []).some((p) => p.name.toLowerCase().includes(needle)) ) + }, [annotations, q]) + + /* ------------------------------ Mutations ------------------------------- */ + + async function deleteAnnotation(id: string) { + if (!confirm("Delete this annotation? This cannot be undone.")) return + await api.delete(`/api/v1/annotations/${id}`) + await loadAll() + } + + // Create + const createForm = useForm({ + resolver: zodResolver(CreateSchema), + defaultValues: { name: "", value: "", node_pool_ids: [] }, + }) + + const submitCreate = async (values: CreateValues) => { + const payload: any = { name: values.name.trim(), value: values.value.trim() } + if (values.node_pool_ids && values.node_pool_ids.length > 0) { + payload.node_pool_ids = values.node_pool_ids + } + await api.post("/api/v1/annotations", payload) + setCreateOpen(false) + createForm.reset({ name: "", value: "", node_pool_ids: [] }) + await loadAll() + } + + // Edit + const editForm = useForm({ + resolver: zodResolver(UpdateSchema), + defaultValues: { name: "", value: "" }, + }) + + function openEdit(a: Annotation) { + setEditTarget(a) + editForm.reset({ name: a.name, value: a.value }) + } + + const submitEdit = async (values: UpdateValues) => { + if (!editTarget) return + await api.patch(`/api/v1/annotations/${editTarget.id}`, { + name: values.name.trim(), + value: values.value.trim(), + }) + setEditTarget(null) + await loadAll() + } + + // Manage pools (attach/detach) + const attachPoolsForm = useForm({ + resolver: zodResolver(AttachPoolsSchema), + defaultValues: { node_pool_ids: [] }, + }) + + function openManagePools(a: Annotation) { + setManagePoolsTarget(a) + attachPoolsForm.reset({ node_pool_ids: [] }) + void loadAttachedPools(a.id) + } + + const submitAttachPools = async (values: AttachPoolsValues) => { + if (!managePoolsTarget) return + await api.post(`/api/v1/annotations/${managePoolsTarget.id}/node_pools`, { + node_pool_ids: values.node_pool_ids, + }) + attachPoolsForm.reset({ node_pool_ids: [] }) + await loadAttachedPools(managePoolsTarget.id) + await loadAll() + } + + async function detachPool(poolId: string) { + if (!managePoolsTarget) return + if (!confirm("Detach this node pool from the annotation?")) return + await api.delete(`/api/v1/annotations/${managePoolsTarget.id}/node_pools/${poolId}`) + await loadAttachedPools(managePoolsTarget.id) + await loadAll() + } + + /* --------------------------------- Render -------------------------------- */ + + if (loading) return
Loading annotations…
+ if (err) return
{err}
+ + return ( +
+
+

Annotations

+ +
+
+ + setQ(e.target.value)} + placeholder="Search name, value, pool…" + className="w-72 pl-8" + /> +
+ + + + + + + + + + Create annotation + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Value + + + + + + )} + /> + ( + + Initial node pools (optional) +
+ {allPools.length === 0 && ( +
+ No node pools available +
+ )} + {allPools.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + })} +
+ +
+ )} + /> + + + + + + + +
+
+
+
+ +
+
+ + + + Name + Value + Node Pools + Actions + + + + {filtered.map((a) => { + const pools = a.node_pools || [] + return ( + + {a.name} + {a.value} + + +
+ {pools.slice(0, 6).map((p) => ( + + {p.name} + + ))} + {pools.length === 0 && ( + No node pools + )} + {pools.length > 6 && ( + +{pools.length - 6} more + )} +
+ +
+ + +
+ + + + + + + deleteAnnotation(a.id)}> + Confirm delete + + + +
+
+
+ ) + })} + + {filtered.length === 0 && ( + + + No annotations match your search. + + + )} +
+
+
+
+ + {/* Edit dialog */} + !o && setEditTarget(null)}> + + + Edit annotation + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Value + + + + + + )} + /> + + + + + + + +
+
+ + {/* Manage node pools dialog */} + !o && setManagePoolsTarget(null)}> + + + + Manage node pools for {managePoolsTarget?.name} + + + + {/* Attached pools list */} +
+
Attached node pools
+ + {attachedLoading ? ( +
Loading…
+ ) : attachedErr ? ( +
{attachedErr}
+ ) : ( +
+ + + + Name + Detach + + + + {attachedPools.map((p) => ( + + {p.name} + +
+ +
+
+
+ ))} + {attachedPools.length === 0 && ( + + + No node pools attached yet. + + + )} +
+
+
+ )} +
+ + {/* Attach pools */} +
+
+ + ( + + Attach more node pools +
+ {(() => { + const attachedIds = new Set(attachedPools.map((p) => p.id)) + const attachable = allPools.filter((p) => !attachedIds.has(p.id)) + if (attachable.length === 0) { + return ( +
+ No more node pools available to attach +
+ ) + } + return attachable.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + }) + })()} +
+ +
+ )} + /> + + + + + + +
+
+
+
+ ) } diff --git a/ui/src/pages/core/nodepool-page.tsx b/ui/src/pages/core/nodepool-page.tsx index ab5d15e..db181ef 100644 --- a/ui/src/pages/core/nodepool-page.tsx +++ b/ui/src/pages/core/nodepool-page.tsx @@ -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 type CreatePoolValues = z.output @@ -101,20 +111,25 @@ const UpdatePoolSchema = z.object({ type UpdatePoolValues = z.output 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 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 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 +const AttachAnnotationsSchema = z.object({ + annotation_ids: z.array(z.string().uuid()).min(1, "Pick at least one annotation"), +}) +type AttachAnnotationsValues = z.output + /* --------------------------------- 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([]) const [allServers, setAllServers] = useState([]) - // Labels + // Labels / Taints / Annotations const [allLabels, setAllLabels] = useState([]) - - // Taints const [allTaints, setAllTaints] = useState([]) + const [allAnnotations, setAllAnnotations] = useState([]) const [err, setErr] = useState(null) const [q, setQ] = useState("") @@ -189,22 +206,30 @@ export const NodePoolPage = () => { const [taintsLoading, setTaintsLoading] = useState(false) const [taintsErr, setTaintsErr] = useState(null) + // Annotations dialog state + const [manageAnnotationsTarget, setManageAnnotationsTarget] = useState(null) + const [attachedAnnotations, setAttachedAnnotations] = useState([]) + const [annotationsLoading, setAnnotationsLoading] = useState(false) + const [annotationsErr, setAnnotationsErr] = useState(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("/api/v1/node-pools?include=servers"), api.get("/api/v1/servers"), api.get("/api/v1/labels?include=node_pools"), api.get("/api/v1/taints?include=node_pools"), + api.get("/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(`/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() for (const l of allLabels) { @@ -280,7 +324,6 @@ export const NodePoolPage = () => { return map }, [allLabels]) - // poolId -> TaintBrief[] const taintsByPool = useMemo(() => { const map = new Map() for (const t of allTaints) { @@ -293,6 +336,18 @@ export const NodePoolPage = () => { return map }, [allTaints]) + const annotationsByPool = useMemo(() => { + const map = new Map() + 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({ + 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
Loading node pools…
@@ -468,7 +565,7 @@ export const NodePoolPage = () => { setQ(e.target.value)} - placeholder="Search pools, servers, labels, taints…" + placeholder="Search pools, servers, labels, taints, annotations…" className="w-72 pl-8" /> @@ -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 ( {p.name} @@ -612,15 +710,30 @@ export const NodePoolPage = () => { - {/* Annotations placeholder */} + {/* Annotations cell */} -
Annotations
-
- {/* Labels cell with badges */} + {/* Labels cell */}
{labels.slice(0, 6).map((l) => ( @@ -808,7 +921,6 @@ export const NodePoolPage = () => { Attach more servers
- {/* options */} {(() => { const attachedIds = new Set( (manageTarget?.servers || []).map((s) => s.id) @@ -1122,6 +1234,140 @@ export const NodePoolPage = () => {
+ + {/* Manage annotations dialog */} + !o && setManageAnnotationsTarget(null)} + > + + + + Manage annotations for{" "} + {manageAnnotationsTarget?.name} + + + + {/* Attached annotations list */} +
+
Attached annotations
+ + {annotationsLoading ? ( +
Loading…
+ ) : annotationsErr ? ( +
{annotationsErr}
+ ) : ( +
+ + + + Name + Value + Detach + + + + {attachedAnnotations.map((a) => ( + + {a.name} + {a.value} + +
+ +
+
+
+ ))} + {attachedAnnotations.length === 0 && ( + + + No annotations attached yet. + + + )} +
+
+
+ )} +
+ + {/* Attach annotations */} +
+
+ + ( + + Attach more annotations +
+ {(() => { + 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 ( +
+ No more annotations available to attach +
+ ) + } + return attachable.map((a) => { + const checked = field.value?.includes(a.id) || false + return ( + + ) + }) + })()} +
+ +
+ )} + /> + + + + + + +
+
+
) }