Initial Labels Page & API

This commit is contained in:
allanice001
2025-09-03 09:27:57 +01:00
parent 7f29580d3b
commit 26aef56d1d
46 changed files with 7286 additions and 95 deletions

View File

@@ -70,7 +70,7 @@ clean:
@echo ">> Cleaning artifacts..."
@rm -rf $(BIN) docs/swagger.* docs/docs.go $(UI_DIR)/dist
dev: swagger ui
dev: swagger
@echo ">> Starting Vite (frontend) and Go API (backend)..."
@cd $(UI_DIR) && \
( \

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

12
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.9.1
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag v1.16.6
@@ -23,12 +23,14 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -39,18 +41,20 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/tools v0.35.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

27
go.sum
View File

@@ -3,8 +3,9 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -29,8 +30,9 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -65,10 +67,11 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
@@ -80,8 +83,12 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -118,8 +125,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -159,6 +166,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -176,8 +185,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -6,9 +6,12 @@ import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/labels"
"github.com/glueops/autoglue/internal/handlers/nodepools"
"github.com/glueops/autoglue/internal/handlers/orgs"
"github.com/glueops/autoglue/internal/handlers/servers"
"github.com/glueops/autoglue/internal/handlers/ssh"
"github.com/glueops/autoglue/internal/handlers/taints"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
"github.com/go-chi/chi/v5"
@@ -80,6 +83,42 @@ func RegisterRoutes(r chi.Router) {
s.Delete("/{id}", servers.DeleteServer)
})
v1.Route("/node-pools", func(np chi.Router) {
np.Use(authMW)
np.Get("/", nodepools.ListNodePools)
np.Post("/", nodepools.CreateNodePool)
np.Get("/{id}", nodepools.GetNodePool)
np.Patch("/{id}", nodepools.UpdateNodePool)
np.Delete("/{id}", nodepools.DeleteNodePool)
// servers
np.Get("/{id}/servers", nodepools.ListNodePoolServers)
np.Post("/{id}/servers", nodepools.AttachNodePoolServers)
np.Delete("/{id}/servers/{serverId}", nodepools.DetachNodePoolServer)
// taints
np.Get("/{id}/taints", nodepools.ListNodePoolTaints)
np.Post("/{id}/taints", nodepools.AttachNodePoolTaints)
np.Delete("/{id}/taints/{taintId}", nodepools.DetachNodePoolTaint)
})
v1.Route("/taints", func(t chi.Router) {
t.Use(authMW)
t.Get("/", taints.ListTaints)
t.Post("/", taints.CreateTaint)
t.Get("/{id}", taints.GetTaint)
t.Patch("/{id}", taints.UpdateTaint)
t.Delete("/{id}", taints.DeleteTaint)
t.Post("/{id}/node_pools", taints.AddTaintToNodePool)
t.Delete("/{id}/node_pools/{poolId}", taints.RemoveTaintFromNodePool)
})
v1.Route("/labels", func(l chi.Router) {
l.Use(authMW)
l.Get("/", labels.ListLabels)
l.Post("/", labels.CreateLabel)
l.Get("/{id}", labels.GetLabel)
})
})
})

View File

@@ -26,17 +26,22 @@ func Connect() {
}
err = DB.AutoMigrate(
&models.Annotation{},
&models.Cluster{},
&models.Credential{},
&models.EmailVerification{},
&models.Invitation{},
&models.Label{},
&models.MasterKey{},
&models.Member{},
&models.NodePool{},
&models.Organization{},
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.Server{},
&models.SshKey{},
&models.Taint{},
&models.User{},
)
if err != nil {

View File

@@ -0,0 +1,13 @@
package models
import "github.com/google/uuid"
type Annotation struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,17 @@
package models
import "github.com/google/uuid"
type Cluster struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `json:"status"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}

View File

@@ -0,0 +1,13 @@
package models
import "github.com/google/uuid"
type Label struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,16 @@
package models
import "github.com/google/uuid"
type NodePool struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
Timestamped
}

View File

@@ -13,5 +13,6 @@ type Server struct {
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,14 @@
package models
import "github.com/google/uuid"
type Taint struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
Effect string `gorm:"not null" json:"effect"`
NodePools []NodePool `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1 @@
package annotations

View File

@@ -0,0 +1,21 @@
package labels
import "github.com/google/uuid"
type labelResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
NodeGroups []nodePoolBrief `json:"node_groups,omitempty"`
}
type nodePoolBrief struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type createLabelRequest struct {
Key string `json:"key"`
Value string `json:"value"`
NodePoolIDs []string `json:"node_pool_ids,omitempty"`
}

