From a17589841386a1ffbb534bd720cf538a463a4c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?= Date: Sun, 16 Aug 2026 22:57:44 +0800 Subject: [PATCH] feat(cluster): remote panel registry API and health check --- backend/internal/cluster/api.go | 109 ++++++++++++++++++++ backend/internal/cluster/service.go | 150 ++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 backend/internal/cluster/api.go create mode 100644 backend/internal/cluster/service.go diff --git a/backend/internal/cluster/api.go b/backend/internal/cluster/api.go new file mode 100644 index 0000000..de1c28e --- /dev/null +++ b/backend/internal/cluster/api.go @@ -0,0 +1,109 @@ +package cluster + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("", h.List) + rg.POST("", h.Create) + rg.PUT("/:id", h.Update) + rg.DELETE("/:id", h.Delete) + rg.POST("/:id/health", h.Health) +} + +func parseID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return 0, false + } + return uint(id), true +} + +func (h *Handler) List(c *gin.Context) { + rows, err := h.svc.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rows) +} + +func (h *Handler) Create(c *gin.Context) { + var in CreateInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + row, err := h.svc.Create(in) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, row) +} + +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var in UpdateInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + row, err := h.svc.Update(id, in) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, row) +} + +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.svc.Delete(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *Handler) Health(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + row, err := h.svc.HealthCheck(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, row) +} diff --git a/backend/internal/cluster/service.go b/backend/internal/cluster/service.go new file mode 100644 index 0000000..6ee6b79 --- /dev/null +++ b/backend/internal/cluster/service.go @@ -0,0 +1,150 @@ +package cluster + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + httpClient *http.Client +} + +func NewService(db *gorm.DB) *Service { + return &Service{ + db: db, + httpClient: &http.Client{ + Timeout: 8 * time.Second, + }, + } +} + +type CreateInput struct { + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + APIToken string `json:"api_token"` + Enabled *bool `json:"enabled"` + Remark string `json:"remark"` +} + +type UpdateInput struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + APIToken string `json:"api_token"` + Enabled *bool `json:"enabled"` + Remark string `json:"remark"` + KeepToken bool `json:"keep_token"` +} + +func (s *Service) List() ([]models.RemoteServer, error) { + var rows []models.RemoteServer + if err := s.db.Order("id desc").Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + rows[i].APITokenSet = rows[i].APIToken != "" + rows[i].APIToken = "" + } + return rows, nil +} + +func (s *Service) Create(in CreateInput) (*models.RemoteServer, error) { + name := strings.TrimSpace(in.Name) + base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/") + if name == "" || base == "" { + return nil, fmt.Errorf("name and base_url are required") + } + enabled := true + if in.Enabled != nil { + enabled = *in.Enabled + } + row := &models.RemoteServer{ + Name: name, + BaseURL: base, + APIToken: strings.TrimSpace(in.APIToken), + Enabled: enabled, + Remark: strings.TrimSpace(in.Remark), + } + if err := s.db.Create(row).Error; err != nil { + return nil, err + } + row.APITokenSet = row.APIToken != "" + row.APIToken = "" + return row, nil +} + +func (s *Service) Update(id uint, in UpdateInput) (*models.RemoteServer, error) { + var row models.RemoteServer + if err := s.db.First(&row, id).Error; err != nil { + return nil, err + } + if strings.TrimSpace(in.Name) != "" { + row.Name = strings.TrimSpace(in.Name) + } + if strings.TrimSpace(in.BaseURL) != "" { + row.BaseURL = strings.TrimRight(strings.TrimSpace(in.BaseURL), "/") + } + if !in.KeepToken && strings.TrimSpace(in.APIToken) != "" { + row.APIToken = strings.TrimSpace(in.APIToken) + } + if in.Enabled != nil { + row.Enabled = *in.Enabled + } + row.Remark = strings.TrimSpace(in.Remark) + if err := s.db.Save(&row).Error; err != nil { + return nil, err + } + row.APITokenSet = row.APIToken != "" + row.APIToken = "" + return &row, nil +} + +func (s *Service) Delete(id uint) error { + return s.db.Delete(&models.RemoteServer{}, id).Error +} + +// HealthCheck probes the remote panel health endpoint. +func (s *Service) HealthCheck(id uint) (*models.RemoteServer, error) { + var row models.RemoteServer + if err := s.db.First(&row, id).Error; err != nil { + return nil, err + } + url := row.BaseURL + "/api/v1/health" + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if row.APIToken != "" { + req.Header.Set("Authorization", "Bearer "+row.APIToken) + } + now := time.Now().UTC() + row.LastCheckAt = &now + resp, err := s.httpClient.Do(req) + if err != nil { + row.LastStatus = "down" + row.LastError = err.Error() + _ = s.db.Save(&row) + row.APITokenSet = row.APIToken != "" + row.APIToken = "" + return &row, nil + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + row.LastStatus = "up" + row.LastError = "" + } else { + row.LastStatus = "error" + row.LastError = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + _ = s.db.Save(&row) + row.APITokenSet = row.APIToken != "" + row.APIToken = "" + return &row, nil +}