feat: apply inbound/outbound/routing changes live via Xray gRPC API

Add a hot-apply layer that computes a diff between the old and new
generated config and applies only the changed parts through the Xray
gRPC HandlerService and RoutingService, avoiding a full process restart
whenever possible. A restart is still performed when sections that have
no reload API (log, dns, policy, observatory, ...) actually change.

Key additions:
- internal/xray/hot_diff.go: ComputeHotDiff with canonical-JSON
  comparison (sorted keys, null=absent, full number precision) so UI
  reformatting never triggers a spurious restart
- internal/xray/api.go: AddOutbound/DelOutbound, ApplyRoutingConfig,
  GetBalancerInfo, SetBalancerTarget, TestRoute gRPC wrappers
- internal/web/service/xray.go: tryHotApply, ensureAPIServices,
  GetBalancersStatus, OverrideBalancer, TestRoute service methods
- internal/web/controller/xray_setting.go: balancerStatus,
  balancerOverride, routeTest API endpoints
- frontend: BalancersTab live-status/override columns, RouteTester
  component, Restart button removed (Save now hot-applies)
- balancer-helpers.ts: syncObservatories never creates observatory
  sections for random/roundRobin balancers (no reload API → restart)
- i18n: balancerLive/Override/routeTester keys added to all 13 locales
This commit is contained in:
MHSanaei
2026-06-10 23:01:33 +02:00
parent 3092326d9e
commit 6b16d8c37a
32 changed files with 2209 additions and 109 deletions
+86 -1
View File
@@ -4,12 +4,14 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/gin-gonic/gin"
)
@@ -46,6 +48,9 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
g.POST("/balancerStatus", a.balancerStatus)
g.POST("/balancerOverride", a.balancerOverride)
g.POST("/routeTest", a.routeTest)
// Outbound subscription (remote outbound lists)
g.GET("/outbound-subs", a.listOutboundSubs)
@@ -120,7 +125,9 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
jsonObj(c, string(result), nil)
}
// updateSetting updates the Xray configuration settings.
// updateSetting updates the Xray configuration settings and applies them to
// the running core right away — through the gRPC API when only inbounds,
// outbounds or routing rules changed, with a process restart otherwise.
func (a *XraySettingController) updateSetting(c *gin.Context) {
xraySetting := c.PostForm("xraySetting")
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
@@ -135,6 +142,13 @@ func (a *XraySettingController) updateSetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
// Only reconcile a running core; a manually stopped xray stays stopped.
if a.XrayService.IsXrayRunning() {
if err := a.XrayService.RestartXray(false); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
}
@@ -272,6 +286,77 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
jsonObj(c, result, nil)
}
// balancerStatus reports the live state (override + strategy picks) of the
// balancer tags given as a comma-separated "tags" form field.
func (a *XraySettingController) balancerStatus(c *gin.Context) {
raw := c.PostForm("tags")
var tags []string
for _, tag := range strings.Split(raw, ",") {
if tag = strings.TrimSpace(tag); tag != "" {
tags = append(tags, tag)
}
}
statuses, err := a.XrayService.GetBalancersStatus(tags)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
byTag := make(map[string]service.BalancerStatus, len(statuses))
for _, status := range statuses {
byTag[status.Tag] = status
}
jsonObj(c, byTag, nil)
}
// balancerOverride forces a balancer to a specific outbound tag; an empty
// "target" clears the override.
func (a *XraySettingController) balancerOverride(c *gin.Context) {
tag := c.PostForm("tag")
if tag == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("tag is required"))
return
}
target := c.PostForm("target")
if err := a.XrayService.OverrideBalancer(tag, target); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, "", nil)
}
// routeTest asks the running core which outbound it would route a synthetic
// connection to.
func (a *XraySettingController) routeTest(c *gin.Context) {
port := 0
if portStr := c.PostForm("port"); portStr != "" {
parsed, err := strconv.Atoi(portStr)
if err != nil || parsed < 0 || parsed > 65535 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("invalid port"))
return
}
port = parsed
}
req := xray.RouteTestRequest{
InboundTag: c.PostForm("inboundTag"),
Domain: c.PostForm("domain"),
IP: c.PostForm("ip"),
Port: port,
Network: c.PostForm("network"),
Protocol: c.PostForm("protocol"),
Email: c.PostForm("email"),
}
if req.Domain == "" && req.IP == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("domain or ip is required"))
return
}
result, err := a.XrayService.TestRoute(req)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
}
// --- Outbound Subscription handlers ---
func (a *XraySettingController) listOutboundSubs(c *gin.Context) {