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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user