View File

@@ -0,0 +1,50 @@
package labels
import (
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(l models.Label, include bool) labelResponse {
resp := labelResponse{
ID: l.ID,
Key: l.Key,
Value: l.Value,
}
if include {
resp.NodeGroups = make([]nodePoolBrief, 0, len(l.NodePools))
for _, np := range l.NodePools {
resp.NodeGroups = append(resp.NodeGroups, nodePoolBrief{ID: np.ID, Name: np.Name})
}
}
return resp
}
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))
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
var count int64
if err := db.DB.Model(&models.NodePool{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return fmt.Errorf("some node groups do not belong to this organization")
}
return nil
}

View File

@@ -0,0 +1,180 @@
package labels
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListLabels godoc
// @Summary List node labels (org scoped)
// @Description Returns node labels for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node groups.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param name query string false "Exact name"
// @Param value query string false "Exact value"
// @Param q query string false "Name contains (case-insensitive)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {array} labelResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /api/v1/labels [get]
func ListLabels(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where("name ILIKE ?", "%"+needle+"%")
}
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
if includePools {
q.Preload("NodePools")
}
var rows []models.Label
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list taints", http.StatusInternalServerError)
return
}
out := make([]labelResponse, 0, len(rows))
for _, np := range rows {
out = append(out, toResp(np, includePools))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetLabel godoc
// @Summary Get label by ID (org scoped)
// @Description Returns one label. Add `include=node_pools` to include node groups.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} labelResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/labels/{id} [get]
func GetLabel(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid label id", http.StatusBadRequest)
return
}
include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
var l models.Label
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if include {
q = q.Preload("NodePools")
}
if err := q.First(&l).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "label not found", http.StatusNotFound)
return
}
http.Error(w, "failed to find label", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(l, include))
}
// CreateLabel godoc
// @Summary Create label (org scoped)
// @Description Creates a label. Optionally link to node pools.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createLabelRequest true "Label payload"
// @Security BearerAuth
// @Success 201 {object} labelResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/labels [post]
func CreateLabel(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createLabelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" || req.Value == "" {
http.Error(w, "invalid json or missing key/value", http.StatusBadRequest)
return
}
t := models.Label{
OrganizationID: ac.OrganizationID,
Key: req.Key,
Value: req.Value,
}
if err := db.DB.Create(&t).Error; err != nil {
http.Error(w, "failed to create label", http.StatusInternalServerError)
return
}
if len(req.NodePoolIDs) > 0 {
ids, err := parseUUIDs(req.NodePoolIDs)
if err != nil {
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)
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)
return
}
if err := db.DB.Model(&t).Association("NodePools").Append(&nps); err != nil {
http.Error(w, "attach node pools failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusCreated, toResp(t, false))
}

View File

@@ -0,0 +1,43 @@
package nodepools
import (
"github.com/google/uuid"
)
type nodePoolResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Servers []serverBrief `json:"servers,omitempty"`
}
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"`
}

View File

@@ -0,0 +1,72 @@
package nodepools
import (
"errors"
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(ng models.NodePool, includeServers bool) nodePoolResponse {
resp := nodePoolResponse{
ID: ng.ID,
Name: ng.Name,
}
if includeServers {
resp.Servers = make([]serverBrief, 0, len(ng.Servers))
for _, s := range ng.Servers {
resp.Servers = append(resp.Servers, serverBrief{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IPAddress,
Role: s.Role,
Status: s.Status,
})
}
}
return resp
}
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))
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
var count int64
if err := db.DB.Model(&models.Server{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return fmt.Errorf("some servers do not belong to this organization")
}
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).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some taints not in organization")
}
return nil
}

View File

