Pārlūkot izejas kodu

feat(wip): node selector supports sse

Jacky 5 mēneši atpakaļ
vecāks
revīzija
bc70567dc1

+ 6 - 106
api/api.go

@@ -1,15 +1,10 @@
 package api
 
 import (
-	"errors"
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/gin-gonic/gin"
-	"github.com/go-playground/validator/v10"
 	"github.com/uozi-tech/cosy/logger"
 	"net/http"
-	"reflect"
-	"regexp"
-	"strings"
 )
 
 func CurrentUser(c *gin.Context) *model.User {
@@ -23,105 +18,10 @@ func ErrHandler(c *gin.Context, err error) {
 	})
 }
 
-type ValidError struct {
-	Key     string
-	Message string
-}
-
-func BindAndValid(c *gin.Context, target interface{}) bool {
-	err := c.ShouldBindJSON(target)
-	if err != nil {
-		logger.Error("bind err", err)
-
-		var verrs validator.ValidationErrors
-		ok := errors.As(err, &verrs)
-
-		if !ok {
-			c.JSON(http.StatusNotAcceptable, gin.H{
-				"message": "Requested with wrong parameters",
-				"code":    http.StatusNotAcceptable,
-			})
-			return false
-		}
-
-		t := reflect.TypeOf(target).Elem()
-		errorsMap := make(map[string]interface{})
-		for _, value := range verrs {
-			var path []string
-
-			namespace := strings.Split(value.StructNamespace(), ".")
-			// logger.Debug(t.Name(), namespace)
-			if t.Name() != "" && len(namespace) > 1 {
-				namespace = namespace[1:]
-			}
-
-			getJsonPath(t, namespace, &path)
-			insertError(errorsMap, path, value.Tag())
-		}
-
-		c.JSON(http.StatusNotAcceptable, gin.H{
-			"errors":  errorsMap,
-			"message": "Requested with wrong parameters",
-			"code":    http.StatusNotAcceptable,
-		})
-
-		return false
-	}
-
-	return true
-}
-
-// findField recursively finds the field in a nested struct
-func getJsonPath(t reflect.Type, fields []string, path *[]string) {
-	field := fields[0]
-	// used in case of array
-	var index string
-	if field[len(field)-1] == ']' {
-		re := regexp.MustCompile(`(\w+)\[(\d+)\]`)
-		matches := re.FindStringSubmatch(field)
-
-		if len(matches) > 2 {
-			field = matches[1]
-			index = matches[2]
-		}
-	}
-
-	f, ok := t.FieldByName(field)
-	if !ok {
-		return
-	}
-
-	*path = append(*path, f.Tag.Get("json"))
-
-	if index != "" {
-		*path = append(*path, index)
-	}
-
-	if len(fields) > 1 {
-		subFields := fields[1:]
-		getJsonPath(f.Type, subFields, path)
-	}
-}
-
-// insertError inserts an error into the errors map
-func insertError(errorsMap map[string]interface{}, path []string, errorTag string) {
-	if len(path) == 0 {
-		return
-	}
-
-	jsonTag := path[0]
-	if len(path) == 1 {
-		// Last element in the path, set the error
-		errorsMap[jsonTag] = errorTag
-		return
-	}
-
-	// Create a new map if necessary
-	if _, ok := errorsMap[jsonTag]; !ok {
-		errorsMap[jsonTag] = make(map[string]interface{})
-	}
-
-	// Recursively insert into the nested map
-	subMap, _ := errorsMap[jsonTag].(map[string]interface{})
-	insertError(subMap, path[1:], errorTag)
+func SetSSEHeaders(c *gin.Context) {
+	c.Header("Content-Type", "text/event-stream")
+	c.Header("Cache-Control", "no-cache")
+	c.Header("Connection", "keep-alive")
+	// https://stackoverflow.com/questions/27898622/server-sent-events-stopped-work-after-enabling-ssl-on-proxy/27960243#27960243
+	c.Header("X-Accel-Buffering", "no")
 }

+ 3 - 3
api/certificate/certificate.go

@@ -97,7 +97,7 @@ type certJson struct {
 func AddCert(c *gin.Context) {
 	var json certJson
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -145,7 +145,7 @@ func ModifyCert(c *gin.Context) {
 
 	var json certJson
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -202,7 +202,7 @@ func RemoveCert(c *gin.Context) {
 func SyncCertificate(c *gin.Context) {
 	var json cert.SyncCertificatePayload
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 2
api/certificate/dns_credential.go

@@ -47,7 +47,7 @@ type DnsCredentialManageJson struct {
 
 func AddDnsCredential(c *gin.Context) {
 	var json DnsCredentialManageJson
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -73,7 +73,7 @@ func EditDnsCredential(c *gin.Context) {
 	id := cast.ToUint64(c.Param("id"))
 
 	var json DnsCredentialManageJson
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 84 - 0
api/cluster/environment.go

@@ -1,6 +1,9 @@
 package cluster
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/analytic"
 	"github.com/0xJacky/Nginx-UI/internal/cluster"
@@ -11,7 +14,9 @@ import (
 	"github.com/spf13/cast"
 	"github.com/uozi-tech/cosy"
 	"gorm.io/gorm"
+	"io"
 	"net/http"
+	"time"
 )
 
 func GetEnvironment(c *gin.Context) {
@@ -44,6 +49,85 @@ func GetEnvironmentList(c *gin.Context) {
 	}).PagingList()
 }
 
+func GetAllEnabledEnvironment(c *gin.Context) {
+	api.SetSSEHeaders(c)
+	notify := c.Writer.CloseNotify()
+
+	interval := 10
+
+	type respEnvironment struct {
+		*model.Environment
+		Status bool `json:"status"`
+	}
+
+	f := func() (any, bool) {
+		return cosy.Core[model.Environment](c).
+			SetFussy("name").
+			SetTransformer(func(m *model.Environment) any {
+				resp := respEnvironment{
+					Environment: m,
+					Status:      analytic.GetNode(m).Status,
+				}
+				return resp
+			}).ListAllData()
+	}
+
+	getHash := func(data any) string {
+		bytes, _ := json.Marshal(data)
+		hash := sha256.New()
+		hash.Write(bytes)
+		hashSum := hash.Sum(nil)
+		return hex.EncodeToString(hashSum)
+	}
+
+	dataHash := ""
+
+	{
+		data, ok := f()
+		if !ok {
+			return
+		}
+
+		c.Stream(func(w io.Writer) bool {
+			c.SSEvent("message", data)
+			dataHash = getHash(data)
+			return false
+		})
+	}
+
+	for {
+		select {
+		case <-time.After(time.Duration(interval) * time.Second):
+			data, ok := f()
+			if !ok {
+				return
+			}
+			// if data is not changed, send heartbeat
+			if dataHash == getHash(data) {
+				c.Stream(func(w io.Writer) bool {
+					c.SSEvent("heartbeat", "")
+					return false
+				})
+				return
+			}
+
+			dataHash = getHash(data)
+
+			c.Stream(func(w io.Writer) bool {
+				c.SSEvent("message", data)
+				return false
+			})
+		case <-time.After(30 * time.Second):
+			c.Stream(func(w io.Writer) bool {
+				c.SSEvent("heartbeat", "")
+				return false
+			})
+		case <-notify:
+			return
+		}
+	}
+}
+
 func AddEnvironment(c *gin.Context) {
 	cosy.Core[model.Environment](c).SetValidRules(gin.H{
 		"name":    "required",

+ 1 - 0
api/cluster/router.go

@@ -5,6 +5,7 @@ import "github.com/gin-gonic/gin"
 func InitRouter(r *gin.RouterGroup) {
 	// Environment
 	r.GET("environments", GetEnvironmentList)
+	r.GET("environments/enabled", GetAllEnabledEnvironment)
 	r.POST("environments/load_from_settings", LoadEnvironmentFromSettings)
 	envGroup := r.Group("environments")
 	{

+ 2 - 1
api/config/add.go

@@ -9,6 +9,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -24,7 +25,7 @@ func AddConfig(c *gin.Context) {
 		SyncNodeIds []uint64 `json:"sync_node_ids"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/config/mkdir.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"os"
 )
@@ -14,7 +15,7 @@ func Mkdir(c *gin.Context) {
 		BasePath   string `json:"base_path"`
 		FolderName string `json:"folder_name"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 	fullPath := nginx.GetConfPath(json.BasePath, json.FolderName)

+ 2 - 1
api/config/modify.go

@@ -9,6 +9,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -26,7 +27,7 @@ func EditConfig(c *gin.Context) {
 		SyncOverwrite bool     `json:"sync_overwrite"`
 		SyncNodeIds   []uint64 `json:"sync_node_ids"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 3 - 2
api/config/rename.go

@@ -8,6 +8,8 @@ import (
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
+	"github.com/uozi-tech/cosy/logger"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -21,8 +23,7 @@ func Rename(c *gin.Context) {
 		NewName     string   `json:"new_name"`
 		SyncNodeIds []uint64 `json:"sync_node_ids" gorm:"serializer:json"`
 	}
-
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 4 - 3
api/nginx/nginx.go

@@ -4,12 +4,13 @@ import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
 func BuildNginxConfig(c *gin.Context) {
 	var ngxConf nginx.NgxConfig
-	if !api.BindAndValid(c, &ngxConf) {
+	if !cosy.BindAndValid(c, &ngxConf) {
 		return
 	}
 	content, err := ngxConf.BuildConfig()
@@ -27,7 +28,7 @@ func TokenizeNginxConfig(c *gin.Context) {
 		Content string `json:"content" binding:"required"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -45,7 +46,7 @@ func FormatNginxConfig(c *gin.Context) {
 		Content string `json:"content" binding:"required"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 	content, err := nginx.FmtCode(json.Content)

+ 2 - 2
api/nginx/nginx_log.go

@@ -3,7 +3,6 @@ package nginx
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/cache"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
@@ -13,6 +12,7 @@ import (
 	"github.com/hpcloud/tail"
 	"github.com/pkg/errors"
 	"github.com/spf13/cast"
+	"github.com/uozi-tech/cosy"
 	"github.com/uozi-tech/cosy/logger"
 	"io"
 	"net/http"
@@ -44,7 +44,7 @@ func GetNginxLogPage(c *gin.Context) {
 	}
 
 	var control controlStruct
-	if !api.BindAndValid(c, &control) {
+	if !cosy.BindAndValid(c, &control) {
 		return
 	}
 

+ 2 - 5
api/notification/live.go

@@ -1,6 +1,7 @@
 package notification
 
 import (
+	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/notification"
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/gin-gonic/gin"
@@ -9,11 +10,7 @@ import (
 )
 
 func Live(c *gin.Context) {
-	c.Header("Content-Type", "text/event-stream")
-	c.Header("Cache-Control", "no-cache")
-	c.Header("Connection", "keep-alive")
-	// https://stackoverflow.com/questions/27898622/server-sent-events-stopped-work-after-enabling-ssl-on-proxy/27960243#27960243
-	c.Header("X-Accel-Buffering", "no")
+	api.SetSSEHeaders(c)
 
 	evtChan := make(chan *model.Notification)
 

+ 2 - 2
api/openai/openai.go

@@ -3,13 +3,13 @@ package openai
 import (
 	"context"
 	"fmt"
-	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/chatbot"
 	"github.com/0xJacky/Nginx-UI/internal/transport"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"io"
 	"net/http"
 )
@@ -26,7 +26,7 @@ func MakeChatCompletionRequest(c *gin.Context) {
 		Messages []openai.ChatCompletionMessage `json:"messages"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/openai/store.go

@@ -6,6 +6,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -15,7 +16,7 @@ func StoreChatGPTRecord(c *gin.Context) {
 		Messages []openai.ChatCompletionMessage `json:"messages"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 4 - 4
api/settings/auth.go

@@ -1,10 +1,10 @@
 package settings
 
 import (
-	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"time"
 )
@@ -19,7 +19,7 @@ func GetBanLoginIP(c *gin.Context) {
 		b.ExpiredAt.Gte(time.Now().Unix()),
 		b.Attempts.Gte(settings.AuthSettings.MaxAttempts)).Find()
 	if err != nil {
-		api.ErrHandler(c, err)
+		cosy.ErrHandler(c, err)
 		return
 	}
 	c.JSON(http.StatusOK, banIps)
@@ -29,7 +29,7 @@ func RemoveBannedIP(c *gin.Context) {
 	var json struct {
 		IP string `json:"ip"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -37,7 +37,7 @@ func RemoveBannedIP(c *gin.Context) {
 	_, err := b.Where(b.IP.Eq(json.IP)).Delete()
 
 	if err != nil {
-		api.ErrHandler(c, err)
+		cosy.ErrHandler(c, err)
 		return
 	}
 

+ 2 - 1
api/settings/settings.go

@@ -7,6 +7,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	cSettings "github.com/uozi-tech/cosy/settings"
 	"net/http"
 )
@@ -68,7 +69,7 @@ func SaveSettings(c *gin.Context) {
 		Logrotate settings.Logrotate `json:"logrotate"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/sites/advance.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -13,7 +14,7 @@ func DomainEditByAdvancedMode(c *gin.Context) {
 		Advanced bool `json:"advanced"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/sites/auto_cert.go

@@ -6,6 +6,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/gin-gonic/gin"
 	"github.com/go-acme/lego/v4/certcrypto"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -19,7 +20,7 @@ func AddDomainToAutoCert(c *gin.Context) {
 		KeyType         certcrypto.KeyType `json:"key_type"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/sites/duplicate.go

@@ -4,6 +4,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/site"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -16,7 +17,7 @@ func DuplicateSite(c *gin.Context) {
 		Name string `json:"name" binding:"required"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 1 - 1
api/sites/site.go

@@ -120,7 +120,7 @@ func SaveSite(c *gin.Context) {
 		Overwrite      bool     `json:"overwrite"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/streams/advance.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -13,7 +14,7 @@ func AdvancedEdit(c *gin.Context) {
 		Advanced bool `json:"advanced"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/streams/duplicate.go

@@ -5,6 +5,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 )
 
@@ -17,7 +18,7 @@ func Duplicate(c *gin.Context) {
 		Name string `json:"name" binding:"required"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/streams/streams.go

@@ -8,6 +8,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"os"
 	"strings"
@@ -174,7 +175,7 @@ func SaveStream(c *gin.Context) {
 		Overwrite bool   `json:"overwrite"`
 	}
 
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 

+ 2 - 1
api/system/install.go

@@ -8,6 +8,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/google/uuid"
+	"github.com/uozi-tech/cosy"
 	cSettings "github.com/uozi-tech/cosy/settings"
 	"golang.org/x/crypto/bcrypt"
 	"net/http"
@@ -39,7 +40,7 @@ func InstallNginxUI(c *gin.Context) {
 		return
 	}
 	var json InstallJson
-	ok := api.BindAndValid(c, &json)
+	ok := cosy.BindAndValid(c, &json)
 	if !ok {
 		return
 	}

+ 2 - 1
api/user/2fa.go

@@ -12,6 +12,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/go-webauthn/webauthn/webauthn"
 	"github.com/google/uuid"
+	"github.com/uozi-tech/cosy"
 	"net/http"
 	"strings"
 	"time"
@@ -71,7 +72,7 @@ func Start2FASecureSessionByOTP(c *gin.Context) {
 		OTP          string `json:"otp"`
 		RecoveryCode string `json:"recovery_code"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 	u := api.CurrentUser(c)

+ 2 - 1
api/user/auth.go

@@ -7,6 +7,7 @@ import (
 	"github.com/0xJacky/Nginx-UI/settings"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
+	"github.com/uozi-tech/cosy"
 	"github.com/uozi-tech/cosy/logger"
 	"math/rand/v2"
 	"net/http"
@@ -61,7 +62,7 @@ func Login(c *gin.Context) {
 	}
 
 	var json LoginUser
-	ok := api.BindAndValid(c, &json)
+	ok := cosy.BindAndValid(c, &json)
 	if !ok {
 		return
 	}

+ 2 - 1
api/user/casdoor.go

@@ -8,6 +8,7 @@ import (
 	"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
+	"github.com/uozi-tech/cosy"
 	"gorm.io/gorm"
 	"net/http"
 	"net/url"
@@ -22,7 +23,7 @@ type CasdoorLoginUser struct {
 func CasdoorCallback(c *gin.Context) {
 	var loginUser CasdoorLoginUser
 
-	ok := api.BindAndValid(c, &loginUser)
+	ok := cosy.BindAndValid(c, &loginUser)
 	if !ok {
 		return
 	}

+ 3 - 2
api/user/otp.go

@@ -13,6 +13,7 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/pquerna/otp"
 	"github.com/pquerna/otp/totp"
+	"github.com/uozi-tech/cosy"
 	"image/jpeg"
 	"net/http"
 	"strings"
@@ -81,7 +82,7 @@ func EnrollTOTP(c *gin.Context) {
 		Secret   string `json:"secret" binding:"required"`
 		Passcode string `json:"passcode" binding:"required"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 
@@ -117,7 +118,7 @@ func ResetOTP(c *gin.Context) {
 	var json struct {
 		RecoveryCode string `json:"recovery_code"`
 	}
-	if !api.BindAndValid(c, &json) {
+	if !cosy.BindAndValid(c, &json) {
 		return
 	}
 	recoverCode, err := hex.DecodeString(json.RecoveryCode)

+ 2 - 2
api/user/user.go

@@ -38,7 +38,7 @@ type UserJson struct {
 
 func AddUser(c *gin.Context) {
 	var json UserJson
-	ok := api.BindAndValid(c, &json)
+	ok := cosy.BindAndValid(c, &json)
 	if !ok {
 		return
 	}
@@ -79,7 +79,7 @@ func EditUser(c *gin.Context) {
 	}
 
 	var json UserJson
-	ok := api.BindAndValid(c, &json)
+	ok := cosy.BindAndValid(c, &json)
 	if !ok {
 		return
 	}

+ 26 - 16
app/src/components/NodeSelector/NodeSelector.vue

@@ -1,7 +1,8 @@
 <script setup lang="ts">
 import type { Environment } from '@/api/environment'
 import type { Ref } from 'vue'
-import environment from '@/api/environment'
+import { useUserStore } from '@/pinia'
+import { SSE, type SSEvent } from 'sse.js'
 
 const props = defineProps<{
   hiddenLocal?: boolean
@@ -9,26 +10,35 @@ const props = defineProps<{
 
 const target = defineModel<number[]>('target')
 const map = defineModel<Record<number, string>>('map')
+const { token } = storeToRefs(useUserStore())
 
 const data = ref([]) as Ref<Environment[]>
 const data_map = ref({}) as Ref<Record<number, Environment>>
 
-onMounted(async () => {
-  let hasMore = true
-  let page = 1
-  while (hasMore) {
-    await environment.get_list({ page, enabled: true }).then(r => {
-      data.value.push(...r.data)
-      r.data?.forEach(node => {
-        data_map.value[node.id] = node
-      })
-      hasMore = r.data.length === r.pagination?.per_page
-      page++
-    }).catch(() => {
-      hasMore = false
-    })
+const sse = shallowRef(newSSE())
+
+function reconnect() {
+  setTimeout(() => {
+    sse.value = newSSE()
+  }, 5000)
+}
+
+function newSSE() {
+  const s = new SSE('/api/environments/enabled', {
+    headers: {
+      Authorization: token.value,
+    },
+  })
+
+  s.onmessage = (e: SSEvent) => {
+    data.value = JSON.parse(e.data)
   }
-})
+
+  // reconnect
+  s.onerror = reconnect
+
+  return s
+}
 
 const value = computed({
   get() {

+ 1 - 1
app/src/views/environment/Environment.vue

@@ -35,7 +35,7 @@ function batchUpgrade() {
       :api="environment"
       :columns="envColumns"
     >
-      <template #extra>
+      <template #beforeAdd>
         <a @click="loadFromSettings">{{ $gettext('Load from settings') }}</a>
       </template>
     </StdCurd>