Files
kazeyukiro 01670760f8 fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
fix(cluster): surface remote errors, JWT login refresh; rollback MUI-ish theme
- Revert Material-inspired Ant Design tokens to previous defaults
- Cluster: return real upstream error text (not generic only)
- Cluster: POST /cluster/:id/login exchanges remote admin JWT and saves token
- Cluster UI: Login action, token/error columns, distinct action icons
- Nav icons: unique icon per menu item (sidebar + mobile)
2026-09-18 23:40:00 +08:00

391 lines
8.8 KiB
Go

package cluster
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"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.POST("/health-all", h.HealthAll)
rg.GET("/mirrored-nodes", h.ListMirroredNodes)
rg.PUT("/:id", h.Update)
rg.DELETE("/:id", h.Delete)
rg.POST("/:id/health", h.Health)
rg.POST("/:id/login", h.LoginRemote)
rg.GET("/:id/dashboard", h.RemoteDashboard)
rg.GET("/:id/users", h.RemoteUsers)
rg.GET("/:id/nodes", h.RemoteNodes)
rg.POST("/:id/sync-nodes", h.SyncNodes)
rg.POST("/:id/push-node", h.PushNode)
rg.POST("/:id/nodes", h.RemoteCreateNode)
rg.PUT("/:id/nodes/:nodeId", h.RemoteUpdateNode)
rg.DELETE("/:id/nodes/:nodeId", h.RemoteDeleteNode)
rg.POST("/:id/mihomo/start", h.RemoteStart)
rg.POST("/:id/mihomo/stop", h.RemoteStop)
rg.POST("/:id/mihomo/restart", h.RemoteRestart)
rg.POST("/:id/proxy", h.Proxy)
}
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 {
log.Printf("cluster list failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server 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
}
log.Printf("cluster health check failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, row)
}
func (h *Handler) HealthAll(c *gin.Context) {
rows, err := h.svc.HealthCheckAll()
if err != nil {
log.Printf("cluster health-check-all failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, rows)
}
func (h *Handler) RemoteNodes(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchRemoteNodes(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteDashboard(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchDashboard(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteUsers(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.FetchUsers(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteCreateNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status, raw, err := h.svc.ProxyRemote(id, http.MethodPost, "/api/v1/nodes", body)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteUpdateNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
nodeID := c.Param("nodeId")
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status, raw, err := h.svc.ProxyRemote(id, http.MethodPut, "/api/v1/nodes/"+nodeID, body)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteDeleteNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
nodeID := c.Param("nodeId")
status, raw, err := h.svc.ProxyRemote(id, http.MethodDelete, "/api/v1/nodes/"+nodeID, nil)
if err != nil {
writeRemoteErr(c, err)
return
}
if len(raw) == 0 {
c.JSON(status, gin.H{"status": "ok"})
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteStart(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.StartCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteStop(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.StopCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
func (h *Handler) RemoteRestart(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
raw, err := h.svc.RestartCore(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", raw)
}
// Proxy is a restricted generic forwarder (allowlisted paths only).
func (h *Handler) Proxy(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "method and path required"})
return
}
method := body.Method
if method == "" {
method = http.MethodGet
}
var payload []byte
if body.Body != "" {
payload = []byte(body.Body)
}
status, raw, err := h.svc.ProxyRemote(id, method, body.Path, payload)
if err != nil {
writeRemoteErr(c, err)
return
}
c.Data(status, "application/json; charset=utf-8", raw)
}
func (h *Handler) LoginRemote(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required"})
return
}
row, err := h.svc.LoginRemote(id, body.Username, body.Password)
if err != nil {
writeRemoteErr(c, err)
return
}
c.JSON(http.StatusOK, row)
}
func writeRemoteErr(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
log.Printf("cluster remote operation failed: %v", err)
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "upstream cluster operation failed"
}
if len(msg) > 800 {
msg = msg[:800]
}
// Keep a stable prefix so operators/search still find the generic case,
// but include the real cause (auth, HTTP status, dial error).
if !strings.Contains(strings.ToLower(msg), "upstream") {
msg = "upstream cluster operation failed: " + msg
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
}
func (h *Handler) SyncNodes(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
rows, err := h.svc.SyncRemoteNodes(id)
if err != nil {
writeRemoteErr(c, err)
return
}
c.JSON(http.StatusOK, rows)
}
func (h *Handler) ListMirroredNodes(c *gin.Context) {
var remoteID uint
if v := c.Query("remote_server_id"); v != "" {
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
remoteID = uint(n)
}
}
rows, err := h.svc.ListMirroredNodes(remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, rows)
}
func (h *Handler) PushNode(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var in PushNodeInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := h.svc.PushLocalNodeToRemote(id, in)
if err != nil {
writeRemoteErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}