@@ -0,0 +1,626 @@
package nodepools
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListNodePools godoc
// @Summary List node pools (org scoped)
// @Description Returns node pools for the organization in X-Org-ID. Add `include=servers` to include attached servers. Filter by `q` (name contains).
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Param include query string false "Optional: servers"
// @Security BearerAuth
// @Success 200 {array} nodePoolResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node groups"
// @Router /api/v1/node-pools [get]
func ListNodePools(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where("name ILIKE ?", "%"+needle+"%")
}
includeServers := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "servers")
if includeServers {
q = q.Preload("Servers")
}
var rows []models.NodePool
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list node groups", http.StatusInternalServerError)
return
}
out := make([]nodePoolResponse, 0, len(rows))
for _, ng := range rows {
out = append(out, toResp(ng, includeServers))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetNodePool godoc
// @Summary Get node group by ID (org scoped)
// @Description Returns one node group. Add `include=servers` to include servers.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Param include query string false "Optional: servers"
// @Security BearerAuth
// @Success 200 {object} nodePoolResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/node-pools/{id} [get]
func GetNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
includeServers := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "servers")
var ng models.NodePool
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if includeServers {
q = q.Preload("Servers")
}
if err := q.First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(ng, includeServers))
}
// CreateNodePool godoc
// @Summary Create node group (org scoped)
// @Description Creates a node group. Optionally attach initial servers.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createNodePoolRequest true "NodeGroup payload"
// @Security BearerAuth
// @Success 201 {object} nodePoolResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/node-pools [post]
func CreateNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
http.Error(w, "invalid json or missing name", http.StatusBadRequest)
return
}
ng := models.NodePool{
OrganizationID: ac.OrganizationID,
Name: strings.TrimSpace(req.Name),
}
if err := db.DB.Create(&ng).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
// attach servers if provided
if len(req.ServerIDs) > 0 {
ids, err := parseUUIDs(req.ServerIDs)
if err != nil {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
if err := ensureServersBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid server_ids for this organization", http.StatusBadRequest)
return
}
var servers []models.Server
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&servers).Error; err != nil {
http.Error(w, "attach servers failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Servers").Append(&servers); err != nil {
http.Error(w, "attach servers failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusCreated, toResp(ng, false))
}
// UpdateNodePool godoc
// @Summary Update node pool (org scoped)
// @Description Partially update node pool fields.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body updateNodePoolRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} nodePoolResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /api/v1/node-pools/{id} [patch]
func UpdateNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Name != nil {
ng.Name = strings.TrimSpace(*req.Name)
}
if err := db.DB.Save(&ng).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(ng, false))
}
// DeleteNodePool godoc
// @Summary Delete node pool (org scoped)
// @Description Permanently deletes the node pool.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/node-pools/{id} [delete]
func DeleteNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
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.NodePool{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListNodePoolServers godoc
// @Summary List servers attached to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Security BearerAuth
// @Success 200 {array} serverBrief
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/node-pools/{id}/servers [get]
func ListNodePoolServers(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Preload("Servers").First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
out := make([]serverBrief, 0, len(ng.Servers))
for _, s := range ng.Servers {
out = append(out, serverBrief{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IPAddress,
Role: s.Role,
Status: s.Status,
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// AttachNodePoolServers godoc
// @Summary Attach servers to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Param body body attachServersRequest true "Server IDs to attach"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/node-pools/{id}/servers [post]
func AttachNodePoolServers(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var body struct {
ServerIDs []string `json:"server_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.ServerIDs) == 0 {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
ids, err := parseUUIDs(body.ServerIDs)
if err != nil {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
if err := ensureServersBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid server_ids for this organization", http.StatusBadRequest)
return
}
var servers []models.Server
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&servers).Error; err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Servers").Append(&servers); err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DetachNodePoolServer godoc
// @Summary Detach one server from a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param serverId path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /api/v1/node-pools/{id}/servers/{serverId} [delete]
func DetachNodePoolServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
sid, err := uuid.Parse(chi.URLParam(r, "serverId"))
if err != nil {
http.Error(w, "invalid serverId", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
Preload("Servers").First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", sid, ac.OrganizationID).First(&s).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := db.DB.Model(&ng).Association("Servers").Delete(&s); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// ListNodePoolTaints godoc
// @Summary List taints attached to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Security BearerAuth
// @Success 200 {array} taintBrief
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/node-pools/{id}/taints [get]
func ListNodePoolTaints(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
Preload("Taints").
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
out := make([]taintBrief, 0, len(ng.Taints))
for _, t := range ng.Taints {
out = append(out, taintBrief{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// AttachNodePoolTaints godoc
// @Summary Attach taints to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body attachTaintsRequest true "Taint IDs to attach"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid taint_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/node-pools/{id}/taints [post]
func AttachNodePoolTaints(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var body struct {
TaintIDs []string `json:"taint_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.TaintIDs) == 0 {
http.Error(w, "invalid taint_ids", http.StatusBadRequest)
return
}
ids, err := parseUUIDs(body.TaintIDs)
if err != nil {
http.Error(w, "invalid taint_ids", http.StatusBadRequest)
return
}
if err := ensureTaintsBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid taint_ids for this organization", http.StatusBadRequest)
return
}
var taints []models.Taint
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&taints).Error; err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Taints").Append(&taints); err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DetachNodePoolTaint godoc
// @Summary Detach one taint from a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param taintId path string true "Taint ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /api/v1/node-pools/{id}/taints/{taintId} [delete]
func DetachNodePoolTaint(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
tid, err := uuid.Parse(chi.URLParam(r, "taintId"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var t models.Taint
if err := db.DB.Where("id = ? AND organization_id = ?", tid, ac.OrganizationID).
First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Taints").Delete(&t); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -0,0 +1,33 @@
package taints
import "github.com/google/uuid"
type taintResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
NodeGroups []nodePoolBrief `json:"node_groups,omitempty"`
}
type nodePoolBrief struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type createTaintRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
NodePoolIDs []string `json:"node_pool_ids,omitempty"`
}
type updateTaintRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
Effect *string `json:"effect,omitempty"`
}
type addTaintToPoolRequest struct {
NodePoolIDs []string `json:"node_pool_ids"`
}

View File

@@ -0,0 +1,51 @@
package taints
import (
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(t models.Taint, include bool) taintResponse {
resp := taintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
}
if include {
resp.NodeGroups = make([]nodePoolBrief, 0, len(t.NodePools))
for _, np := range t.NodePools {
resp.NodeGroups = append(resp.NodeGroups, nodePoolBrief{ID: np.ID, Name: np.Name})
}
}
return resp
}
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))
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
var count int64
if err := db.DB.Model(&models.NodePool{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return fmt.Errorf("some node groups do not belong to this organization")
}
return nil
}

View File

@@ -0,0 +1,425 @@
package taints
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListTaints godoc
// @Summary List node taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_groups` to include linked node groups.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param name query string false "Exact name"
// @Param value query string false "Exact value"
// @Param q query string false "Name contains (case-insensitive)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {array} taintResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /api/v1/taints [get]
func ListTaints(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where("name ILIKE ?", "%"+needle+"%")
}
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
if includePools {
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)
return
}
out := make([]taintResponse, 0, len(rows))
for _, np := range rows {
out = append(out, toResp(np, includePools))
}
_ = 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.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/taints/{id} [get]
func GetTaint(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid taint id", http.StatusBadRequest)
return
}
include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
var t models.Taint
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if include {
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)
return
}
http.Error(w, "failed to find taint", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(t, include))
}
// CreateTaint godoc
// @Summary Create node taint (org scoped)
// @Description Creates a taint. Optionally link to node pools.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createTaintRequest true "Taint payload"
// @Security BearerAuth
// @Success 201 {object} taintResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/taints [post]
func CreateTaint(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
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)
return
}
t := models.Taint{
OrganizationID: ac.OrganizationID,
Key: req.Key,
Value: req.Value,
Effect: req.Effect,
}
if err := db.DB.Create(&t).Error; err != nil {
http.Error(w, "failed to create taint", http.StatusInternalServerError)
return
}
if len(req.NodePoolIDs) > 0 {
ids, err := parseUUIDs(req.NodePoolIDs)
if err != nil {
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)
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)
return
}
if err := db.DB.Model(&t).Association("NodePools").Append(&nps); err != nil {
http.Error(w, "attach node pools failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusCreated, toResp(t, false))
}
// UpdateTaint godoc
// @Summary Update node taint (org scoped)
// @Description Partially update taint fields.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param body body updateTaintRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /api/v1/taints/{id} [patch]
func UpdateTaint(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t models.Taint
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
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Key != nil {
t.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
t.Value = strings.TrimSpace(*req.Value)
}
if req.Effect != nil {
t.Effect = strings.TrimSpace(*req.Effect)
}
if err := db.DB.Save(&t).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(t, false))
}
// DeleteTaint godoc
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/taints/{id} [delete]
func DeleteTaint(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
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 {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// AddTaintToNodePool godoc
// @Summary Attach taint to node pools (org scoped)
// @Description Links the taint to one or more node pools in the same organization.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Taint ID (UUID)"
// @Param body body addTaintToPoolRequest true "IDs to attach"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/taints/{id}/node_pools [post]
func AddTaintToNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t models.Taint
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)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var in struct {
NodePoolIDs []string `json:"node_pool_ids"`
}
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
}
ids, err := parseUUIDs(in.NodePoolIDs)
if err != nil {
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)
return
}
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, "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
}
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
if includePools {
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)
return
}
}
_ = response.JSON(w, http.StatusOK, toResp(t, includePools))
}
// RemoveTaintFromNodePool godoc
// @Summary Detach taint from a node pool (org scoped)
// @Description Unlinks the taint from the specified node pool.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Taint ID (UUID)"
// @Param poolId path string true "Node Pool ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /api/v1/taints/{id}/node_pools/{poolId} [delete]
func RemoveTaintFromNodePool(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
poolID, err := uuid.Parse(chi.URLParam(r, "poolId"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t models.Taint
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)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var p models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID).
First(&p).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&t).Association("NodePools").Delete(&p); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{r as i}from"./vendor-D1z0LlOQ.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
import{r as i}from"./vendor-DBKlM1wc.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
* react-router v7.8.2
*
* Copyright (c) Remix Software Inc.

