mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
Initial Labels Page & API
This commit is contained in:
2
Makefile
2
Makefile
@@ -70,7 +70,7 @@ clean:
|
|||||||
@echo ">> Cleaning artifacts..."
|
@echo ">> Cleaning artifacts..."
|
||||||
@rm -rf $(BIN) docs/swagger.* docs/docs.go $(UI_DIR)/dist
|
@rm -rf $(BIN) docs/swagger.* docs/docs.go $(UI_DIR)/dist
|
||||||
|
|
||||||
dev: swagger ui
|
dev: swagger
|
||||||
@echo ">> Starting Vite (frontend) and Go API (backend)..."
|
@echo ">> Starting Vite (frontend) and Go API (backend)..."
|
||||||
@cd $(UI_DIR) && \
|
@cd $(UI_DIR) && \
|
||||||
( \
|
( \
|
||||||
|
|||||||
1760
docs/docs.go
1760
docs/docs.go
File diff suppressed because it is too large
Load Diff
1760
docs/swagger.json
1760
docs/swagger.json
File diff suppressed because it is too large
Load Diff
1147
docs/swagger.yaml
1147
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
12
go.mod
12
go.mod
@@ -9,7 +9,7 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
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/spf13/viper v1.20.1
|
||||||
github.com/swaggo/http-swagger/v2 v2.0.2
|
github.com/swaggo/http-swagger/v2 v2.0.2
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
@@ -23,12 +23,14 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
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/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||||
github.com/go-openapi/spec v0.20.6 // indirect
|
github.com/go-openapi/spec v0.20.6 // indirect
|
||||||
github.com/go-openapi/swag v0.19.15 // indirect
|
github.com/go-openapi/swag v0.19.15 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // 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/sagikazarmark/locafero v0.7.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.12.0 // indirect
|
github.com/spf13/afero v1.12.0 // indirect
|
||||||
github.com/spf13/cast v1.7.1 // 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/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr 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/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.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
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
27
go.sum
27
go.sum
@@ -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/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/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.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.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 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||||
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
|
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
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/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/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 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
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/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 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.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.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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
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.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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
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=
|
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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/config"
|
"github.com/glueops/autoglue/internal/config"
|
||||||
"github.com/glueops/autoglue/internal/handlers/authn"
|
"github.com/glueops/autoglue/internal/handlers/authn"
|
||||||
"github.com/glueops/autoglue/internal/handlers/health"
|
"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/orgs"
|
||||||
"github.com/glueops/autoglue/internal/handlers/servers"
|
"github.com/glueops/autoglue/internal/handlers/servers"
|
||||||
"github.com/glueops/autoglue/internal/handlers/ssh"
|
"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/middleware"
|
||||||
"github.com/glueops/autoglue/internal/ui"
|
"github.com/glueops/autoglue/internal/ui"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -80,6 +83,42 @@ func RegisterRoutes(r chi.Router) {
|
|||||||
s.Delete("/{id}", servers.DeleteServer)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ func Connect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = DB.AutoMigrate(
|
err = DB.AutoMigrate(
|
||||||
|
&models.Annotation{},
|
||||||
|
&models.Cluster{},
|
||||||
&models.Credential{},
|
&models.Credential{},
|
||||||
&models.EmailVerification{},
|
&models.EmailVerification{},
|
||||||
&models.Invitation{},
|
&models.Invitation{},
|
||||||
|
&models.Label{},
|
||||||
&models.MasterKey{},
|
&models.MasterKey{},
|
||||||
&models.Member{},
|
&models.Member{},
|
||||||
|
&models.NodePool{},
|
||||||
&models.Organization{},
|
&models.Organization{},
|
||||||
&models.OrganizationKey{},
|
&models.OrganizationKey{},
|
||||||
&models.PasswordReset{},
|
&models.PasswordReset{},
|
||||||
&models.RefreshToken{},
|
&models.RefreshToken{},
|
||||||
&models.Server{},
|
&models.Server{},
|
||||||
&models.SshKey{},
|
&models.SshKey{},
|
||||||
|
&models.Taint{},
|
||||||
&models.User{},
|
&models.User{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
13
internal/db/models/annotation.go
Normal file
13
internal/db/models/annotation.go
Normal 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
|
||||||
|
}
|
||||||
17
internal/db/models/cluster.go
Normal file
17
internal/db/models/cluster.go
Normal 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"`
|
||||||
|
}
|
||||||
13
internal/db/models/label.go
Normal file
13
internal/db/models/label.go
Normal 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
|
||||||
|
}
|
||||||
16
internal/db/models/node-pool.go
Normal file
16
internal/db/models/node-pool.go
Normal 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
|
||||||
|
}
|
||||||
@@ -13,5 +13,6 @@ type Server struct {
|
|||||||
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
|
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
|
||||||
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
|
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
|
||||||
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
|
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
|
||||||
|
NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
||||||
Timestamped
|
Timestamped
|
||||||
}
|
}
|
||||||
|
|||||||
14
internal/db/models/taint.go
Normal file
14
internal/db/models/taint.go
Normal 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
|
||||||
|
}
|
||||||
1
internal/handlers/annotations/annotations.go
Normal file
1
internal/handlers/annotations/annotations.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package annotations
|
||||||
21
internal/handlers/labels/dto.go
Normal file
21
internal/handlers/labels/dto.go
Normal 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"`
|
||||||
|
}
|
||||||
50
internal/handlers/labels/funcs.go
Normal file
50
internal/handlers/labels/funcs.go
Normal 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
|
||||||
|
}
|
||||||
180
internal/handlers/labels/labels.go
Normal file
180
internal/handlers/labels/labels.go
Normal 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))
|
||||||
|
|
||||||
|
}
|
||||||
43
internal/handlers/nodepools/dto.go
Normal file
43
internal/handlers/nodepools/dto.go
Normal 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"`
|
||||||
|
}
|
||||||
72
internal/handlers/nodepools/funcs.go
Normal file
72
internal/handlers/nodepools/funcs.go
Normal 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
|
||||||
|
}
|
||||||
626
internal/handlers/nodepools/nodepools.go
Normal file
626
internal/handlers/nodepools/nodepools.go
Normal 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)
|
||||||
|
}
|
||||||
33
internal/handlers/taints/dto.go
Normal file
33
internal/handlers/taints/dto.go
Normal 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"`
|
||||||
|
}
|
||||||
51
internal/handlers/taints/funcs.go
Normal file
51
internal/handlers/taints/funcs.go
Normal 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
|
||||||
|
}
|
||||||
425
internal/handlers/taints/taints.go
Normal file
425
internal/handlers/taints/taints.go
Normal 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
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-D2Vr0ZQJ.css
vendored
Normal file
1
internal/ui/dist/assets/index-D2Vr0ZQJ.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-eleTxiqf.js
vendored
Normal file
1
internal/ui/dist/assets/index-eleTxiqf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
internal/ui/dist/assets/radix-9eRs70j8.js
vendored
11
internal/ui/dist/assets/radix-9eRs70j8.js
vendored
File diff suppressed because one or more lines are too long
11
internal/ui/dist/assets/radix-BnAuhYuH.js
vendored
Normal file
11
internal/ui/dist/assets/radix-BnAuhYuH.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
* react-router v7.8.2
|
||||||
*
|
*
|
||||||
* Copyright (c) Remix Software Inc.
|
* Copyright (c) Remix Software Inc.
|
||||||
File diff suppressed because one or more lines are too long
12
internal/ui/dist/index.html
vendored
12
internal/ui/dist/index.html
vendored
@@ -5,12 +5,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AutoGlue</title>
|
<title>AutoGlue</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
|
<script type="module" crossorigin src="/assets/index-eleTxiqf.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-CQ4G2GmP.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-DBKlM1wc.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-BnAuhYuH.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
|
<link rel="modulepreload" crossorigin href="/assets/icons-CNkJtX2d.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D2Vr0ZQJ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import { Me } from "@/pages/auth/me.tsx"
|
|||||||
import { Register } from "@/pages/auth/register.tsx"
|
import { Register } from "@/pages/auth/register.tsx"
|
||||||
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
||||||
import { VerifyEmail } from "@/pages/auth/verify-email.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 { ServersPage } from "@/pages/core/servers-page.tsx"
|
||||||
|
import { TaintsPage } from "@/pages/core/taints-page.tsx"
|
||||||
import { Forbidden } from "@/pages/error/forbidden.tsx"
|
import { Forbidden } from "@/pages/error/forbidden.tsx"
|
||||||
import { NotFoundPage } from "@/pages/error/not-found.tsx"
|
import { NotFoundPage } from "@/pages/error/not-found.tsx"
|
||||||
import { SshKeysPage } from "@/pages/security/ssh.tsx"
|
import { SshKeysPage } from "@/pages/security/ssh.tsx"
|
||||||
@@ -20,7 +25,6 @@ import { OrgManagement } from "@/pages/settings/orgs.tsx"
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/403" element={<Forbidden />} />
|
|
||||||
<Route path="/" element={<Navigate to="/auth/login" replace />} />
|
<Route path="/" element={<Navigate to="/auth/login" replace />} />
|
||||||
{/* Public/auth branch */}
|
{/* Public/auth branch */}
|
||||||
<Route path="/auth">
|
<Route path="/auth">
|
||||||
@@ -40,13 +44,12 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/core">
|
<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="servers" element={<ServersPage />} />
|
||||||
{/*
|
|
||||||
<Route path="cluster" element={<ClusterListPage />} />
|
|
||||||
<Route path="node-pools" element={<NodePoolsPage />} />
|
|
||||||
|
|
||||||
<Route path="taints" element={<TaintsPage />} />
|
<Route path="taints" element={<TaintsPage />} />
|
||||||
*/}
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/security">
|
<Route path="/security">
|
||||||
@@ -58,10 +61,13 @@ function App() {
|
|||||||
<Route path="members" element={<MemberManagement />} />
|
<Route path="members" element={<MemberManagement />} />
|
||||||
<Route path="me" element={<Me />} />
|
<Route path="me" element={<Me />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/403" element={<Forbidden />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/403" element={<Forbidden />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const DashboardSidebar = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
|
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
|
||||||
} finally {
|
} finally {
|
||||||
if (!alive) return
|
// if (!alive) return
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -122,13 +122,15 @@ export const DashboardSidebar = () => {
|
|||||||
return filterItems(items, admin, orgAdmin)
|
return filterItems(items, admin, orgAdmin)
|
||||||
}, [me])
|
}, [me])
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6">Loading…</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarHeader className="flex items-center justify-between p-4">
|
<SidebarHeader className="flex items-center justify-between p-4">
|
||||||
<h1 className="text-xl font-bold">AutoGlue</h1>
|
<h1 className="text-xl font-bold">AutoGlue</h1>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{(loading ? items : visibleItems).map((item, i) => (
|
{visibleItems.map((item, i) => (
|
||||||
<MenuItem item={item} key={i} />
|
<MenuItem item={item} key={i} />
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ export const items = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Cluster",
|
label: "Cluster",
|
||||||
to: "/core/cluster",
|
to: "/core/clusters",
|
||||||
icon: AiOutlineCluster,
|
icon: AiOutlineCluster,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Node Pools",
|
label: "Node Pools",
|
||||||
icon: BoxesIcon,
|
icon: BoxesIcon,
|
||||||
to: "/core/node-pools",
|
to: "/core/nodepools",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Annotations",
|
label: "Annotations",
|
||||||
|
|||||||
27
ui/src/components/ui/checkbox.tsx
Normal file
27
ui/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
9
ui/src/pages/core/annotations-page.tsx
Normal file
9
ui/src/pages/core/annotations-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
ui/src/pages/core/clusters-page.tsx
Normal file
9
ui/src/pages/core/clusters-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
ui/src/pages/core/labels-page.tsx
Normal file
190
ui/src/pages/core/labels-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
604
ui/src/pages/core/nodepool-page.tsx
Normal file
604
ui/src/pages/core/nodepool-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ const CreateServerSchema = z.object({
|
|||||||
hostname: z.string().trim().max(120, "Max 120 chars").optional(),
|
hostname: z.string().trim().max(120, "Max 120 chars").optional(),
|
||||||
ip_address: z.string().trim().min(1, "IP address is required"),
|
ip_address: z.string().trim().min(1, "IP address is required"),
|
||||||
role: z.enum(ROLE_OPTIONS),
|
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"),
|
ssh_user: z.string().trim().min(1, "SSH user is required"),
|
||||||
status: z.enum(STATUS).default("pending"),
|
status: z.enum(STATUS).default("pending"),
|
||||||
})
|
})
|
||||||
@@ -158,9 +158,9 @@ export const ServersPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAll()
|
void loadAll()
|
||||||
const onStorage = (e: StorageEvent) => {
|
const onStorage = (e: StorageEvent) => {
|
||||||
if (e.key === "active_org_id") loadAll()
|
if (e.key === "active_org_id") void loadAll()
|
||||||
}
|
}
|
||||||
window.addEventListener("storage", onStorage)
|
window.addEventListener("storage", onStorage)
|
||||||
return () => window.removeEventListener("storage", onStorage)
|
return () => window.removeEventListener("storage", onStorage)
|
||||||
@@ -168,7 +168,7 @@ export const ServersPage = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAll()
|
void loadAll()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statusFilter, roleFilter])
|
}, [statusFilter, roleFilter])
|
||||||
|
|
||||||
|
|||||||
9
ui/src/pages/core/taints-page.tsx
Normal file
9
ui/src/pages/core/taints-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
ui/yarn.lock
14
ui/yarn.lock
@@ -782,6 +782,20 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-primitive" "2.1.3"
|
"@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":
|
"@radix-ui/react-collapsible@^1.1.12":
|
||||||
version "1.1.12"
|
version "1.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz#e2cc69a4490a2920f97c3c3150b0bf21281e3c49"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz#e2cc69a4490a2920f97c3c3150b0bf21281e3c49"
|
||||||
|
|||||||
Reference in New Issue
Block a user