| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- package controller
- import (
- "context"
- "fmt"
- "slices"
- "strconv"
- "time"
- "github.com/mhsanaei/3x-ui/v3/database/model"
- "github.com/mhsanaei/3x-ui/v3/web/service"
- "github.com/gin-gonic/gin"
- )
- // NodeController exposes CRUD + probe endpoints for managing remote
- // 3x-ui panels registered as nodes. All routes mount under
- // /panel/api/nodes/ via APIController.initRouter and inherit its
- // session-or-bearer auth from checkAPIAuth.
- type NodeController struct {
- nodeService service.NodeService
- }
- // NewNodeController creates the controller and wires its routes onto g.
- 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)
- // /test takes a transient payload (no DB write) so the user can
- // validate connectivity before saving the node.
- g.POST("/test", a.test)
- // /probe/:id triggers a synchronous probe of an already-saved node
- // without waiting for the next 10s heartbeat tick.
- g.POST("/probe/:id", a.probe)
- // /history/:id/:metric/:bucket returns up to 60 averaged buckets of
- // the per-node CPU or Mem time series collected by the heartbeat job.
- 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 := &model.Node{}
- if err := c.ShouldBind(n); err != nil {
- jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
- 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 := &model.Node{}
- if err := c.ShouldBind(n); err != nil {
- jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
- 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)
- }
- // setEnable accepts a JSON body { "enable": bool } so the toggle
- // switch can flip a node without sending the whole record back.
- 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)
- }
- // test runs Probe against a transient Node payload without writing to
- // the DB. Used by the form modal to validate connectivity before save.
- 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
- }
- // Reuse normalize-style defaults so the form can leave scheme/basePath
- // blank and still get a sensible probe URL. We do this by round-tripping
- // through Create's validator without the DB write — a tiny duplication
- // here vs. exposing normalize publicly.
- 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)
- }
- // probe triggers a one-off probe against a saved node and persists
- // the result so the dashboard updates immediately, without waiting
- // for the next heartbeat tick.
- 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)
- }
- // history returns averaged buckets of the per-node CPU/Mem time-series.
- // Mirrors the system-level /panel/api/server/history/:metric/:bucket
- // endpoint so the frontend can reuse the same fetch logic.
- 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 || !allowedHistoryBuckets[bucket] {
- jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
- return
- }
- jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
- }
|