File diff suppressed because one or more lines are too long

View File

@@ -5,12 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
<script type="module" crossorigin src="/assets/index-eleTxiqf.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CQ4G2GmP.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-DBKlM1wc.js">
<link rel="modulepreload" crossorigin href="/assets/radix-BnAuhYuH.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CNkJtX2d.js">
<link rel="stylesheet" crossorigin href="/assets/index-D2Vr0ZQJ.css">
</head>
<body>
<div id="root"></div>

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",

View File

@@ -10,7 +10,12 @@ import { Me } from "@/pages/auth/me.tsx"
import { Register } from "@/pages/auth/register.tsx"
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
import { VerifyEmail } from "@/pages/auth/verify-email.tsx"
import { AnnotationsPage } from "@/pages/core/annotations-page.tsx"
import { ClustersPage } from "@/pages/core/clusters-page.tsx"
import { LabelsPage } from "@/pages/core/labels-page.tsx"
import { NodePoolPage } from "@/pages/core/nodepool-page.tsx"
import { ServersPage } from "@/pages/core/servers-page.tsx"
import { TaintsPage } from "@/pages/core/taints-page.tsx"
import { Forbidden } from "@/pages/error/forbidden.tsx"
import { NotFoundPage } from "@/pages/error/not-found.tsx"
import { SshKeysPage } from "@/pages/security/ssh.tsx"
@@ -20,7 +25,6 @@ import { OrgManagement } from "@/pages/settings/orgs.tsx"
function App() {
return (
<Routes>
<Route path="/403" element={<Forbidden />} />
<Route path="/" element={<Navigate to="/auth/login" replace />} />
{/* Public/auth branch */}
<Route path="/auth">
@@ -40,13 +44,12 @@ function App() {
</Route>
<Route path="/core">
<Route path="annotations" element={<AnnotationsPage />} />
<Route path="clusters" element={<ClustersPage />} />
<Route path="labels" element={<LabelsPage />} />
<Route path="nodepools" element={<NodePoolPage />} />
<Route path="servers" element={<ServersPage />} />
{/*
<Route path="cluster" element={<ClusterListPage />} />
<Route path="node-pools" element={<NodePoolsPage />} />
<Route path="taints" element={<TaintsPage />} />
*/}
</Route>
<Route path="/security">
@@ -58,10 +61,13 @@ function App() {
<Route path="members" element={<MemberManagement />} />
<Route path="me" element={<Me />} />
</Route>
<Route path="/403" element={<Forbidden />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
<Route path="/403" element={<Forbidden />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
)

View File

@@ -107,7 +107,7 @@ export const DashboardSidebar = () => {
} catch {
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
} finally {
if (!alive) return
// if (!alive) return
setLoading(false)
}
})()
@@ -122,13 +122,15 @@ export const DashboardSidebar = () => {
return filterItems(items, admin, orgAdmin)
}, [me])
if (loading) return <div className="p-6">Loading</div>
return (
<Sidebar>
<SidebarHeader className="flex items-center justify-between p-4">
<h1 className="text-xl font-bold">AutoGlue</h1>
</SidebarHeader>
<SidebarContent>
{(loading ? items : visibleItems).map((item, i) => (
{visibleItems.map((item, i) => (
<MenuItem item={item} key={i} />
))}
</SidebarContent>

View File

@@ -40,13 +40,13 @@ export const items = [
items: [
{
label: "Cluster",
to: "/core/cluster",
to: "/core/clusters",
icon: AiOutlineCluster,
},
{
label: "Node Pools",
icon: BoxesIcon,
to: "/core/node-pools",
to: "/core/nodepools",
},
{
label: "Annotations",

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,9 @@
export const AnnotationsPage = () => {
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Annotations</h1>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export const ClustersPage = () => {
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Clusters</h1>
</div>
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilIcon, Plus, TrashIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { api } from "@/lib/api.ts"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
type Label = {
id: string
key: string
value: string
}
const CreateLabelSchema = z.object({
key: z.string().min(2),
value: z.string().min(2),
})
type CreateLabelValues = z.infer<typeof CreateLabelSchema>
export const LabelsPage = () => {
const [labels, setLabels] = useState<Label[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [err, setErr] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
async function loadAll() {
setLoading(true)
setErr(null)
try {
const labelData = await api.get<Label[]>("/api/v1/labels")
console.log(JSON.stringify(labelData))
setLabels(labelData)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
}, [])
const createForm = useForm<CreateLabelValues>({
resolver: zodResolver(CreateLabelSchema),
defaultValues: {
key: "",
value: "",
},
})
const submitCreate = async (values: CreateLabelValues) => {
const payload: Record<string, any> = {
key: values.key,
value: values.value,
}
await api.post<Label>("/api/v1/labels", payload)
setCreateOpen(false)
createForm.reset()
await loadAll()
}
if (loading) return <div className="p-6">Loading servers</div>
if (err) return <div className="p-6 text-red-500">{err}</div>
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Labels</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Label
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Label</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="app.kubernetes.io/managed-by" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value</FormLabel>
<FormControl>
<Input placeholder="GlueOps" {...field} />
</FormControl>
<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 className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Values</TableHead>
<TableHead className="w-[180px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{labels.map((l) => (
<TableRow key={l.id}>
<TableCell>{l.key}</TableCell>
<TableCell>{l.value}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm">
<PencilIcon className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="destructive" size="sm">
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,604 @@
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import {
LinkIcon,
Pencil,
Plus,
RefreshCw,
Search,
ServerIcon,
Trash,
UnlinkIcon,
} from "lucide-react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { api, ApiError } from "@/lib/api.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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
type ServerBrief = {
id: string
hostname?: string | null
ip?: string
ip_address?: string
role?: string
status?: string
}
type NodePool = {
id: string
name: string
servers?: ServerBrief[]
}
const CreatePoolSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
server_ids: z.array(z.uuid()).optional().default([]),
})
type CreatePoolInput = z.input<typeof CreatePoolSchema>
type CreatePoolValues = z.output<typeof CreatePoolSchema>
const UpdatePoolSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
})
type UpdatePoolValues = z.output<typeof UpdatePoolSchema>
const AttachServersSchema = z.object({
server_ids: z.array(z.uuid()).min(1, "Pick at least one server"),
})
type AttachServersValues = z.output<typeof AttachServersSchema>
function StatusBadge({ status }: { status?: string }) {
const v =
status === "ready"
? "default"
: status === "provisioning"
? "secondary"
: status === "failed"
? "destructive"
: "outline"
return (
<Badge variant={v as any} className="capitalize">
{status || "unknown"}
</Badge>
)
}
function truncateMiddle(str: string, keep = 12) {
if (!str || str.length <= keep * 2 + 3) return str
return `${str.slice(0, keep)}${str.slice(-keep)}`
}
function serverLabel(s: ServerBrief) {
const ip = s.ip || s.ip_address
const name = s.hostname || ip || s.id
const role = s.role ? ` · ${s.role}` : ""
return `${name}${role}`
}
export const NodePoolPage = () => {
const [loading, setLoading] = useState<boolean>(true)
const [pools, setPools] = useState<NodePool[]>([])
const [allServers, setAllServers] = useState<ServerBrief[]>([])
const [err, setErr] = useState<string | null>(null)
const [q, setQ] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<NodePool | null>(null)
const [manageTarget, setManageTarget] = useState<NodePool | null>(null)
async function loadAll() {
setLoading(true)
setErr(null)
try {
const [poolsData, serversData] = await Promise.all([
api.get<NodePool[]>("/api/v1/node-pools?include=servers"),
api.get<ServerBrief[]>("/api/v1/servers"),
])
setPools(poolsData || [])
setAllServers(serversData || [])
if (manageTarget) {
const refreshed = (poolsData || []).find((p) => p.id === manageTarget.id) || null
setManageTarget(refreshed)
}
if (editTarget) {
const refreshed = (poolsData || []).find((p) => p.id === editTarget.id) || null
setEditTarget(refreshed)
}
} catch (e) {
console.error(e)
const msg = e instanceof ApiError ? e.message : "Failed to load node pools or servers"
setErr(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
}, [])
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)
)
)
}, [pools, q])
async function deletePool(id: string) {
if (!confirm("Delete this node pool? This cannot be undone.")) return
await api.delete<void>(`/api/v1/node-pools/${id}`)
await loadAll()
}
const createForm = useForm<CreatePoolInput, any, CreatePoolValues>({
resolver: zodResolver(CreatePoolSchema),
defaultValues: { name: "", server_ids: [] },
})
const submitCreate = async (values: CreatePoolValues) => {
const payload: any = { name: values.name.trim() }
if (values.server_ids && values.server_ids.length > 0) {
payload.server_ids = values.server_ids
}
await api.post("/api/v1/node-pools", payload)
setCreateOpen(false)
createForm.reset({ name: "", server_ids: [] })
await loadAll()
}
const editForm = useForm<UpdatePoolValues>({
resolver: zodResolver(UpdatePoolSchema),
defaultValues: { name: "" },
})
function openEdit(p: NodePool) {
setEditTarget(p)
editForm.reset({ name: p.name })
}
const submitEdit = async (values: UpdatePoolValues) => {
if (!editTarget) return
await api.patch(`/api/v1/node-pools/${editTarget.id}`, { name: values.name.trim() })
setEditTarget(null)
await loadAll()
}
const attachForm = useForm<AttachServersValues>({
resolver: zodResolver(AttachServersSchema),
defaultValues: { server_ids: [] },
})
function openManage(p: NodePool) {
setManageTarget(p)
attachForm.reset({ server_ids: [] })
}
const submitAttach = async (values: AttachServersValues) => {
if (!manageTarget) return
await api.post(`/api/v1/node-pools/${manageTarget.id}/servers`, {
server_ids: values.server_ids,
})
attachForm.reset({ server_ids: [] })
await loadAll()
}
async function detachServer(serverId: string) {
if (!manageTarget) return
if (!confirm("Detach this server from the pool?")) return
await api.delete(`/api/v1/node-pools/${manageTarget.id}/servers/${serverId}`)
await loadAll()
}
const attachableServers = useMemo(() => {
if (!manageTarget) return [] as ServerBrief[]
const attachedIds = new Set((manageTarget.servers || []).map((s) => s.id))
return allServers.filter((s) => !attachedIds.has(s.id))
}, [manageTarget, allServers])
if (loading) return <div className="p-6">Loading node pools</div>
if (err) return <div className="p-6 text-red-500">{err}</div>
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Node Pools</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 pools or servers…"
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 Pool
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create node pool</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(submitCreate)} className="space-y-4">
<FormField
control={createForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="pool-workers-a" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="server_ids"
render={({ field }) => (
<FormItem>
<FormLabel>Initial servers (optional)</FormLabel>
<div className="max-h-56 space-y-2 overflow-auto rounded-xl border p-2">
{allServers.length === 0 && (
<div className="text-muted-foreground p-2 text-sm">
No servers available
</div>
)}
{allServers.map((s) => {
const checked = field.value?.includes(s.id) || false
return (
<label
key={s.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(s.id)
else next.delete(s.id)
field.onChange(Array.from(next))
}}
/>
<div className="leading-tight">
<div className="text-sm font-medium">{serverLabel(s)}</div>
<div className="text-muted-foreground text-xs">
{truncateMiddle(s.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 className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Servers</TableHead>
<TableHead>Annotations</TableHead>
<TableHead>Labels</TableHead>
<TableHead>Taints</TableHead>
<TableHead className="w-[180px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{(p.servers || []).slice(0, 6).map((s) => (
<Badge key={s.id} variant="secondary" className="gap-1">
<ServerIcon className="h-3 w-3" />{" "}
{s.hostname || s.ip || s.ip_address || truncateMiddle(s.id, 6)}
{s.status && (
<span className="ml-1">
<StatusBadge status={s.status} />
</span>
)}
</Badge>
))}
{(p.servers || []).length === 0 && (
<span className="text-muted-foreground">No servers</span>
)}
{(p.servers || []).length > 6 && (
<span className="text-muted-foreground">
+{(p.servers || []).length - 6} more
</span>
)}
</div>
<Button variant="outline" size="sm" onClick={() => openManage(p)}>
<LinkIcon className="mr-2 h-4 w-4" /> Manage servers
</Button>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">Annotations</div>
<Button variant="outline" size="sm">
<LinkIcon className="mr-2 h-4 w-4" /> Manage Annotations
</Button>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">Labels</div>
<Button variant="outline" size="sm">
<LinkIcon className="mr-2 h-4 w-4" /> Manage Labels
</Button>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">Taints</div>
<Button variant="outline" size="sm">
<LinkIcon className="mr-2 h-4 w-4" /> Manage Taints
</Button>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(p)}>
<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={() => deletePool(p.id)}>
Confirm delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground py-10 text-center">
No node pools 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 node pool</DialogTitle>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(submitEdit)} className="space-y-4">
<FormField
control={editForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="pool-workers-a" {...field} />
</FormControl>
<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 servers dialog */}
<Dialog open={!!manageTarget} onOpenChange={(o) => !o && setManageTarget(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Manage servers for <span className="font-mono">{manageTarget?.name}</span>
</DialogTitle>
</DialogHeader>
{/* Attached servers list */}
<div className="space-y-3">
<div className="text-sm font-medium">Attached servers</div>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Server</TableHead>
<TableHead>IP</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[120px] text-right">Detach</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(manageTarget?.servers || []).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
{s.hostname || truncateMiddle(s.id, 8)}
</TableCell>
<TableCell>
<code className="font-mono text-sm">{s.ip || s.ip_address || "—"}</code>
</TableCell>
<TableCell className="capitalize">{s.role || "—"}</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell>
<div className="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => detachServer(s.id)}
>
<UnlinkIcon className="mr-2 h-4 w-4" /> Detach
</Button>
</div>
</TableCell>
</TableRow>
))}
{(manageTarget?.servers || []).length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground py-8 text-center">
No servers 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="server_ids"
render={({ field }) => (
<FormItem>
<FormLabel>Attach more servers</FormLabel>
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
{attachableServers.length === 0 && (
<div className="text-muted-foreground p-2 text-sm">
No more servers available to attach
</div>
)}
{attachableServers.map((s) => {
const checked = field.value?.includes(s.id) || false
return (
<label
key={s.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(s.id)
else next.delete(s.id)
field.onChange(Array.from(next))
}}
/>
<div className="leading-tight">
<div className="text-sm font-medium">{serverLabel(s)}</div>
<div className="text-muted-foreground text-xs">
{truncateMiddle(s.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>
)
}

View File

@@ -86,7 +86,7 @@ const CreateServerSchema = z.object({
hostname: z.string().trim().max(120, "Max 120 chars").optional(),
ip_address: z.string().trim().min(1, "IP address is required"),
role: z.enum(ROLE_OPTIONS),
ssh_key_id: z.string().uuid("Pick a valid SSH key"),
ssh_key_id: z.uuid("Pick a valid SSH key"),
ssh_user: z.string().trim().min(1, "SSH user is required"),
status: z.enum(STATUS).default("pending"),
})
@@ -158,9 +158,9 @@ export const ServersPage = () => {
}
useEffect(() => {
loadAll()
void loadAll()
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") loadAll()
if (e.key === "active_org_id") void loadAll()
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
@@ -168,7 +168,7 @@ export const ServersPage = () => {
}, [])
useEffect(() => {
loadAll()
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, roleFilter])

View File

@@ -0,0 +1,9 @@
export const TaintsPage = () => {
return (
<div className="space-y-4 p-6">
<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>
</div>
</div>
)
}

View File

@@ -782,6 +782,20 @@
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-checkbox@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz#db45ca8a6d5c056a92f74edbb564acee05318b79"
integrity sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-collapsible@^1.1.12":
version "1.1.12"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz#e2cc69a4490a2920f97c3c3150b0bf21281e3c49"