mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 09:27:15 +00:00
7100fbcd08
* feat(model): add MemberWeights to SubBalancer Per-inbound leastLoad weights, stored with the same gorm json serializer as InboundIds so AutoMigrate adds the text column on every dialect (postgresModelSettled sees the missing column and re-runs). Absent entries mean weight 1.0; only meaningful for strategy leastLoad. * feat(sub): accept memberWeights on the sub-balancer API Parsed as one JSON form field (gin cannot bind bracket-keyed maps from urlencoded bodies). validate() rejects weights under any strategy but leastLoad — xray would silently ignore costs there, so storing them would pretend a knob exists. Non-positive weights error instead of defaulting: a zero usually means a typo'd "never pick this node". Entries for inbounds no longer selected are dropped on save. * feat(sub): emit leastLoad strategy costs from member weights costs[] is built after the tagging loop reuses the exact retagged tags (bal-N-protocol[-k]) and each member's owning inbound id. Members without a configured weight default to 1.0, but costs are omitted entirely unless at least one explicit weight survives — an all-1.0 array would bloat every subscription response for no effect. * feat(sub-balancers): leastLoad member weight inputs Weight fields render only under leastLoad and hide on strategy change without dropping their values, so an accidental toggle away and back loses nothing until save; non-leastLoad submits strip them entirely because xray would ignore costs. Weights travel as one JSON form field (gin cannot bind bracket-keyed maps) and every locale gets the three new keys in the same commit per the dead-keys rule. * docs(api): document memberWeights on sub-balancers leastLoad-only JSON form field; update notes that omitting it clears stored weights. Regenerated openapi artifacts via make gen + the docs copy/gen:api step nothing checks automatically. * fix(api-docs): use the allowed object ParamType for memberWeights * fix(sub-balancers): cap the member-weight list height Many selected inbounds pushed the modal body past the viewport. The weight rows now scroll inside a 220px viewport, mirroring the inbound picker's listHeight so both lists read the same. * fix(sub): anchor leastLoad cost matches to exact member tags Verified against xray-core: without regexp, WeightManager matches costs by substring (strings.Index), so the bare tag "bal-1-vless" also hits the deduplicated "bal-1-vless-2" and both members get the first entry's weight. Anchored ^tag$ regexps make every cost entry match only its own member. Also confirmed value<=0 makes xray derive a weight from the first digit of the matched tag — validating weights > 0 server-side was the right call. * fix(sub-balancers): keep member weights across the enabled toggle The table's toggleEnabled re-posted a full-row payload without memberWeights, and the update path treats an absent key as "erase" — flipping the switch silently dropped every configured weight. Round-trip the stored weights through the toggle payload, and prove persistence with a re-Get in the weight-validation test (the returned struct alone would stay green even if Save skipped the column). * fix(sub-balancers): address review on member weights - omitempty on MemberWeights: the panel sends null for every pre-existing and non-leastLoad balancer, which failed the hand-written zod response schema on every fetch (zod .optional() accepts undefined only; switched to .nullish() per repo convention) and drifted the generated contract. Regenerated openapi artifacts + docs copy + MDX. - Bound weights to the positive float32 range: xray decodes costs as float32, so an over-range value makes clients reject the whole subscription document and an underflow decays to the tag-digit fallback weight. Tests for both directions. - Trim six comment blocks to the 2-line cap from CLAUDE.md. --------- Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
138 lines
3.9 KiB
Go
138 lines
3.9 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// SubBalancerController manages client-side JSON-subscription balancers.
|
|
type SubBalancerController struct {
|
|
SubBalancerService service.SubBalancerService
|
|
}
|
|
|
|
func NewSubBalancerController(g *gin.RouterGroup) *SubBalancerController {
|
|
a := &SubBalancerController{}
|
|
g = g.Group("/sub-balancers")
|
|
g.GET("", a.list)
|
|
g.POST("", a.create)
|
|
g.POST("/:id", a.update)
|
|
g.DELETE("/:id", a.del)
|
|
g.POST("/:id/del", a.del)
|
|
return a
|
|
}
|
|
|
|
// parseSubBalancerForm reads the urlencoded form (HttpUtil default): scalars
|
|
// via ShouldBind, inboundIds as repeated keys. enabled is returned as *bool so
|
|
// Update can keep the stored value when the key is absent; a bad value is a 400.
|
|
func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
|
|
form := struct {
|
|
Remark string `form:"remark"`
|
|
Strategy string `form:"strategy"`
|
|
SortOrder int `form:"sortOrder"`
|
|
}{}
|
|
if err := c.ShouldBind(&form); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var enabled *bool
|
|
if raw, ok := c.GetPostForm("enabled"); ok {
|
|
v, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid enabled %q: %w", raw, err)
|
|
}
|
|
enabled = &v
|
|
}
|
|
balancer := &model.SubBalancer{
|
|
Remark: form.Remark,
|
|
Strategy: form.Strategy,
|
|
SortOrder: form.SortOrder,
|
|
}
|
|
for _, raw := range c.PostFormArray("inboundIds") {
|
|
id, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid inbound id %q: %w", raw, err)
|
|
}
|
|
balancer.InboundIds = append(balancer.InboundIds, id)
|
|
}
|
|
// Weights arrive as one JSON object ("memberWeights":{"3":0.5}); gin cannot
|
|
// bind bracket-keyed maps from urlencoded forms, unlike repeated scalars.
|
|
if raw, ok := c.GetPostForm("memberWeights"); ok && strings.TrimSpace(raw) != "" {
|
|
weights := map[int]float64{}
|
|
if err := json.Unmarshal([]byte(raw), &weights); err != nil {
|
|
return nil, nil, fmt.Errorf("invalid memberWeights %q: %w", raw, err)
|
|
}
|
|
balancer.MemberWeights = weights
|
|
}
|
|
return balancer, enabled, nil
|
|
}
|
|
|
|
func (a *SubBalancerController) parseID(c *gin.Context) (int, error) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil || id < 1 {
|
|
return 0, fmt.Errorf("invalid id %q", c.Param("id"))
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (a *SubBalancerController) list(c *gin.Context) {
|
|
balancers, err := a.SubBalancerService.List()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.list"), err)
|
|
return
|
|
}
|
|
jsonObj(c, balancers, nil)
|
|
}
|
|
|
|
func (a *SubBalancerController) create(c *gin.Context) {
|
|
balancer, enabled, err := parseSubBalancerForm(c)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
|
|
return
|
|
}
|
|
balancer.Enabled = enabled == nil || *enabled
|
|
created, err := a.SubBalancerService.Create(balancer)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
|
|
return
|
|
}
|
|
jsonObj(c, created, nil)
|
|
}
|
|
|
|
func (a *SubBalancerController) update(c *gin.Context) {
|
|
id, err := a.parseID(c)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
|
|
return
|
|
}
|
|
balancer, enabled, err := parseSubBalancerForm(c)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
|
|
return
|
|
}
|
|
updated, err := a.SubBalancerService.Update(id, balancer, enabled)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
|
|
return
|
|
}
|
|
jsonObj(c, updated, nil)
|
|
}
|
|
|
|
func (a *SubBalancerController) del(c *gin.Context) {
|
|
id, err := a.parseID(c)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
|
|
return
|
|
}
|
|
if err := a.SubBalancerService.Delete(id); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.delete"), err)
|
|
return
|
|
}
|
|
jsonObj(c, "", nil)
|
|
}
|