Files
3x-ui/web/controller/node.go
MHSanaei 971843f669 feat(nodes): bulk panel self-update with live online indicator
Adds the ability to update node panels to the latest release from the Nodes
page: select online, enabled nodes (checkboxes) and trigger their official
self-updater, or use the per-row Update action. A node whose reported panel
version trails the latest GitHub release is flagged with an 'update available'
tag (compared via lib/panel-version, mirroring the Go isNewerVersion).

Backend: Remote.UpdatePanel calls the node's existing
POST /panel/api/server/updatePanel; NodeService.UpdatePanels fans out over the
selected ids, skipping disabled/offline nodes with a per-node reason; exposed
as POST /panel/api/nodes/updatePanel (documented in endpoints.ts + openapi.json).

The bulk request sends a JSON body, so it sets Content-Type: application/json
explicitly — axios defaults POST to form-urlencoded, which made ShouldBindJSON
fail with 'invalid character i'.

Also reuses the clients-page online cue on the Nodes page: a pulsing green dot
plus green label for an online node. The .online-dot style moved to the shared
styles/utils.css so both pages load it.

Translations for all new node keys added across every language file.
2026-06-01 07:03:06 +02:00

203 lines
4.9 KiB
Go

package controller
import (
"context"
"fmt"
"slices"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/middleware"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin"
)
type NodeController struct {
nodeService service.NodeService
}
func NewNodeController(g *gin.RouterGroup) *NodeController {
a := &NodeController{}
a.initRouter(g)
return a
}
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:id", a.get)
g.POST("/add", a.add)
g.POST("/update/:id", a.update)
g.POST("/del/:id", a.del)
g.POST("/setEnable/:id", a.setEnable)
g.POST("/test", a.test)
g.POST("/probe/:id", a.probe)
g.POST("/updatePanel", a.updatePanel)
g.GET("/history/:id/:metric/:bucket", a.history)
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return
}
jsonObj(c, nodes, nil)
}
func (a *NodeController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
jsonObj(c, n, nil)
}
func (a *NodeController) add(c *gin.Context) {
n, ok := middleware.BindAndValidate[model.Node](c)
if !ok {
return
}
if err := a.nodeService.Create(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
}
func (a *NodeController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, ok := middleware.BindAndValidate[model.Node](c)
if !ok {
return
}
if err := a.nodeService.Update(id, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
func (a *NodeController) del(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
if err := a.nodeService.Delete(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
}
func (a *NodeController) setEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
body := struct {
Enable bool `json:"enable" form:"enable"`
}{}
if err := c.ShouldBind(&body); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
func (a *NodeController) test(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, err := a.nodeService.Probe(ctx, n)
jsonObj(c, patch.ToUI(err == nil), nil)
}
func (a *NodeController) probe(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, probeErr := a.nodeService.Probe(ctx, n)
if probeErr != nil {
patch.Status = "offline"
} else {
patch.Status = "online"
}
_ = a.nodeService.UpdateHeartbeat(id, patch)
jsonObj(c, patch.ToUI(probeErr == nil), nil)
}
func (a *NodeController) updatePanel(c *gin.Context) {
var req struct {
Ids []int `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if len(req.Ids) == 0 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
return
}
results, err := a.nodeService.UpdatePanels(req.Ids)
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
}
func (a *NodeController) history(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
metric := c.Param("metric")
if !slices.Contains(service.NodeMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
}