feat(nodes): traffic-writer queue, full-mirror sync, WS event fixes

- Traffic-writer single-consumer queue (web/service/traffic_writer.go)
  serialises every DB write that touches up/down/all_time/last_online
  (AddTraffic, SetRemoteTraffic, Reset*, UpdateClientTrafficByEmail) so
  overlapping goroutines can no longer clobber each other's column-scoped
  Updates with a stale tx.Save.

- DB pool: WAL + busy_timeout=10s + synchronous=NORMAL + _txlock=
  immediate, MaxOpenConns=8 / MaxIdleConns=4. The immediate-tx PRAGMA
  fixes residual "database is locked [0ms]" cases where deferred-tx
  writer-upgrade conflicts bypass busy_timeout.

- SetRemoteTraffic full-mirrors node-authoritative state into central:
  settings JSON, remark, listen, port, total, expiry, all_time, enable,
  plus per-client total/expiry/reset/all_time. Inbounds and
  client_traffics rows present on node but missing from central are
  created; rows missing from snap are deleted (with cascading
  client_traffics removal).

- NodeTrafficSyncJob detects structural changes from the mirror and
  broadcasts invalidate(inbounds) so open central UIs re-fetch via REST
  on node-side add/del/edit without manual refresh.

- XrayTrafficJob broadcasts invalidate(inbounds) when auto-disable flips
  client_traffics.enable so the per-client toggle reflects depletion
  without manual refresh.

- Frontend: inbounds page now subscribes to the BroadcastInbounds 'inbounds'
  WS event (full-list pushes from add/del/update controllers were silently
  dropped). Fixes invalidate payload field (dataType -> type). Restart-
  panel modal switched from Promise-wrap to onOk-only so Cancel actually
  cancels.

- Node files trimmed of stale prose-comments; cron cadence dropped
  10s -> 5s to match the inbounds page UX.

- README badges and Go module path bumped v2 -> v3 to match module rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-10 16:25:23 +02:00
parent 24cd271486
commit 8e7d215b4a
25 changed files with 559 additions and 639 deletions
-25
View File
@@ -13,15 +13,10 @@ import (
"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)
@@ -37,14 +32,8 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
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)
}
@@ -115,8 +104,6 @@ func (a *NodeController) del(c *gin.Context) {
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 {
@@ -137,18 +124,12 @@ func (a *NodeController) setEnable(c *gin.Context) {
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"
}
@@ -162,9 +143,6 @@ func (a *NodeController) test(c *gin.Context) {
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 {
@@ -188,9 +166,6 @@ func (a *NodeController) probe(c *gin.Context) {
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 {