From 4e254fc56994ce2a633fb349ee1c355952e7b934 Mon Sep 17 00:00:00 2001 From: allanice001 Date: Wed, 3 Sep 2025 22:49:28 +0100 Subject: [PATCH] taints --- docs/docs.go | 5 +- docs/swagger.json | 5 +- docs/swagger.yaml | 7 +- internal/handlers/nodepools/dto.go | 70 ++- internal/handlers/nodepools/funcs.go | 68 +-- internal/handlers/taints/funcs.go | 14 + internal/handlers/taints/taints.go | 183 +++++--- ui/src/pages/core/nodepool-page.tsx | 301 ++++++++++-- ui/src/pages/core/taints-page.tsx | 655 +++++++++++++++++++++++++++ 9 files changed, 1115 insertions(+), 193 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 1e62216..7129607 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3549,7 +3549,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Returns node taints for the organization in X-Org-ID. Filters: ` + "`" + `key` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (key contains). Add ` + "`" + `include=node_groups` + "`" + ` to include linked node groups.", + "description": "Returns node taints for the organization in X-Org-ID. Filters: ` + "`" + `key` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (key contains). Add ` + "`" + `include=node_pools` + "`" + ` to include linked node pools.", "consumes": [ "application/json" ], @@ -3699,7 +3699,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Returns one taint. Add ` + "`" + `include=node_groups` + "`" + ` to include node groups.", + "description": "Returns one taint. Add ` + "`" + `include=node_pools` + "`" + ` to include node pools.", "consumes": [ "application/json" ], @@ -4620,7 +4620,6 @@ const docTemplate = `{ "type": "string" }, "server_ids": { - "description": "optional initial servers", "type": "array", "items": { "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index d103dc1..cc4aeab 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3545,7 +3545,7 @@ "BearerAuth": [] } ], - "description": "Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_groups` to include linked node groups.", + "description": "Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.", "consumes": [ "application/json" ], @@ -3695,7 +3695,7 @@ "BearerAuth": [] } ], - "description": "Returns one taint. Add `include=node_groups` to include node groups.", + "description": "Returns one taint. Add `include=node_pools` to include node pools.", "consumes": [ "application/json" ], @@ -4616,7 +4616,6 @@ "type": "string" }, "server_ids": { - "description": "optional initial servers", "type": "array", "items": { "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 50552e4..a45e17e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -300,7 +300,6 @@ definitions: name: type: string server_ids: - description: optional initial servers items: type: string type: array @@ -2848,8 +2847,8 @@ paths: consumes: - application/json description: 'Returns node taints for the organization in X-Org-ID. Filters: - `key`, `value`, and `q` (key contains). Add `include=node_groups` to include - linked node groups.' + `key`, `value`, and `q` (key contains). Add `include=node_pools` to include + linked node pools.' parameters: - description: Organization UUID in: header @@ -2989,7 +2988,7 @@ paths: get: consumes: - application/json - description: Returns one taint. Add `include=node_groups` to include node groups. + description: Returns one taint. Add `include=node_pools` to include node pools. parameters: - description: Organization UUID in: header diff --git a/internal/handlers/nodepools/dto.go b/internal/handlers/nodepools/dto.go index e66e30f..e834455 100644 --- a/internal/handlers/nodepools/dto.go +++ b/internal/handlers/nodepools/dto.go @@ -4,6 +4,27 @@ import ( "github.com/google/uuid" ) +type createNodePoolRequest struct { + Name string `json:"name"` + ServerIDs []string `json:"server_ids"` +} + +type updateNodePoolRequest struct { + Name *string `json:"name"` +} + +type attachServersRequest struct { + ServerIDs []string `json:"server_ids"` +} + +type attachLabelsRequest struct { + LabelIDs []string `json:"label_ids"` +} + +type attachTaintsRequest struct { + TaintIDs []string `json:"taint_ids"` +} + type nodePoolResponse struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -12,34 +33,10 @@ type nodePoolResponse struct { type serverBrief struct { ID uuid.UUID `json:"id"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - Role string `json:"role"` - Status string `json:"status"` -} - -type createNodePoolRequest struct { - Name string `json:"name"` - ServerIDs []string `json:"server_ids,omitempty"` // optional initial servers -} - -type updateNodePoolRequest struct { - Name *string `json:"name,omitempty"` -} - -type attachServersRequest struct { - ServerIDs []string `json:"server_ids"` -} - -type taintBrief struct { - ID uuid.UUID `json:"id"` - Key string `json:"key"` - Value string `json:"value"` - Effect string `json:"effect"` -} - -type attachTaintsRequest struct { - TaintIDs []string `json:"taint_ids"` + Hostname string `json:"hostname,omitempty"` + IP string `json:"ip,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` } type labelBrief struct { @@ -48,16 +45,9 @@ type labelBrief struct { Value string `json:"value"` } -type attachLabelsRequest struct { - LabelIDs []string `json:"label_ids"` -} - -type annotationBrief struct { - ID uuid.UUID `json:"id"` - Key string `json:"key"` - Value string `json:"value"` -} - -type attachAnnotationsRequest struct { - AnnotationIDs []string `json:"annotation_ids"` +type taintBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` } diff --git a/internal/handlers/nodepools/funcs.go b/internal/handlers/nodepools/funcs.go index 6b40877..b48168b 100644 --- a/internal/handlers/nodepools/funcs.go +++ b/internal/handlers/nodepools/funcs.go @@ -2,8 +2,6 @@ package nodepools import ( "errors" - "fmt" - "strings" "github.com/glueops/autoglue/internal/db" "github.com/glueops/autoglue/internal/db/models" @@ -11,14 +9,14 @@ import ( ) func toResp(ng models.NodePool, includeServers bool) nodePoolResponse { - resp := nodePoolResponse{ + out := nodePoolResponse{ ID: ng.ID, Name: ng.Name, } if includeServers { - resp.Servers = make([]serverBrief, 0, len(ng.Servers)) + out.Servers = make([]serverBrief, 0, len(ng.Servers)) for _, s := range ng.Servers { - resp.Servers = append(resp.Servers, serverBrief{ + out.Servers = append(out.Servers, serverBrief{ ID: s.ID, Hostname: s.Hostname, IP: s.IPAddress, @@ -27,17 +25,17 @@ func toResp(ng models.NodePool, includeServers bool) nodePoolResponse { }) } } - return resp + return out } func parseUUIDs(ids []string) ([]uuid.UUID, error) { out := make([]uuid.UUID, 0, len(ids)) - for _, s := range ids { - u, err := uuid.Parse(strings.TrimSpace(s)) + for _, raw := range ids { + id, err := uuid.Parse(raw) if err != nil { return nil, err } - out = append(out, u) + out = append(out, id) } return out, nil } @@ -50,15 +48,25 @@ func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { return err } if count != int64(len(ids)) { - return fmt.Errorf("some servers do not belong to this organization") + return errors.New("some servers do not belong to org") + } + return nil +} + +func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + var count int64 + if err := db.DB.Model(&models.Label{}). + Where("organization_id = ? AND id IN ?", orgID, ids). + Count(&count).Error; err != nil { + return err + } + if count != int64(len(ids)) { + return errors.New("some labels do not belong to org") } return nil } func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } var count int64 if err := db.DB.Model(&models.Taint{}). Where("organization_id = ? AND id IN ?", orgID, ids). @@ -66,39 +74,7 @@ func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { return err } if count != int64(len(ids)) { - return errors.New("some taints not in organization") - } - return nil -} - -func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - var cnt int64 - if err := db.DB.Model(&models.Label{}). - Where("organization_id = ? AND id IN ?", orgID, ids). - Count(&cnt).Error; err != nil { - return err - } - if cnt != int64(len(ids)) { - return errors.New("one or more labels not in organization") - } - return nil -} - -func ensureAnnotationsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - var cnt int64 - if err := db.DB.Model(&models.Annotation{}). - Where("organization_id = ? AND id IN ?", orgID, ids). - Count(&cnt).Error; err != nil { - return err - } - if cnt != int64(len(ids)) { - return errors.New("one or more annotations not in organization") + return errors.New("some taints do not belong to org") } return nil } diff --git a/internal/handlers/taints/funcs.go b/internal/handlers/taints/funcs.go index d387330..e90a955 100644 --- a/internal/handlers/taints/funcs.go +++ b/internal/handlers/taints/funcs.go @@ -2,6 +2,7 @@ package taints import ( "fmt" + "net/http" "strings" "github.com/glueops/autoglue/internal/db" @@ -9,6 +10,19 @@ import ( "github.com/google/uuid" ) +var allowedEffects = map[string]struct{}{ + "NoSchedule": {}, + "PreferNoSchedule": {}, + "NoExecute": {}, +} + +// includeNodePools returns true when the query param requests linked pools. +// Accepts both "node_pools" and "node_groups" for compatibility. +func includeNodePools(r *http.Request) bool { + inc := strings.TrimSpace(r.URL.Query().Get("include")) + return strings.EqualFold(inc, "node_pools") || strings.EqualFold(inc, "node_groups") +} + func toResp(t models.Taint, include bool) taintResponse { resp := taintResponse{ ID: t.ID, diff --git a/internal/handlers/taints/taints.go b/internal/handlers/taints/taints.go index 67f284c..ffbb2bf 100644 --- a/internal/handlers/taints/taints.go +++ b/internal/handlers/taints/taints.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "slices" "strings" "github.com/glueops/autoglue/internal/db" @@ -15,9 +16,11 @@ import ( "gorm.io/gorm" ) +// ---------- Handlers ---------- + // ListTaints godoc // @Summary List node taints (org scoped) -// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_groups` to include linked node groups. +// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools. // @Tags taints // @Accept json // @Produce json @@ -40,37 +43,38 @@ func ListTaints(w http.ResponseWriter, r *http.Request) { } q := db.DB.Where("organization_id = ?", ac.OrganizationID) + if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" { - q = q.Where("key = ?", key) + q = q.Where(`key = ?`, key) } if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" { - q = q.Where("value = ?", val) + q = q.Where(`value = ?`, val) } if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { - q = q.Where("name ILIKE ?", "%"+needle+"%") + q = q.Where(`key ILIKE ?`, "%"+needle+"%") } - includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") - if includePools { + withPools := includeNodePools(r) + if withPools { q = q.Preload("NodePools") } var rows []models.Taint if err := q.Order("created_at DESC").Find(&rows).Error; err != nil { - http.Error(w, "failed to list taints", http.StatusInternalServerError) + http.Error(w, "failed to list node taints", http.StatusInternalServerError) return } out := make([]taintResponse, 0, len(rows)) - for _, np := range rows { - out = append(out, toResp(np, includePools)) + for _, t := range rows { + out = append(out, toResp(t, withPools)) } _ = response.JSON(w, http.StatusOK, out) } // GetTaint godoc // @Summary Get node taint by ID (org scoped) -// @Description Returns one taint. Add `include=node_groups` to include node groups. +// @Description Returns one taint. Add `include=node_pools` to include node pools. // @Tags taints // @Accept json // @Produce json @@ -94,28 +98,27 @@ func GetTaint(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { - http.Error(w, "invalid taint id", http.StatusBadRequest) + http.Error(w, "invalid id", http.StatusBadRequest) return } - include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") + withPools := includeNodePools(r) var t models.Taint q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID) - if include { + if withPools { q = q.Preload("NodePools") } - if err := q.First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - http.Error(w, "taint not found", http.StatusNotFound) + http.Error(w, "not found", http.StatusNotFound) return } - http.Error(w, "failed to find taint", http.StatusInternalServerError) + http.Error(w, "fetch failed", http.StatusInternalServerError) return } - _ = response.JSON(w, http.StatusOK, toResp(t, include)) + _ = response.JSON(w, http.StatusOK, toResp(t, withPools)) } // CreateTaint godoc @@ -141,8 +144,20 @@ func CreateTaint(w http.ResponseWriter, r *http.Request) { } var req createTaintRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" || req.Value == "" || req.Effect == "" { - http.Error(w, "invalid json or missing key/value/effect", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + req.Key = strings.TrimSpace(req.Key) + req.Value = strings.TrimSpace(req.Value) + req.Effect = strings.TrimSpace(req.Effect) + + if req.Key == "" || req.Effect == "" { + http.Error(w, "invalid json or missing key/effect", http.StatusBadRequest) + return + } + if _, ok := allowedEffects[req.Effect]; !ok { + http.Error(w, "invalid effect", http.StatusBadRequest) return } @@ -152,33 +167,39 @@ func CreateTaint(w http.ResponseWriter, r *http.Request) { Value: req.Value, Effect: req.Effect, } - if err := db.DB.Create(&t).Error; err != nil { - http.Error(w, "failed to create taint", http.StatusInternalServerError) + http.Error(w, "create failed", http.StatusInternalServerError) return } + // optional initial links if len(req.NodePoolIDs) > 0 { ids, err := parseUUIDs(req.NodePoolIDs) if err != nil { - http.Error(w, "invalid node pool IDs", http.StatusBadRequest) + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) return } if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil { - http.Error(w, "invalid node pool IDs for this organization", http.StatusBadRequest) + http.Error(w, "invalid node_pool_ids for this organization", http.StatusBadRequest) return } - var nps []models.NodePool - if err := db.DB.Where("id in ? AND organization_id = ?", ids, ac.OrganizationID).Find(&nps).Error; err != nil { - http.Error(w, "node pools not found for this organization", http.StatusInternalServerError) + var pools []models.NodePool + if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID). + Find(&pools).Error; err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) return } - if err := db.DB.Model(&t).Association("NodePools").Append(&nps); err != nil { - http.Error(w, "attach node pools failed", http.StatusInternalServerError) + if len(pools) != len(ids) { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := db.DB.Model(&t).Association("NodePools").Append(&pools); err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) return } } + _ = response.JSON(w, http.StatusCreated, toResp(t, false)) } @@ -205,7 +226,6 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) { http.Error(w, "organization required", http.StatusForbidden) return } - id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) @@ -213,7 +233,8 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) { } var t models.Taint - if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&t).Error; err != nil { + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID). + First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { http.Error(w, "not found", http.StatusNotFound) return @@ -227,6 +248,7 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid json", http.StatusBadRequest) return } + if req.Key != nil { t.Key = strings.TrimSpace(*req.Key) } @@ -234,7 +256,16 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) { t.Value = strings.TrimSpace(*req.Value) } if req.Effect != nil { - t.Effect = strings.TrimSpace(*req.Effect) + e := strings.TrimSpace(*req.Effect) + if e == "" { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := allowedEffects[e]; !ok { + http.Error(w, "invalid effect", http.StatusBadRequest) + return + } + t.Effect = e } if err := db.DB.Save(&t).Error; err != nil { @@ -265,18 +296,17 @@ func DeleteTaint(w http.ResponseWriter, r *http.Request) { http.Error(w, "organization required", http.StatusForbidden) return } - id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } - if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).Delete(&models.Taint{}).Error; err != nil { + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID). + Delete(&models.Taint{}).Error; err != nil { http.Error(w, "delete failed", http.StatusInternalServerError) return } - response.NoContent(w) } @@ -304,7 +334,6 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) { http.Error(w, "organization required", http.StatusForbidden) return } - taintID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) @@ -312,8 +341,7 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) { } var t models.Taint - if err := db.DB. - Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID). + if err := db.DB.Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID). First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { http.Error(w, "not found", http.StatusNotFound) @@ -323,9 +351,7 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) { return } - var in struct { - NodePoolIDs []string `json:"node_pool_ids"` - } + var in addTaintToPoolRequest if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.NodePoolIDs) == 0 { http.Error(w, "invalid json or empty node_pool_ids", http.StatusBadRequest) return @@ -341,20 +367,43 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) { return } - var pools []models.NodePool - if err := db.DB. - Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID). - Find(&pools).Error; err != nil { + // Fetch existing links to avoid duplicates + var existing []models.NodePool + if err := db.DB.Model(&t).Association("NodePools").Find(&existing); err != nil { http.Error(w, "attach failed", http.StatusInternalServerError) return } - if err := db.DB.Model(&t).Association("NodePools").Append(&pools); err != nil { - http.Error(w, "attach failed", http.StatusInternalServerError) - return + existingIDs := make([]uuid.UUID, 0, len(existing)) + for _, p := range existing { + existingIDs = append(existingIDs, p.ID) } - includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") - if includePools { + toFetch := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if !slices.Contains(existingIDs, id) { + toFetch = append(toFetch, id) + } + } + + if len(toFetch) > 0 { + var toAttach []models.NodePool + if err := db.DB.Where("id IN ? AND organization_id = ?", toFetch, ac.OrganizationID). + Find(&toAttach).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if len(toAttach) != len(toFetch) { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := db.DB.Model(&t).Association("NodePools").Append(&toAttach); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + } + + withPools := includeNodePools(r) + if withPools { if err := db.DB.Preload("NodePools"). First(&t, "id = ? AND organization_id = ?", taintID, ac.OrganizationID).Error; err != nil { http.Error(w, "fetch failed", http.StatusInternalServerError) @@ -362,7 +411,7 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) { } } - _ = response.JSON(w, http.StatusOK, toResp(t, includePools)) + _ = response.JSON(w, http.StatusOK, toResp(t, withPools)) } // RemoveTaintFromNodePool godoc @@ -426,7 +475,6 @@ func RemoveTaintFromNodePool(w http.ResponseWriter, r *http.Request) { http.Error(w, "detach failed", http.StatusInternalServerError) return } - response.NoContent(w) } @@ -460,9 +508,10 @@ func ListNodePoolsWithTaint(w http.ResponseWriter, r *http.Request) { return } - // Ensure the taint exists and belongs to this org + // Load the taint and its pools using GORM's mapping (avoids guessing join table name) var t models.Taint if err := db.DB.Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID). + Preload("NodePools"). First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { http.Error(w, "not found", http.StatusNotFound) @@ -472,24 +521,18 @@ func ListNodePoolsWithTaint(w http.ResponseWriter, r *http.Request) { return } - // Build query for pools linked via join table - q := db.DB.Model(&models.NodePool{}). - Joins("JOIN taint_node_pools tnp ON tnp.node_pool_id = node_pools.id"). - Where("tnp.taint_id = ? AND node_pools.organization_id = ?", taintID, ac.OrganizationID) - - if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { - q = q.Where("node_pools.name ILIKE ?", "%"+needle+"%") + needle := strings.TrimSpace(r.URL.Query().Get("q")) + out := make([]nodePoolResponse, 0, len(t.NodePools)) + for _, p := range t.NodePools { + if needle != "" && !strings.Contains(strings.ToLower(p.Name), strings.ToLower(needle)) { + continue + } + out = append(out, nodePoolResponse{ + ID: p.ID, + Name: p.Name, + // Servers intentionally omitted here; this endpoint doesn't include them by default. + }) } - var pools []models.NodePool - if err := q.Order("node_pools.created_at DESC").Find(&pools).Error; err != nil { - http.Error(w, "fetch failed", http.StatusInternalServerError) - return - } - - // If you have a serializer like toNodePoolResp, use it; otherwise return models with JSON tags. - //out := make([]nodePoolResponse, 0, len(pools)) - //for _, p := range pools { out = append(out, toNodePoolResp(p)) } - - _ = response.JSON(w, http.StatusOK, pools) + _ = response.JSON(w, http.StatusOK, out) } diff --git a/ui/src/pages/core/nodepool-page.tsx b/ui/src/pages/core/nodepool-page.tsx index 746e848..ab5d15e 100644 --- a/ui/src/pages/core/nodepool-page.tsx +++ b/ui/src/pages/core/nodepool-page.tsx @@ -71,6 +71,17 @@ type LabelWithPools = LabelBrief & { node_groups?: { id: string; name: string }[] } +type TaintBrief = { + id: string + key: string + value: string + effect: string +} + +type TaintWithPools = TaintBrief & { + node_groups?: { id: string; name: string }[] +} + type NodePool = { id: string name: string @@ -79,7 +90,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.string().uuid()).optional().default([]), + server_ids: z.array(z.uuid()).optional().default([]), }) type CreatePoolInput = z.input type CreatePoolValues = z.output @@ -90,15 +101,20 @@ const UpdatePoolSchema = z.object({ type UpdatePoolValues = z.output const AttachServersSchema = z.object({ - server_ids: z.array(z.string().uuid()).min(1, "Pick at least one server"), + server_ids: z.array(z.uuid()).min(1, "Pick at least one server"), }) type AttachServersValues = z.output const AttachLabelsSchema = z.object({ - label_ids: z.array(z.string().uuid()).min(1, "Pick at least one label"), + label_ids: z.array(z.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"), +}) +type AttachTaintsValues = z.output + /* --------------------------------- Utils --------------------------------- */ function StatusBadge({ status }: { status?: string }) { @@ -133,6 +149,12 @@ function labelKV(l: LabelBrief) { return `${l.key}=${l.value}` } +function taintText(t: TaintBrief) { + // Kubernetes-ish: key[=value]:effect + const kv = t.value ? `${t.key}=${t.value}` : t.key + return `${kv}:${t.effect}` +} + /* --------------------------------- Page ---------------------------------- */ export const NodePoolPage = () => { @@ -140,9 +162,12 @@ export const NodePoolPage = () => { const [pools, setPools] = useState([]) const [allServers, setAllServers] = useState([]) - // Pull labels with include=node_pools so we can map them to pools + // Labels const [allLabels, setAllLabels] = useState([]) + // Taints + const [allTaints, setAllTaints] = useState([]) + const [err, setErr] = useState(null) const [q, setQ] = useState("") @@ -158,20 +183,28 @@ export const NodePoolPage = () => { const [labelsLoading, setLabelsLoading] = useState(false) const [labelsErr, setLabelsErr] = useState(null) + // Taints dialog state + const [manageTaintsTarget, setManageTaintsTarget] = useState(null) + const [attachedTaints, setAttachedTaints] = useState([]) + const [taintsLoading, setTaintsLoading] = useState(false) + const [taintsErr, setTaintsErr] = useState(null) + /* ------------------------------- Data Load ------------------------------ */ async function loadAll() { setLoading(true) setErr(null) try { - const [poolsData, serversData, labelsData] = await Promise.all([ + const [poolsData, serversData, labelsData, taintsData] = 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"), ]) setPools(poolsData || []) setAllServers(serversData || []) setAllLabels(labelsData || []) + setAllTaints(taintsData || []) if (manageTarget) { const refreshed = (poolsData || []).find((p) => p.id === manageTarget.id) || null @@ -184,9 +217,13 @@ export const NodePoolPage = () => { if (manageLabelsTarget) { await loadAttachedLabels(manageLabelsTarget.id) } + if (manageTaintsTarget) { + await loadAttachedTaints(manageTaintsTarget.id) + } } catch (e) { console.error(e) - const msg = e instanceof ApiError ? e.message : "Failed to load node pools / servers / labels" + const msg = + e instanceof ApiError ? e.message : "Failed to load node pools / servers / labels / taints" setErr(msg) } finally { setLoading(false) @@ -208,14 +245,29 @@ export const NodePoolPage = () => { } } + async function loadAttachedTaints(poolId: string) { + setTaintsLoading(true) + setTaintsErr(null) + try { + const data = await api.get(`/api/v1/node-pools/${poolId}/taints`) + setAttachedTaints(data || []) + } catch (e) { + console.error(e) + const msg = e instanceof ApiError ? e.message : "Failed to load taints for pool" + setTaintsErr(msg) + } finally { + setTaintsLoading(false) + } + } + useEffect(() => { void loadAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - /* ---------------------------- Labels per Pool --------------------------- */ + /* ---------------------------- Labels/Taints per Pool --------------------------- */ - // Build a quick lookup: poolId -> LabelBrief[] + // poolId -> LabelBrief[] const labelsByPool = useMemo(() => { const map = new Map() for (const l of allLabels) { @@ -228,26 +280,47 @@ export const NodePoolPage = () => { return map }, [allLabels]) + // poolId -> TaintBrief[] + const taintsByPool = useMemo(() => { + const map = new Map() + for (const t of allTaints) { + for (const ng of t.node_groups || []) { + const arr = map.get(ng.id) || [] + arr.push({ id: t.id, key: t.key, value: t.value, effect: t.effect }) + map.set(ng.id, arr) + } + } + return map + }, [allTaints]) + /* -------------------------------- Filters ------------------------------- */ const filtered = useMemo(() => { const needle = q.trim().toLowerCase() if (!needle) return pools - return pools.filter( - (p) => - p.name.toLowerCase().includes(needle) || - (p.servers || []).some( - (s) => - (s.hostname || "").toLowerCase().includes(needle) || - (s.ip || s.ip_address || "").toLowerCase().includes(needle) || - (s.role || "").toLowerCase().includes(needle) - ) || - (labelsByPool.get(p.id) || []).some( - (l) => - l.key.toLowerCase().includes(needle) || (l.value || "").toLowerCase().includes(needle) + return pools.filter((p) => { + const serversMatch = (p.servers || []).some( + (s) => + (s.hostname || "").toLowerCase().includes(needle) || + (s.ip || s.ip_address || "").toLowerCase().includes(needle) || + (s.role || "").toLowerCase().includes(needle) + ) + const labelsMatch = (labelsByPool.get(p.id) || []).some( + (l) => + l.key.toLowerCase().includes(needle) || (l.value || "").toLowerCase().includes(needle) + ) + const taintsMatch = (taintsByPool.get(p.id) || []).some((t) => { + const kv = `${t.key}=${t.value}`.toLowerCase() + return ( + t.key.toLowerCase().includes(needle) || + (t.value || "").toLowerCase().includes(needle) || + t.effect.toLowerCase().includes(needle) || + kv.includes(needle) ) - ) - }, [pools, q, labelsByPool]) + }) + return p.name.toLowerCase().includes(needle) || serversMatch || labelsMatch || taintsMatch + }) + }, [pools, q, labelsByPool, taintsByPool]) /* ------------------------------ Mutations ------------------------------- */ @@ -349,6 +422,36 @@ export const NodePoolPage = () => { await loadAll() // refresh badges in table } + // Attach / Detach Taints + const attachTaintsForm = useForm({ + resolver: zodResolver(AttachTaintsSchema), + defaultValues: { taint_ids: [] }, + }) + + function openManageTaints(p: NodePool) { + setManageTaintsTarget(p) + attachTaintsForm.reset({ taint_ids: [] }) + void loadAttachedTaints(p.id) + } + + const submitAttachTaints = async (values: AttachTaintsValues) => { + if (!manageTaintsTarget) return + await api.post(`/api/v1/node-pools/${manageTaintsTarget.id}/taints`, { + taint_ids: values.taint_ids, + }) + attachTaintsForm.reset({ taint_ids: [] }) + await loadAttachedTaints(manageTaintsTarget.id) + await loadAll() // refresh taint badges in table + } + + async function detachTaint(taintId: string) { + if (!manageTaintsTarget) return + if (!confirm("Detach this taint from the pool?")) return + await api.delete(`/api/v1/node-pools/${manageTaintsTarget.id}/taints/${taintId}`) + await loadAttachedTaints(manageTaintsTarget.id) + await loadAll() + } + /* --------------------------------- Render -------------------------------- */ if (loading) return
Loading node pools…
@@ -365,7 +468,7 @@ export const NodePoolPage = () => { setQ(e.target.value)} - placeholder="Search pools, servers, labels…" + placeholder="Search pools, servers, labels, taints…" className="w-72 pl-8" /> @@ -475,6 +578,7 @@ export const NodePoolPage = () => { {filtered.map((p) => { const labels = labelsByPool.get(p.id) || [] + const taints = taintsByPool.get(p.id) || [] return ( {p.name} @@ -537,10 +641,23 @@ export const NodePoolPage = () => { - {/* Taints placeholder */} + {/* Taints cell */} -
Taints
-
@@ -822,7 +939,7 @@ export const NodePoolPage = () => {
{(() => { const attachedIds = new Set(attachedLabels.map((l) => l.id)) - const attachable = (allLabels as LabelBrief[]).filter( + const attachable = (allLabels as unknown as LabelBrief[]).filter( (l) => !attachedIds.has(l.id) ) if (attachable.length === 0) { @@ -875,6 +992,136 @@ export const NodePoolPage = () => {
+ + {/* Manage taints dialog */} + !o && setManageTaintsTarget(null)}> + + + + Manage taints for {manageTaintsTarget?.name} + + + + {/* Attached taints list */} +
+
Attached taints
+ + {taintsLoading ? ( +
Loading…
+ ) : taintsErr ? ( +
{taintsErr}
+ ) : ( +
+ + + + Key + Value + Effect + Detach + + + + {attachedTaints.map((t) => ( + + {t.key} + {t.value} + {t.effect} + +
+ +
+
+
+ ))} + {attachedTaints.length === 0 && ( + + + No taints attached yet. + + + )} +
+
+
+ )} +
+ + {/* Attach taints */} +
+
+ + ( + + Attach more taints +
+ {(() => { + const attachedIds = new Set(attachedTaints.map((t) => t.id)) + const attachable = (allTaints as unknown as TaintBrief[]).filter( + (t) => !attachedIds.has(t.id) + ) + if (attachable.length === 0) { + return ( +
+ No more taints available to attach +
+ ) + } + return attachable.map((t) => { + const checked = field.value?.includes(t.id) || false + return ( + + ) + }) + })()} +
+ +
+ )} + /> + + + + + + +
+
+
) } diff --git a/ui/src/pages/core/taints-page.tsx b/ui/src/pages/core/taints-page.tsx index c2337cb..ad07d05 100644 --- a/ui/src/pages/core/taints-page.tsx +++ b/ui/src/pages/core/taints-page.tsx @@ -1,9 +1,664 @@ +import { useEffect, useMemo, useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { + BadgeCheck, + CircleSlash2, + LinkIcon, + Pencil, + Plus, + RefreshCw, + Search, + Tags, + Trash, + UnlinkIcon, +} from "lucide-react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { api, ApiError } from "@/lib/api.ts" +import { Badge } from "@/components/ui/badge.tsx" +import { Button } from "@/components/ui/button.tsx" +import { Checkbox } from "@/components/ui/checkbox.tsx" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog.tsx" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx" +import { Input } from "@/components/ui/input.tsx" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx" + +type NodePoolBrief = { id: string; name: string } +type Taint = { + id: string + key: string + value?: string | null + effect?: string | null + node_groups?: NodePoolBrief[] // API uses "node_groups" for attached pools +} + +const EFFECTS = ["NoSchedule", "PreferNoSchedule", "NoExecute"] as const + +const CreateTaintSchema = z.object({ + key: z.string().trim().min(1, "Key is required").max(120, "Max 120 chars"), + value: z.string().trim().optional(), + effect: z.enum(EFFECTS), + node_pool_ids: z.array(z.uuid()).optional().default([]), +}) +type CreateTaintInput = z.input +type CreateTaintValues = z.output + +const UpdateTaintSchema = z.object({ + key: z.string().trim().min(1, "Key is required").max(120).optional(), + value: z.string().trim().optional(), + effect: z.enum(EFFECTS as unknown as [string, ...string[]]).optional(), +}) +type UpdateTaintValues = z.output + +const AttachPoolsSchema = z.object({ + node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"), +}) +type AttachPoolsValues = z.output + +function truncateMiddle(str?: string | null, keep = 12) { + if (!str) return "" + if (str.length <= keep * 2 + 3) return str + return `${str.slice(0, keep)}…${str.slice(-keep)}` +} + +function TaintBadge({ t }: { t: Pick }) { + const label = `${t.key}${t.value ? `=${t.value}` : ""}${t.effect ? `:${t.effect}` : ""}` + return ( + + + {label} + + ) +} + export const TaintsPage = () => { + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + const [taints, setTaints] = useState([]) + const [allPools, setAllPools] = useState([]) + + const [q, setQ] = useState("") + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [manageTarget, setManageTarget] = useState(null) + + async function loadAll() { + setLoading(true) + setErr(null) + try { + // include attached node pools for quick display + const [taintsData, poolsData] = await Promise.all([ + api.get("/api/v1/taints?include=node_groups"), + api.get("/api/v1/node-pools"), + ]) + setTaints(taintsData || []) + setAllPools(poolsData || []) + + if (manageTarget) { + const refreshed = (taintsData || []).find((t) => t.id === manageTarget.id) || null + setManageTarget(refreshed) + } + if (editTarget) { + const refreshed = (taintsData || []).find((t) => t.id === editTarget.id) || null + setEditTarget(refreshed) + } + } catch (e) { + console.error(e) + const msg = e instanceof ApiError ? e.message : "Failed to load taints or node pools" + setErr(msg) + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + if (!needle) return taints + return taints.filter((t) => { + const label = + `${t.key}${t.value ? `=${t.value}` : ""}${t.effect ? `:${t.effect}` : ""}`.toLowerCase() + const pools = (t.node_groups || []).some((p) => p.name.toLowerCase().includes(needle)) + return label.includes(needle) || pools + }) + }, [taints, q]) + + async function deleteTaint(id: string) { + if (!confirm("Delete this taint? This cannot be undone.")) return + await api.delete(`/api/v1/taints/${id}`) + await loadAll() + } + + // -------- Create -------- + const createForm = useForm({ + resolver: zodResolver(CreateTaintSchema), + defaultValues: { key: "", value: "", effect: undefined, node_pool_ids: [] }, + }) + + const submitCreate = async (values: CreateTaintValues) => { + const payload: any = { + key: values.key.trim(), + effect: values.effect, + } + if (values.value) payload.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/taints", payload) + setCreateOpen(false) + createForm.reset({ key: "", value: "", effect: undefined, node_pool_ids: [] }) + await loadAll() + } + + // -------- Edit -------- + const editForm = useForm({ + resolver: zodResolver(UpdateTaintSchema), + defaultValues: {}, + }) + + function openEdit(t: Taint) { + setEditTarget(t) + editForm.reset({ key: t.key, value: t.value || "", effect: (t.effect as any) || undefined }) + } + + const submitEdit = async (values: UpdateTaintValues) => { + if (!editTarget) return + const body: Record = {} + if (values.key !== undefined) body.key = values.key.trim() + if (values.value !== undefined) body.value = values.value?.trim() ?? "" + if (values.effect !== undefined) body.effect = values.effect + await api.patch(`/api/v1/taints/${editTarget.id}`, body) + setEditTarget(null) + await loadAll() + } + + // -------- Manage attached pools -------- + const attachForm = useForm({ + resolver: zodResolver(AttachPoolsSchema), + defaultValues: { node_pool_ids: [] }, + }) + + function openManage(t: Taint) { + setManageTarget(t) + attachForm.reset({ node_pool_ids: [] }) + } + + const submitAttach = async (values: AttachPoolsValues) => { + if (!manageTarget) return + await api.post(`/api/v1/taints/${manageTarget.id}/node_pools`, { + node_pool_ids: values.node_pool_ids, + }) + attachForm.reset({ node_pool_ids: [] }) + await loadAll() + } + + async function detachPool(poolId: string) { + if (!manageTarget) return + if (!confirm("Detach this taint from the node pool?")) return + await api.delete(`/api/v1/taints/${manageTarget.id}/node_pools/${poolId}`) + await loadAll() + } + + const attachablePools = useMemo(() => { + if (!manageTarget) return [] as NodePoolBrief[] + const attachedIds = new Set((manageTarget.node_groups || []).map((p) => p.id)) + return allPools.filter((p) => !attachedIds.has(p.id)) + }, [manageTarget, allPools]) + + // -------- UI -------- + if (loading) return
Loading taints…
+ if (err) return
{err}
+ return (

Taints

+ +
+
+ + setQ(e.target.value)} + placeholder="Search taints or attached pools…" + className="w-72 pl-8" + /> +
+ + + + + + + + + + Create taint + + +
+ + ( + + Key + + + + + + )} + /> + + ( + + Value (optional) + + + + + + )} + /> + + ( + + Effect + + + + )} + /> + + ( + + Attach to node pools (optional) +
+ {allPools.length === 0 && ( +
+ No node pools available +
+ )} + {allPools.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + })} +
+ +
+ )} + /> + + + + + + + +
+
+
+ +
+
+ + + + Taint + Attached Node Pools + Actions + + + + {filtered.map((t) => ( + + +
+ + + {truncateMiddle(t.id, 6)} + +
+
+ + +
+ {(t.node_groups || []).slice(0, 6).map((p) => ( + + + {p.name} + + ))} + + {(t.node_groups || []).length === 0 && ( + No node pools + )} + {(t.node_groups || []).length > 6 && ( + + +{(t.node_groups || []).length - 6} more + + )} +
+ + +
+ + +
+ + + + + + + + deleteTaint(t.id)}> + Confirm delete + + + +
+
+
+ ))} + + {filtered.length === 0 && ( + + + + No taints match your search. + + + )} +
+
+
+
+ + {/* Edit dialog */} + !o && setEditTarget(null)}> + + + Edit taint + + +
+ + ( + + Key + + + + + + )} + /> + + ( + + Value (optional) + + + + + + )} + /> + + ( + + Effect + + + + )} + /> + + + + + + + +
+
+ + {/* Manage node pools dialog */} + !o && setManageTarget(null)}> + + + + Manage pools for{" "} + + {manageTarget + ? `${manageTarget.key}${manageTarget.value ? `=${manageTarget.value}` : ""}${manageTarget.effect ? `:${manageTarget.effect}` : ""}` + : ""} + + + + + {/* Attached pools */} +
+
Attached node pools
+
+ + + + Name + Detach + + + + {(manageTarget?.node_groups || []).map((p) => ( + + {p.name} + +
+ +
+
+
+ ))} + + {(manageTarget?.node_groups || []).length === 0 && ( + + + No node pools attached yet. + + + )} +
+
+
+
+ + {/* Attach section */} +
+
+ + ( + + Attach more node pools +
+ {attachablePools.length === 0 && ( +
+ No more node pools available to attach +
+ )} + {attachablePools.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + })} +
+ +
+ )} + /> + + + + + + +
+
+
) }