mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
taints
This commit is contained in:
@@ -3549,7 +3549,7 @@ const docTemplate = `{
|
|||||||
"BearerAuth": []
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -3699,7 +3699,7 @@ const docTemplate = `{
|
|||||||
"BearerAuth": []
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -4620,7 +4620,6 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"server_ids": {
|
"server_ids": {
|
||||||
"description": "optional initial servers",
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -3545,7 +3545,7 @@
|
|||||||
"BearerAuth": []
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -3695,7 +3695,7 @@
|
|||||||
"BearerAuth": []
|
"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": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -4616,7 +4616,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"server_ids": {
|
"server_ids": {
|
||||||
"description": "optional initial servers",
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ definitions:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
server_ids:
|
server_ids:
|
||||||
description: optional initial servers
|
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
@@ -2848,8 +2847,8 @@ paths:
|
|||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 'Returns node taints for the organization in X-Org-ID. Filters:
|
description: 'Returns node taints for the organization in X-Org-ID. Filters:
|
||||||
`key`, `value`, and `q` (key contains). Add `include=node_groups` to include
|
`key`, `value`, and `q` (key contains). Add `include=node_pools` to include
|
||||||
linked node groups.'
|
linked node pools.'
|
||||||
parameters:
|
parameters:
|
||||||
- description: Organization UUID
|
- description: Organization UUID
|
||||||
in: header
|
in: header
|
||||||
@@ -2989,7 +2988,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- 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:
|
parameters:
|
||||||
- description: Organization UUID
|
- description: Organization UUID
|
||||||
in: header
|
in: header
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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 {
|
type nodePoolResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -12,34 +33,10 @@ type nodePoolResponse struct {
|
|||||||
|
|
||||||
type serverBrief struct {
|
type serverBrief struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip,omitempty"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status,omitempty"`
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type labelBrief struct {
|
type labelBrief struct {
|
||||||
@@ -48,16 +45,9 @@ type labelBrief struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type attachLabelsRequest struct {
|
type taintBrief struct {
|
||||||
LabelIDs []string `json:"label_ids"`
|
ID uuid.UUID `json:"id"`
|
||||||
}
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
type annotationBrief struct {
|
Effect string `json:"effect"`
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type attachAnnotationsRequest struct {
|
|
||||||
AnnotationIDs []string `json:"annotation_ids"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package nodepools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/db"
|
"github.com/glueops/autoglue/internal/db"
|
||||||
"github.com/glueops/autoglue/internal/db/models"
|
"github.com/glueops/autoglue/internal/db/models"
|
||||||
@@ -11,14 +9,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func toResp(ng models.NodePool, includeServers bool) nodePoolResponse {
|
func toResp(ng models.NodePool, includeServers bool) nodePoolResponse {
|
||||||
resp := nodePoolResponse{
|
out := nodePoolResponse{
|
||||||
ID: ng.ID,
|
ID: ng.ID,
|
||||||
Name: ng.Name,
|
Name: ng.Name,
|
||||||
}
|
}
|
||||||
if includeServers {
|
if includeServers {
|
||||||
resp.Servers = make([]serverBrief, 0, len(ng.Servers))
|
out.Servers = make([]serverBrief, 0, len(ng.Servers))
|
||||||
for _, s := range ng.Servers {
|
for _, s := range ng.Servers {
|
||||||
resp.Servers = append(resp.Servers, serverBrief{
|
out.Servers = append(out.Servers, serverBrief{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Hostname: s.Hostname,
|
Hostname: s.Hostname,
|
||||||
IP: s.IPAddress,
|
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) {
|
func parseUUIDs(ids []string) ([]uuid.UUID, error) {
|
||||||
out := make([]uuid.UUID, 0, len(ids))
|
out := make([]uuid.UUID, 0, len(ids))
|
||||||
for _, s := range ids {
|
for _, raw := range ids {
|
||||||
u, err := uuid.Parse(strings.TrimSpace(s))
|
id, err := uuid.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, u)
|
out = append(out, id)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -50,15 +48,25 @@ func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count != int64(len(ids)) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
|
func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.DB.Model(&models.Taint{}).
|
if err := db.DB.Model(&models.Taint{}).
|
||||||
Where("organization_id = ? AND id IN ?", orgID, ids).
|
Where("organization_id = ? AND id IN ?", orgID, ids).
|
||||||
@@ -66,39 +74,7 @@ func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count != int64(len(ids)) {
|
if count != int64(len(ids)) {
|
||||||
return errors.New("some taints not in organization")
|
return errors.New("some taints do not belong to org")
|
||||||
}
|
|
||||||
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 nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package taints
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/db"
|
"github.com/glueops/autoglue/internal/db"
|
||||||
@@ -9,6 +10,19 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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 {
|
func toResp(t models.Taint, include bool) taintResponse {
|
||||||
resp := taintResponse{
|
resp := taintResponse{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/db"
|
"github.com/glueops/autoglue/internal/db"
|
||||||
@@ -15,9 +16,11 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------- Handlers ----------
|
||||||
|
|
||||||
// ListTaints godoc
|
// ListTaints godoc
|
||||||
// @Summary List node taints (org scoped)
|
// @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
|
// @Tags taints
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -40,37 +43,38 @@ func ListTaints(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
|
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
|
||||||
|
|
||||||
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
|
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 != "" {
|
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 != "" {
|
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")
|
withPools := includeNodePools(r)
|
||||||
if includePools {
|
if withPools {
|
||||||
q = q.Preload("NodePools")
|
q = q.Preload("NodePools")
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []models.Taint
|
var rows []models.Taint
|
||||||
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]taintResponse, 0, len(rows))
|
out := make([]taintResponse, 0, len(rows))
|
||||||
for _, np := range rows {
|
for _, t := range rows {
|
||||||
out = append(out, toResp(np, includePools))
|
out = append(out, toResp(t, withPools))
|
||||||
}
|
}
|
||||||
_ = response.JSON(w, http.StatusOK, out)
|
_ = response.JSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTaint godoc
|
// GetTaint godoc
|
||||||
// @Summary Get node taint by ID (org scoped)
|
// @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
|
// @Tags taints
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -94,28 +98,27 @@ func GetTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid taint id", http.StatusBadRequest)
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
|
withPools := includeNodePools(r)
|
||||||
|
|
||||||
var t models.Taint
|
var t models.Taint
|
||||||
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
|
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
|
||||||
if include {
|
if withPools {
|
||||||
q = q.Preload("NodePools")
|
q = q.Preload("NodePools")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := q.First(&t).Error; err != nil {
|
if err := q.First(&t).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
http.Error(w, "taint not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "failed to find taint", http.StatusInternalServerError)
|
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = response.JSON(w, http.StatusOK, toResp(t, include))
|
_ = response.JSON(w, http.StatusOK, toResp(t, withPools))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTaint godoc
|
// CreateTaint godoc
|
||||||
@@ -141,8 +144,20 @@ func CreateTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req createTaintRequest
|
var req createTaintRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" || req.Value == "" || req.Effect == "" {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "invalid json or missing key/value/effect", http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,33 +167,39 @@ func CreateTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: req.Value,
|
Value: req.Value,
|
||||||
Effect: req.Effect,
|
Effect: req.Effect,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.DB.Create(&t).Error; err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optional initial links
|
||||||
if len(req.NodePoolIDs) > 0 {
|
if len(req.NodePoolIDs) > 0 {
|
||||||
ids, err := parseUUIDs(req.NodePoolIDs)
|
ids, err := parseUUIDs(req.NodePoolIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid node pool IDs", http.StatusBadRequest)
|
http.Error(w, "invalid node_pool_ids", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var nps []models.NodePool
|
var pools []models.NodePool
|
||||||
if err := db.DB.Where("id in ? AND organization_id = ?", ids, ac.OrganizationID).Find(&nps).Error; err != nil {
|
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
|
||||||
http.Error(w, "node pools not found for this organization", http.StatusInternalServerError)
|
Find(&pools).Error; err != nil {
|
||||||
|
http.Error(w, "create failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.DB.Model(&t).Association("NodePools").Append(&nps); err != nil {
|
if len(pools) != len(ids) {
|
||||||
http.Error(w, "attach node pools failed", http.StatusInternalServerError)
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = response.JSON(w, http.StatusCreated, toResp(t, false))
|
_ = 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)
|
http.Error(w, "organization required", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
@@ -213,7 +233,8 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var t models.Taint
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -227,6 +248,7 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Key != nil {
|
if req.Key != nil {
|
||||||
t.Key = strings.TrimSpace(*req.Key)
|
t.Key = strings.TrimSpace(*req.Key)
|
||||||
}
|
}
|
||||||
@@ -234,7 +256,16 @@ func UpdateTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.Value = strings.TrimSpace(*req.Value)
|
t.Value = strings.TrimSpace(*req.Value)
|
||||||
}
|
}
|
||||||
if req.Effect != nil {
|
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 {
|
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)
|
http.Error(w, "organization required", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
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)
|
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.NoContent(w)
|
response.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +334,6 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "organization required", http.StatusForbidden)
|
http.Error(w, "organization required", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
|
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
@@ -312,8 +341,7 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var t models.Taint
|
var t models.Taint
|
||||||
if err := db.DB.
|
if err := db.DB.Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID).
|
||||||
Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID).
|
|
||||||
First(&t).Error; err != nil {
|
First(&t).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
@@ -323,9 +351,7 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var in struct {
|
var in addTaintToPoolRequest
|
||||||
NodePoolIDs []string `json:"node_pool_ids"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.NodePoolIDs) == 0 {
|
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)
|
http.Error(w, "invalid json or empty node_pool_ids", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -341,20 +367,43 @@ func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var pools []models.NodePool
|
// Fetch existing links to avoid duplicates
|
||||||
if err := db.DB.
|
var existing []models.NodePool
|
||||||
Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
|
if err := db.DB.Model(&t).Association("NodePools").Find(&existing); err != nil {
|
||||||
Find(&pools).Error; err != nil {
|
|
||||||
http.Error(w, "attach failed", http.StatusInternalServerError)
|
http.Error(w, "attach failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.DB.Model(&t).Association("NodePools").Append(&pools); err != nil {
|
existingIDs := make([]uuid.UUID, 0, len(existing))
|
||||||
http.Error(w, "attach failed", http.StatusInternalServerError)
|
for _, p := range existing {
|
||||||
return
|
existingIDs = append(existingIDs, p.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
|
toFetch := make([]uuid.UUID, 0, len(ids))
|
||||||
if includePools {
|
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").
|
if err := db.DB.Preload("NodePools").
|
||||||
First(&t, "id = ? AND organization_id = ?", taintID, ac.OrganizationID).Error; err != nil {
|
First(&t, "id = ? AND organization_id = ?", taintID, ac.OrganizationID).Error; err != nil {
|
||||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
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
|
// RemoveTaintFromNodePool godoc
|
||||||
@@ -426,7 +475,6 @@ func RemoveTaintFromNodePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "detach failed", http.StatusInternalServerError)
|
http.Error(w, "detach failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.NoContent(w)
|
response.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,9 +508,10 @@ func ListNodePoolsWithTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
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).
|
||||||
|
Preload("NodePools").
|
||||||
First(&t).Error; err != nil {
|
First(&t).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
@@ -472,24 +521,18 @@ func ListNodePoolsWithTaint(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build query for pools linked via join table
|
needle := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
q := db.DB.Model(&models.NodePool{}).
|
out := make([]nodePoolResponse, 0, len(t.NodePools))
|
||||||
Joins("JOIN taint_node_pools tnp ON tnp.node_pool_id = node_pools.id").
|
for _, p := range t.NodePools {
|
||||||
Where("tnp.taint_id = ? AND node_pools.organization_id = ?", taintID, ac.OrganizationID)
|
if needle != "" && !strings.Contains(strings.ToLower(p.Name), strings.ToLower(needle)) {
|
||||||
|
continue
|
||||||
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
|
}
|
||||||
q = q.Where("node_pools.name ILIKE ?", "%"+needle+"%")
|
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
|
_ = response.JSON(w, http.StatusOK, out)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ type LabelWithPools = LabelBrief & {
|
|||||||
node_groups?: { id: string; name: string }[]
|
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 = {
|
type NodePool = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -79,7 +90,7 @@ type NodePool = {
|
|||||||
|
|
||||||
const CreatePoolSchema = z.object({
|
const CreatePoolSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
|
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<typeof CreatePoolSchema>
|
type CreatePoolInput = z.input<typeof CreatePoolSchema>
|
||||||
type CreatePoolValues = z.output<typeof CreatePoolSchema>
|
type CreatePoolValues = z.output<typeof CreatePoolSchema>
|
||||||
@@ -90,15 +101,20 @@ const UpdatePoolSchema = z.object({
|
|||||||
type UpdatePoolValues = z.output<typeof UpdatePoolSchema>
|
type UpdatePoolValues = z.output<typeof UpdatePoolSchema>
|
||||||
|
|
||||||
const AttachServersSchema = z.object({
|
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<typeof AttachServersSchema>
|
type AttachServersValues = z.output<typeof AttachServersSchema>
|
||||||
|
|
||||||
const AttachLabelsSchema = z.object({
|
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<typeof AttachLabelsSchema>
|
type AttachLabelsValues = z.output<typeof AttachLabelsSchema>
|
||||||
|
|
||||||
|
const AttachTaintsSchema = z.object({
|
||||||
|
taint_ids: z.array(z.uuid()).min(1, "Pick at least one taint"),
|
||||||
|
})
|
||||||
|
type AttachTaintsValues = z.output<typeof AttachTaintsSchema>
|
||||||
|
|
||||||
/* --------------------------------- Utils --------------------------------- */
|
/* --------------------------------- Utils --------------------------------- */
|
||||||
|
|
||||||
function StatusBadge({ status }: { status?: string }) {
|
function StatusBadge({ status }: { status?: string }) {
|
||||||
@@ -133,6 +149,12 @@ function labelKV(l: LabelBrief) {
|
|||||||
return `${l.key}=${l.value}`
|
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 ---------------------------------- */
|
/* --------------------------------- Page ---------------------------------- */
|
||||||
|
|
||||||
export const NodePoolPage = () => {
|
export const NodePoolPage = () => {
|
||||||
@@ -140,9 +162,12 @@ export const NodePoolPage = () => {
|
|||||||
const [pools, setPools] = useState<NodePool[]>([])
|
const [pools, setPools] = useState<NodePool[]>([])
|
||||||
const [allServers, setAllServers] = useState<ServerBrief[]>([])
|
const [allServers, setAllServers] = useState<ServerBrief[]>([])
|
||||||
|
|
||||||
// Pull labels with include=node_pools so we can map them to pools
|
// Labels
|
||||||
const [allLabels, setAllLabels] = useState<LabelWithPools[]>([])
|
const [allLabels, setAllLabels] = useState<LabelWithPools[]>([])
|
||||||
|
|
||||||
|
// Taints
|
||||||
|
const [allTaints, setAllTaints] = useState<TaintWithPools[]>([])
|
||||||
|
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
|
|
||||||
@@ -158,20 +183,28 @@ export const NodePoolPage = () => {
|
|||||||
const [labelsLoading, setLabelsLoading] = useState(false)
|
const [labelsLoading, setLabelsLoading] = useState(false)
|
||||||
const [labelsErr, setLabelsErr] = useState<string | null>(null)
|
const [labelsErr, setLabelsErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Taints dialog state
|
||||||
|
const [manageTaintsTarget, setManageTaintsTarget] = useState<NodePool | null>(null)
|
||||||
|
const [attachedTaints, setAttachedTaints] = useState<TaintBrief[]>([])
|
||||||
|
const [taintsLoading, setTaintsLoading] = useState(false)
|
||||||
|
const [taintsErr, setTaintsErr] = useState<string | null>(null)
|
||||||
|
|
||||||
/* ------------------------------- Data Load ------------------------------ */
|
/* ------------------------------- Data Load ------------------------------ */
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const [poolsData, serversData, labelsData] = await Promise.all([
|
const [poolsData, serversData, labelsData, taintsData] = await Promise.all([
|
||||||
api.get<NodePool[]>("/api/v1/node-pools?include=servers"),
|
api.get<NodePool[]>("/api/v1/node-pools?include=servers"),
|
||||||
api.get<ServerBrief[]>("/api/v1/servers"),
|
api.get<ServerBrief[]>("/api/v1/servers"),
|
||||||
api.get<LabelWithPools[]>("/api/v1/labels?include=node_pools"),
|
api.get<LabelWithPools[]>("/api/v1/labels?include=node_pools"),
|
||||||
|
api.get<TaintWithPools[]>("/api/v1/taints?include=node_pools"),
|
||||||
])
|
])
|
||||||
setPools(poolsData || [])
|
setPools(poolsData || [])
|
||||||
setAllServers(serversData || [])
|
setAllServers(serversData || [])
|
||||||
setAllLabels(labelsData || [])
|
setAllLabels(labelsData || [])
|
||||||
|
setAllTaints(taintsData || [])
|
||||||
|
|
||||||
if (manageTarget) {
|
if (manageTarget) {
|
||||||
const refreshed = (poolsData || []).find((p) => p.id === manageTarget.id) || null
|
const refreshed = (poolsData || []).find((p) => p.id === manageTarget.id) || null
|
||||||
@@ -184,9 +217,13 @@ export const NodePoolPage = () => {
|
|||||||
if (manageLabelsTarget) {
|
if (manageLabelsTarget) {
|
||||||
await loadAttachedLabels(manageLabelsTarget.id)
|
await loadAttachedLabels(manageLabelsTarget.id)
|
||||||
}
|
}
|
||||||
|
if (manageTaintsTarget) {
|
||||||
|
await loadAttachedTaints(manageTaintsTarget.id)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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)
|
setErr(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -208,14 +245,29 @@ export const NodePoolPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAttachedTaints(poolId: string) {
|
||||||
|
setTaintsLoading(true)
|
||||||
|
setTaintsErr(null)
|
||||||
|
try {
|
||||||
|
const data = await api.get<TaintBrief[]>(`/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(() => {
|
useEffect(() => {
|
||||||
void loadAll()
|
void loadAll()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 labelsByPool = useMemo(() => {
|
||||||
const map = new Map<string, LabelBrief[]>()
|
const map = new Map<string, LabelBrief[]>()
|
||||||
for (const l of allLabels) {
|
for (const l of allLabels) {
|
||||||
@@ -228,26 +280,47 @@ export const NodePoolPage = () => {
|
|||||||
return map
|
return map
|
||||||
}, [allLabels])
|
}, [allLabels])
|
||||||
|
|
||||||
|
// poolId -> TaintBrief[]
|
||||||
|
const taintsByPool = useMemo(() => {
|
||||||
|
const map = new Map<string, TaintBrief[]>()
|
||||||
|
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 ------------------------------- */
|
/* -------------------------------- Filters ------------------------------- */
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = q.trim().toLowerCase()
|
const needle = q.trim().toLowerCase()
|
||||||
if (!needle) return pools
|
if (!needle) return pools
|
||||||
return pools.filter(
|
return pools.filter((p) => {
|
||||||
(p) =>
|
const serversMatch = (p.servers || []).some(
|
||||||
p.name.toLowerCase().includes(needle) ||
|
(s) =>
|
||||||
(p.servers || []).some(
|
(s.hostname || "").toLowerCase().includes(needle) ||
|
||||||
(s) =>
|
(s.ip || s.ip_address || "").toLowerCase().includes(needle) ||
|
||||||
(s.hostname || "").toLowerCase().includes(needle) ||
|
(s.role || "").toLowerCase().includes(needle)
|
||||||
(s.ip || s.ip_address || "").toLowerCase().includes(needle) ||
|
)
|
||||||
(s.role || "").toLowerCase().includes(needle)
|
const labelsMatch = (labelsByPool.get(p.id) || []).some(
|
||||||
) ||
|
(l) =>
|
||||||
(labelsByPool.get(p.id) || []).some(
|
l.key.toLowerCase().includes(needle) || (l.value || "").toLowerCase().includes(needle)
|
||||||
(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 ------------------------------- */
|
/* ------------------------------ Mutations ------------------------------- */
|
||||||
|
|
||||||
@@ -349,6 +422,36 @@ export const NodePoolPage = () => {
|
|||||||
await loadAll() // refresh badges in table
|
await loadAll() // refresh badges in table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach / Detach Taints
|
||||||
|
const attachTaintsForm = useForm<AttachTaintsValues>({
|
||||||
|
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 -------------------------------- */
|
/* --------------------------------- Render -------------------------------- */
|
||||||
|
|
||||||
if (loading) return <div className="p-6">Loading node pools…</div>
|
if (loading) return <div className="p-6">Loading node pools…</div>
|
||||||
@@ -365,7 +468,7 @@ export const NodePoolPage = () => {
|
|||||||
<Input
|
<Input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder="Search pools, servers, labels…"
|
placeholder="Search pools, servers, labels, taints…"
|
||||||
className="w-72 pl-8"
|
className="w-72 pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,6 +578,7 @@ export const NodePoolPage = () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((p) => {
|
{filtered.map((p) => {
|
||||||
const labels = labelsByPool.get(p.id) || []
|
const labels = labelsByPool.get(p.id) || []
|
||||||
|
const taints = taintsByPool.get(p.id) || []
|
||||||
return (
|
return (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell className="font-medium">{p.name}</TableCell>
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
@@ -537,10 +641,23 @@ export const NodePoolPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Taints placeholder */}
|
{/* Taints cell */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">Taints</div>
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" disabled>
|
{taints.slice(0, 6).map((t) => (
|
||||||
|
<Badge key={t.id} variant="outline" className="font-mono">
|
||||||
|
<Tag className="mr-1 h-3 w-3" />
|
||||||
|
{taintText(t)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{taints.length === 0 && (
|
||||||
|
<span className="text-muted-foreground">No taints</span>
|
||||||
|
)}
|
||||||
|
{taints.length > 6 && (
|
||||||
|
<span className="text-muted-foreground">+{taints.length - 6} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openManageTaints(p)}>
|
||||||
<LinkIcon className="mr-2 h-4 w-4" /> Manage Taints
|
<LinkIcon className="mr-2 h-4 w-4" /> Manage Taints
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -822,7 +939,7 @@ export const NodePoolPage = () => {
|
|||||||
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const attachedIds = new Set(attachedLabels.map((l) => l.id))
|
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)
|
(l) => !attachedIds.has(l.id)
|
||||||
)
|
)
|
||||||
if (attachable.length === 0) {
|
if (attachable.length === 0) {
|
||||||
@@ -875,6 +992,136 @@ export const NodePoolPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage taints dialog */}
|
||||||
|
<Dialog open={!!manageTaintsTarget} onOpenChange={(o) => !o && setManageTaintsTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Manage taints for <span className="font-mono">{manageTaintsTarget?.name}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Attached taints list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">Attached taints</div>
|
||||||
|
|
||||||
|
{taintsLoading ? (
|
||||||
|
<div className="text-muted-foreground rounded-md border p-3 text-sm">Loading…</div>
|
||||||
|
) : taintsErr ? (
|
||||||
|
<div className="rounded-md border p-3 text-sm text-red-500">{taintsErr}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
<TableHead>Effect</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">Detach</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{attachedTaints.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{t.key}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{t.value}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{t.effect}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => detachTaint(t.id)}
|
||||||
|
>
|
||||||
|
<UnlinkIcon className="mr-2 h-4 w-4" /> Detach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{attachedTaints.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
No taints attached yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attach taints */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Form {...attachTaintsForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={attachTaintsForm.handleSubmit(submitAttachTaints)}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={attachTaintsForm.control}
|
||||||
|
name="taint_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Attach more taints</FormLabel>
|
||||||
|
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
||||||
|
{(() => {
|
||||||
|
const attachedIds = new Set(attachedTaints.map((t) => t.id))
|
||||||
|
const attachable = (allTaints as unknown as TaintBrief[]).filter(
|
||||||
|
(t) => !attachedIds.has(t.id)
|
||||||
|
)
|
||||||
|
if (attachable.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">
|
||||||
|
No more taints available to attach
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return attachable.map((t) => {
|
||||||
|
const checked = field.value?.includes(t.id) || false
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={t.id}
|
||||||
|
className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = new Set(field.value || [])
|
||||||
|
if (v === true) next.add(t.id)
|
||||||
|
else next.delete(t.id)
|
||||||
|
field.onChange(Array.from(next))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{taintText(t)}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{truncateMiddle(t.id, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="submit" disabled={attachTaintsForm.formState.isSubmitting}>
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />{" "}
|
||||||
|
{attachTaintsForm.formState.isSubmitting ? "Attaching…" : "Attach selected"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof CreateTaintSchema>
|
||||||
|
type CreateTaintValues = z.output<typeof CreateTaintSchema>
|
||||||
|
|
||||||
|
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<typeof UpdateTaintSchema>
|
||||||
|
|
||||||
|
const AttachPoolsSchema = z.object({
|
||||||
|
node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"),
|
||||||
|
})
|
||||||
|
type AttachPoolsValues = z.output<typeof AttachPoolsSchema>
|
||||||
|
|
||||||
|
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<Taint, "key" | "value" | "effect"> }) {
|
||||||
|
const label = `${t.key}${t.value ? `=${t.value}` : ""}${t.effect ? `:${t.effect}` : ""}`
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
<Tags className="mr-1 h-3 w-3" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const TaintsPage = () => {
|
export const TaintsPage = () => {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [taints, setTaints] = useState<Taint[]>([])
|
||||||
|
const [allPools, setAllPools] = useState<NodePoolBrief[]>([])
|
||||||
|
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Taint | null>(null)
|
||||||
|
const [manageTarget, setManageTarget] = useState<Taint | null>(null)
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
setLoading(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
// include attached node pools for quick display
|
||||||
|
const [taintsData, poolsData] = await Promise.all([
|
||||||
|
api.get<Taint[]>("/api/v1/taints?include=node_groups"),
|
||||||
|
api.get<NodePoolBrief[]>("/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<void>(`/api/v1/taints/${id}`)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Create --------
|
||||||
|
const createForm = useForm<CreateTaintInput, any, CreateTaintValues>({
|
||||||
|
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<UpdateTaintValues>({
|
||||||
|
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<string, any> = {}
|
||||||
|
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<AttachPoolsValues>({
|
||||||
|
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 <div className="p-6">Loading taints…</div>
|
||||||
|
if (err) return <div className="p-6 text-red-500">{err}</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h1 className="mb-4 text-2xl font-bold">Taints</h1>
|
<h1 className="mb-4 text-2xl font-bold">Taints</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search taints or attached pools…"
|
||||||
|
className="w-72 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={loadAll}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Create Taint
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create taint</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...createForm}>
|
||||||
|
<form onSubmit={createForm.handleSubmit(submitCreate)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="dedicated" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Value (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="gpu" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="effect"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Effect</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select effect" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{EFFECTS.map((e) => (
|
||||||
|
<SelectItem key={e} value={e}>
|
||||||
|
{e}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="node_pool_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Attach to node pools (optional)</FormLabel>
|
||||||
|
<div className="max-h-56 space-y-2 overflow-auto rounded-xl border p-2">
|
||||||
|
{allPools.length === 0 && (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">
|
||||||
|
No node pools available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allPools.map((p) => {
|
||||||
|
const checked = field.value?.includes(p.id) || false
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = new Set(field.value || [])
|
||||||
|
if (v === true) next.add(p.id)
|
||||||
|
else next.delete(p.id)
|
||||||
|
field.onChange(Array.from(next))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{truncateMiddle(p.id, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createForm.formState.isSubmitting}>
|
||||||
|
{createForm.formState.isSubmitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Taint</TableHead>
|
||||||
|
<TableHead>Attached Node Pools</TableHead>
|
||||||
|
<TableHead className="w-[180px] text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TaintBadge t={t} />
|
||||||
|
<code className="text-muted-foreground text-xs">
|
||||||
|
{truncateMiddle(t.id, 6)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
{(t.node_groups || []).slice(0, 6).map((p) => (
|
||||||
|
<Badge key={p.id} variant="outline" className="gap-1">
|
||||||
|
<BadgeCheck className="h-3 w-3" />
|
||||||
|
{p.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(t.node_groups || []).length === 0 && (
|
||||||
|
<span className="text-muted-foreground">No node pools</span>
|
||||||
|
)}
|
||||||
|
{(t.node_groups || []).length > 6 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
+{(t.node_groups || []).length - 6} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openManage(t)}>
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" /> Manage node pools
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(t)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => deleteTaint(t.id)}>
|
||||||
|
Confirm delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-muted-foreground py-10 text-center">
|
||||||
|
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||||
|
No taints match your search.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit taint</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...editForm}>
|
||||||
|
<form onSubmit={editForm.handleSubmit(submitEdit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="dedicated" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Value (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="gpu" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="effect"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Effect</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select effect" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{EFFECTS.map((e) => (
|
||||||
|
<SelectItem key={e} value={e}>
|
||||||
|
{e}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={editForm.formState.isSubmitting}>
|
||||||
|
{editForm.formState.isSubmitting ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage node pools dialog */}
|
||||||
|
<Dialog open={!!manageTarget} onOpenChange={(o) => !o && setManageTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Manage pools for{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{manageTarget
|
||||||
|
? `${manageTarget.key}${manageTarget.value ? `=${manageTarget.value}` : ""}${manageTarget.effect ? `:${manageTarget.effect}` : ""}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Attached pools */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">Attached node pools</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">Detach</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(manageTarget?.node_groups || []).map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => detachPool(p.id)}>
|
||||||
|
<UnlinkIcon className="mr-2 h-4 w-4" /> Detach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(manageTarget?.node_groups || []).length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="text-muted-foreground py-8 text-center">
|
||||||
|
No node pools attached yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attach section */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Form {...attachForm}>
|
||||||
|
<form onSubmit={attachForm.handleSubmit(submitAttach)} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={attachForm.control}
|
||||||
|
name="node_pool_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Attach more node pools</FormLabel>
|
||||||
|
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
||||||
|
{attachablePools.length === 0 && (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">
|
||||||
|
No more node pools available to attach
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{attachablePools.map((p) => {
|
||||||
|
const checked = field.value?.includes(p.id) || false
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = new Set(field.value || [])
|
||||||
|
if (v === true) next.add(p.id)
|
||||||
|
else next.delete(p.id)
|
||||||
|
field.onChange(Array.from(next))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{truncateMiddle(p.id, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="submit" disabled={attachForm.formState.isSubmitting}>
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />{" "}
|
||||||
|
{attachForm.formState.isSubmitting ? "Attaching…" : "Attach selected"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user