1
0

node.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package controller
  2. import (
  3. "context"
  4. "fmt"
  5. "slices"
  6. "strconv"
  7. "time"
  8. "github.com/mhsanaei/3x-ui/v2/database/model"
  9. "github.com/mhsanaei/3x-ui/v2/web/service"
  10. "github.com/gin-gonic/gin"
  11. )
  12. // NodeController exposes CRUD + probe endpoints for managing remote
  13. // 3x-ui panels registered as nodes. All routes mount under
  14. // /panel/api/nodes/ via APIController.initRouter and inherit its
  15. // session-or-bearer auth from checkAPIAuth.
  16. type NodeController struct {
  17. nodeService service.NodeService
  18. }
  19. // NewNodeController creates the controller and wires its routes onto g.
  20. func NewNodeController(g *gin.RouterGroup) *NodeController {
  21. a := &NodeController{}
  22. a.initRouter(g)
  23. return a
  24. }
  25. func (a *NodeController) initRouter(g *gin.RouterGroup) {
  26. g.GET("/list", a.list)
  27. g.GET("/get/:id", a.get)
  28. g.POST("/add", a.add)
  29. g.POST("/update/:id", a.update)
  30. g.POST("/del/:id", a.del)
  31. g.POST("/setEnable/:id", a.setEnable)
  32. // /test takes a transient payload (no DB write) so the user can
  33. // validate connectivity before saving the node.
  34. g.POST("/test", a.test)
  35. // /probe/:id triggers a synchronous probe of an already-saved node
  36. // without waiting for the next 10s heartbeat tick.
  37. g.POST("/probe/:id", a.probe)
  38. // /history/:id/:metric/:bucket returns up to 60 averaged buckets of
  39. // the per-node CPU or Mem time series collected by the heartbeat job.
  40. g.GET("/history/:id/:metric/:bucket", a.history)
  41. }
  42. func (a *NodeController) list(c *gin.Context) {
  43. nodes, err := a.nodeService.GetAll()
  44. if err != nil {
  45. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
  46. return
  47. }
  48. jsonObj(c, nodes, nil)
  49. }
  50. func (a *NodeController) get(c *gin.Context) {
  51. id, err := strconv.Atoi(c.Param("id"))
  52. if err != nil {
  53. jsonMsg(c, I18nWeb(c, "get"), err)
  54. return
  55. }
  56. n, err := a.nodeService.GetById(id)
  57. if err != nil {
  58. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
  59. return
  60. }
  61. jsonObj(c, n, nil)
  62. }
  63. func (a *NodeController) add(c *gin.Context) {
  64. n := &model.Node{}
  65. if err := c.ShouldBind(n); err != nil {
  66. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
  67. return
  68. }
  69. if err := a.nodeService.Create(n); err != nil {
  70. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
  71. return
  72. }
  73. jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
  74. }
  75. func (a *NodeController) update(c *gin.Context) {
  76. id, err := strconv.Atoi(c.Param("id"))
  77. if err != nil {
  78. jsonMsg(c, I18nWeb(c, "get"), err)
  79. return
  80. }
  81. n := &model.Node{}
  82. if err := c.ShouldBind(n); err != nil {
  83. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
  84. return
  85. }
  86. if err := a.nodeService.Update(id, n); err != nil {
  87. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
  88. return
  89. }
  90. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
  91. }
  92. func (a *NodeController) del(c *gin.Context) {
  93. id, err := strconv.Atoi(c.Param("id"))
  94. if err != nil {
  95. jsonMsg(c, I18nWeb(c, "get"), err)
  96. return
  97. }
  98. if err := a.nodeService.Delete(id); err != nil {
  99. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
  100. return
  101. }
  102. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
  103. }
  104. // setEnable accepts a JSON body { "enable": bool } so the toggle
  105. // switch can flip a node without sending the whole record back.
  106. func (a *NodeController) setEnable(c *gin.Context) {
  107. id, err := strconv.Atoi(c.Param("id"))
  108. if err != nil {
  109. jsonMsg(c, I18nWeb(c, "get"), err)
  110. return
  111. }
  112. body := struct {
  113. Enable bool `json:"enable" form:"enable"`
  114. }{}
  115. if err := c.ShouldBind(&body); err != nil {
  116. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
  117. return
  118. }
  119. if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
  120. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
  121. return
  122. }
  123. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
  124. }
  125. // test runs Probe against a transient Node payload without writing to
  126. // the DB. Used by the form modal to validate connectivity before save.
  127. func (a *NodeController) test(c *gin.Context) {
  128. n := &model.Node{}
  129. if err := c.ShouldBind(n); err != nil {
  130. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
  131. return
  132. }
  133. // Reuse normalize-style defaults so the form can leave scheme/basePath
  134. // blank and still get a sensible probe URL. We do this by round-tripping
  135. // through Create's validator without the DB write — a tiny duplication
  136. // here vs. exposing normalize publicly.
  137. if n.Scheme == "" {
  138. n.Scheme = "https"
  139. }
  140. if n.BasePath == "" {
  141. n.BasePath = "/"
  142. }
  143. ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
  144. defer cancel()
  145. patch, err := a.nodeService.Probe(ctx, n)
  146. jsonObj(c, patch.ToUI(err == nil), nil)
  147. }
  148. // probe triggers a one-off probe against a saved node and persists
  149. // the result so the dashboard updates immediately, without waiting
  150. // for the next heartbeat tick.
  151. func (a *NodeController) probe(c *gin.Context) {
  152. id, err := strconv.Atoi(c.Param("id"))
  153. if err != nil {
  154. jsonMsg(c, I18nWeb(c, "get"), err)
  155. return
  156. }
  157. n, err := a.nodeService.GetById(id)
  158. if err != nil {
  159. jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
  160. return
  161. }
  162. ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
  163. defer cancel()
  164. patch, probeErr := a.nodeService.Probe(ctx, n)
  165. if probeErr != nil {
  166. patch.Status = "offline"
  167. } else {
  168. patch.Status = "online"
  169. }
  170. _ = a.nodeService.UpdateHeartbeat(id, patch)
  171. jsonObj(c, patch.ToUI(probeErr == nil), nil)
  172. }
  173. // history returns averaged buckets of the per-node CPU/Mem time-series.
  174. // Mirrors the system-level /panel/api/server/history/:metric/:bucket
  175. // endpoint so the frontend can reuse the same fetch logic.
  176. func (a *NodeController) history(c *gin.Context) {
  177. id, err := strconv.Atoi(c.Param("id"))
  178. if err != nil {
  179. jsonMsg(c, I18nWeb(c, "get"), err)
  180. return
  181. }
  182. metric := c.Param("metric")
  183. if !slices.Contains(service.NodeMetricKeys, metric) {
  184. jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
  185. return
  186. }
  187. bucket, err := strconv.Atoi(c.Param("bucket"))
  188. if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
  189. jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
  190. return
  191. }
  192. jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
  193. }