mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-03 17:07:15 +00:00
feat(sub): leastLoad member weights for subscription balancers (#6304)
* 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>
This commit is contained in:
@@ -1247,7 +1247,10 @@ type SubBalancer struct {
|
||||
Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"auto-fastest"`
|
||||
Strategy string `json:"strategy" form:"strategy" validate:"omitempty,oneof=leastLoad leastPing random roundRobin" example:"random"`
|
||||
InboundIds []int `json:"inboundIds" form:"inboundIds" gorm:"serializer:json;column:inbound_ids" example:"[1,3]"`
|
||||
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"column:sort_order" validate:"omitempty,gte=1" example:"1"`
|
||||
// inboundId -> leastLoad weight; absent entries mean 1.0. Only meaningful
|
||||
// with Strategy "leastLoad" — xray ignores costs on every other strategy.
|
||||
MemberWeights map[int]float64 `json:"memberWeights,omitempty" form:"memberWeights" gorm:"serializer:json;column:member_weights"`
|
||||
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"column:sort_order" validate:"omitempty,gte=1" example:"1"`
|
||||
// No gorm default:true — a bool default makes an explicit false at insert
|
||||
// collapse back to the column default (zero value is skipped).
|
||||
Enabled bool `json:"enabled" form:"enabled" example:"true"`
|
||||
|
||||
@@ -351,12 +351,49 @@ func balancerMemberSuffix(protocol string) string {
|
||||
return protocol
|
||||
}
|
||||
|
||||
// balMember is one retagged member outbound and the inbound it came from.
|
||||
type balMember struct {
|
||||
tag string
|
||||
inboundId int
|
||||
}
|
||||
|
||||
// leastLoadCosts builds xray's static strategy costs: higher value = picked
|
||||
// less often; nil unless a member carries an explicit weight (all-1.0 bloat).
|
||||
func leastLoadCosts(balancer *model.SubBalancer, members []balMember) []any {
|
||||
if balancer.Strategy != "leastLoad" || len(members) == 0 || len(balancer.MemberWeights) == 0 {
|
||||
return nil
|
||||
}
|
||||
costs := make([]any, 0, len(members))
|
||||
configured := false
|
||||
for _, m := range members {
|
||||
value := 1.0
|
||||
if weight, ok := balancer.MemberWeights[m.inboundId]; ok && weight > 0 {
|
||||
value = weight
|
||||
configured = true
|
||||
}
|
||||
// Anchored regexp: plain cost matching is substring-based in xray, so
|
||||
// an unanchored "bal-1-vless" would also swallow "bal-1-vless-2".
|
||||
costs = append(costs, map[string]any{
|
||||
"regexp": true,
|
||||
"match": "^" + m.tag + "$",
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
if !configured {
|
||||
return nil
|
||||
}
|
||||
return costs
|
||||
}
|
||||
|
||||
// buildBalancerConfig assembles the balancer profile: members retagged under a
|
||||
// per-balancer prefix, a routing.balancers entry, and (for leastPing/leastLoad) an observatory.
|
||||
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
|
||||
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
|
||||
usedTags := make(map[string]bool)
|
||||
var proxies []json_util.RawMessage
|
||||
// Members in emission order with their owning inbound, so costs[] can
|
||||
// reference the exact retagged tags assigned here.
|
||||
var members []balMember
|
||||
var firstTag string
|
||||
// entryProxies is the pre-extracted proxy outbounds per entry; kind!=0 rows
|
||||
// have none. Clone before retagging so the cached map stays reusable.
|
||||
@@ -375,6 +412,7 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
|
||||
member := maps.Clone(outbound)
|
||||
member["tag"] = tag
|
||||
if raw, err := json.MarshalIndent(member, "", " "); err == nil {
|
||||
members = append(members, balMember{tag: tag, inboundId: entry.id})
|
||||
if firstTag == "" {
|
||||
firstTag = tag
|
||||
}
|
||||
@@ -411,10 +449,14 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
|
||||
}
|
||||
routing["rules"] = rules
|
||||
isObservatory := balancer.Strategy == "leastPing" || balancer.Strategy == "leastLoad"
|
||||
strategyEntry := map[string]any{"type": balancer.Strategy}
|
||||
if costs := leastLoadCosts(balancer, members); costs != nil {
|
||||
strategyEntry["settings"] = map[string]any{"costs": costs}
|
||||
}
|
||||
balancerEntry := map[string]any{
|
||||
"tag": subBalancerTag,
|
||||
"selector": []string{prefix},
|
||||
"strategy": map[string]any{"type": balancer.Strategy},
|
||||
"strategy": strategyEntry,
|
||||
}
|
||||
if isObservatory && firstTag != "" {
|
||||
// With all probes failing, route to the first member instead of
|
||||
|
||||
@@ -420,3 +420,91 @@ func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string)
|
||||
ping, _ := obs["pingConfig"].(map[string]any)
|
||||
return ping
|
||||
}
|
||||
|
||||
func balancerStrategy(t *testing.T, docs []map[string]any, remarks string) map[string]any {
|
||||
t.Helper()
|
||||
doc := findDocByRemarks(docs, remarks)
|
||||
if doc == nil {
|
||||
t.Fatalf("balancer doc %q missing", remarks)
|
||||
}
|
||||
routing, _ := doc["routing"].(map[string]any)
|
||||
balancers, _ := routing["balancers"].([]any)
|
||||
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
|
||||
return strategy
|
||||
}
|
||||
|
||||
// leastLoad with configured weights must emit strategy.settings.costs keyed by
|
||||
// the retagged member tags; members without a weight count as 1.0.
|
||||
func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
fast := seedSubInbound(t, "s1", "fast", 4791, 1, wsTLSStream)
|
||||
slow := seedSubInbound(t, "s1", "slow", 4792, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "weighted", Strategy: "leastLoad", InboundIds: []int{fast.Id, slow.Id},
|
||||
MemberWeights: map[int]float64{fast.Id: 0.2}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "weighted")
|
||||
settings, _ := strategy["settings"].(map[string]any)
|
||||
costs, _ := settings["costs"].([]any)
|
||||
if len(costs) != 2 {
|
||||
t.Fatalf("costs = %v, want 2 entries:\n%s", costs, out)
|
||||
}
|
||||
first, _ := costs[0].(map[string]any)
|
||||
second, _ := costs[1].(map[string]any)
|
||||
// Anchored regexp is required: xray's plain cost match is substring-based,
|
||||
// so a bare "bal-1-vless" would also hit the deduplicated "bal-1-vless-2".
|
||||
if first["regexp"] != true || first["match"] != "^bal-1-vless$" || first["value"] != 0.2 {
|
||||
t.Fatalf("costs[0] = %v, want regexp ^bal-1-vless$ value=0.2", first)
|
||||
}
|
||||
if second["regexp"] != true || second["match"] != "^bal-1-vless-2$" || second["value"] != 1.0 {
|
||||
t.Fatalf("costs[1] = %v, want regexp ^bal-1-vless-2$ value=1 (default)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// leastLoad without any configured weight emits no settings at all.
|
||||
func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
a := seedSubInbound(t, "s1", "a", 4801, 1, wsTLSStream)
|
||||
b := seedSubInbound(t, "s1", "b", 4802, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "plain", Strategy: "leastLoad", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "plain")
|
||||
if _, has := strategy["settings"]; has {
|
||||
t.Fatalf("leastLoad without weights must not emit strategy.settings: %v", strategy["settings"])
|
||||
}
|
||||
}
|
||||
|
||||
// Emission-side guard independent of validate(): a non-leastLoad row written
|
||||
// directly to the DB must still emit no costs — xray would ignore them.
|
||||
func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
a := seedSubInbound(t, "s1", "a", 4811, 1, wsTLSStream)
|
||||
b := seedSubInbound(t, "s1", "b", 4812, 2, wsTLSStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "misconfig", Strategy: "random", InboundIds: []int{a.Id, b.Id},
|
||||
MemberWeights: map[int]float64{a.Id: 0.5}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "misconfig")
|
||||
if _, has := strategy["settings"]; has {
|
||||
t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -58,6 +60,15 @@ func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -36,12 +38,44 @@ func (s *SubBalancerService) validate(b *model.SubBalancer) error {
|
||||
if len(b.InboundIds) == 0 {
|
||||
return common.NewError("balancer must select at least one inbound")
|
||||
}
|
||||
if err := s.validateWeights(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.SortOrder < 1 {
|
||||
b.SortOrder = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWeights rejects weights xray cannot honor (non-positive, outside
|
||||
// float32 range, non-leastLoad strategy) and drops stray inbound ids.
|
||||
func (s *SubBalancerService) validateWeights(b *model.SubBalancer) error {
|
||||
if len(b.MemberWeights) == 0 {
|
||||
b.MemberWeights = nil
|
||||
return nil
|
||||
}
|
||||
if b.Strategy != "leastLoad" {
|
||||
return common.NewError("balancer weights only apply to the leastLoad strategy")
|
||||
}
|
||||
cleaned := make(map[int]float64, len(b.MemberWeights))
|
||||
for id, weight := range b.MemberWeights {
|
||||
if !slices.Contains(b.InboundIds, id) {
|
||||
continue
|
||||
}
|
||||
// xray decodes costs as float32; out-of-range values make it reject the
|
||||
// whole config, and underflow decays to the tag-digit fallback weight.
|
||||
if weight <= 0 || weight > math.MaxFloat32 || weight < math.SmallestNonzeroFloat32 {
|
||||
return common.NewError("balancer member weights must be a positive float32 value")
|
||||
}
|
||||
cleaned[id] = weight
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
cleaned = nil
|
||||
}
|
||||
b.MemberWeights = cleaned
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all balancers in subscription order.
|
||||
func (s *SubBalancerService) List() ([]*model.SubBalancer, error) {
|
||||
var balancers []*model.SubBalancer
|
||||
@@ -79,6 +113,7 @@ func (s *SubBalancerService) Update(id int, balancer *model.SubBalancer, enabled
|
||||
current.Remark = balancer.Remark
|
||||
current.Strategy = balancer.Strategy
|
||||
current.InboundIds = balancer.InboundIds
|
||||
current.MemberWeights = balancer.MemberWeights
|
||||
current.SortOrder = balancer.SortOrder
|
||||
if enabled != nil {
|
||||
current.Enabled = *enabled
|
||||
|
||||
@@ -159,3 +159,91 @@ func TestSubBalancerServiceValidation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Weights are a leastLoad-only knob (xray ignores costs elsewhere); non-positive
|
||||
// weights are rejected rather than defaulted — a zero means "never pick this".
|
||||
func TestSubBalancerServiceWeightValidation(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
svc := &SubBalancerService{}
|
||||
|
||||
if _, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "w", Strategy: "random", InboundIds: []int{1},
|
||||
MemberWeights: map[int]float64{1: 0.5},
|
||||
}); err == nil || !strings.Contains(err.Error(), "leastLoad strategy") {
|
||||
t.Fatalf("weights with random = %v, want leastLoad-strategy error", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "w", Strategy: "leastLoad", InboundIds: []int{1},
|
||||
MemberWeights: map[int]float64{1: -0.5},
|
||||
}); err == nil || !strings.Contains(err.Error(), "positive float32") {
|
||||
t.Fatalf("negative weight = %v, must be rejected", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "w", Strategy: "leastLoad", InboundIds: []int{1},
|
||||
MemberWeights: map[int]float64{1: 1e39},
|
||||
}); err == nil || !strings.Contains(err.Error(), "positive float32") {
|
||||
t.Fatalf("above-float32 weight = %v, must be rejected", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "w", Strategy: "leastLoad", InboundIds: []int{1},
|
||||
MemberWeights: map[int]float64{1: 1e-50},
|
||||
}); err == nil || !strings.Contains(err.Error(), "positive float32") {
|
||||
t.Fatalf("underflowing weight = %v, must be rejected", err)
|
||||
}
|
||||
|
||||
stray, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "stray", Strategy: "leastLoad", InboundIds: []int{1, 2},
|
||||
MemberWeights: map[int]float64{2: 0.25, 99: 3.0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create with stray weight id: %v", err)
|
||||
}
|
||||
stored, err := svc.Get(stray.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if len(stored.MemberWeights) != 1 || stored.MemberWeights[2] != 0.25 {
|
||||
t.Fatalf("memberWeights = %v, want only {2:0.25} (id 99 dropped)", stored.MemberWeights)
|
||||
}
|
||||
|
||||
reweighted, err := svc.Update(stray.Id, &model.SubBalancer{
|
||||
Remark: "stray", Strategy: "leastLoad", InboundIds: []int{1, 2},
|
||||
MemberWeights: map[int]float64{1: 2.5}, SortOrder: 1,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("update weights: %v", err)
|
||||
}
|
||||
if reweighted.MemberWeights[1] != 2.5 || len(reweighted.MemberWeights) != 1 {
|
||||
t.Fatalf("updated memberWeights = %v, want {1:2.5}", reweighted.MemberWeights)
|
||||
}
|
||||
|
||||
cleared, err := svc.Update(stray.Id, &model.SubBalancer{
|
||||
Remark: "stray", Strategy: "leastLoad", InboundIds: []int{1, 2}, SortOrder: 1,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("update without weights: %v", err)
|
||||
}
|
||||
if cleared.MemberWeights != nil {
|
||||
t.Fatalf("absent memberWeights must clear stored weights, got %v", cleared.MemberWeights)
|
||||
}
|
||||
|
||||
// A toggle-style update (weights key absent) must not erase stored weights
|
||||
// when the payload carries them back — re-Get to prove the column survived.
|
||||
toggled, err := svc.Update(stray.Id, &model.SubBalancer{
|
||||
Remark: "stray", Strategy: "leastLoad", InboundIds: []int{1, 2},
|
||||
MemberWeights: map[int]float64{1: 2.5}, SortOrder: 1,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("toggle-style update with weights: %v", err)
|
||||
}
|
||||
reget, err := svc.Get(toggled.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get after toggle-style update: %v", err)
|
||||
}
|
||||
if len(reget.MemberWeights) != 1 || reget.MemberWeights[1] != 2.5 {
|
||||
t.Fatalf("re-Get memberWeights = %v, want persisted {1:2.5}", reget.MemberWeights)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.",
|
||||
"inbounds": "الإينبوندات",
|
||||
"inboundsCount": "{count} الإينبوندات",
|
||||
"weights": "أوزان الأعضاء",
|
||||
"weightsHelp": "لـ LeastLoad فقط: الوزن الأقل يُختار أكثر؛ العضو بدون قيمة وزنه 1.",
|
||||
"enabled": "مُفعّل",
|
||||
"empty": "لا يوجد موزّعات بعد",
|
||||
"deleteConfirm": "حذف هذا الموزّع؟",
|
||||
"errRemarkRequired": "الملاحظة مطلوبة",
|
||||
"errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل",
|
||||
"errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1",
|
||||
"errWeightPositive": "يجب أن تكون الأوزان أكبر من 0",
|
||||
"toasts": {
|
||||
"list": "تعذّر عرض موزّعات الاشتراك",
|
||||
"create": "تعذّر إنشاء موزّع اشتراك",
|
||||
|
||||
@@ -1538,12 +1538,15 @@
|
||||
"sortOrderHelp": "Position in the subscription list, interleaved with the inbounds' own order; on equal numbers the balancer comes after the inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"weights": "Member weights",
|
||||
"weightsHelp": "Only for LeastLoad: a lower weight is picked more often; members without a value weigh 1.",
|
||||
"enabled": "Enabled",
|
||||
"empty": "No balancers yet",
|
||||
"deleteConfirm": "Delete this balancer?",
|
||||
"errRemarkRequired": "Remark is required",
|
||||
"errInboundsRequired": "Select at least one inbound",
|
||||
"errSortOrder": "Order must be a whole number ≥ 1",
|
||||
"errWeightPositive": "Weights must be greater than 0",
|
||||
"toasts": {
|
||||
"list": "Failed to list subscription balancers",
|
||||
"create": "Failed to create subscription balancer",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Posición en la lista de la suscripción, intercalada con el orden de los inbounds; con el mismo número, el balanceador va después del inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"weights": "Pesos de miembros",
|
||||
"weightsHelp": "Solo para LeastLoad: un peso más bajo se elige con más frecuencia; los miembros sin valor pesan 1.",
|
||||
"enabled": "Activado",
|
||||
"empty": "Aún no hay balanceadores",
|
||||
"deleteConfirm": "¿Eliminar este balanceador?",
|
||||
"errRemarkRequired": "El comentario es obligatorio",
|
||||
"errInboundsRequired": "Selecciona al menos un inbound",
|
||||
"errSortOrder": "El orden debe ser un número entero ≥ 1",
|
||||
"errWeightPositive": "Los pesos deben ser mayores que 0",
|
||||
"toasts": {
|
||||
"list": "No se pudieron listar los balanceadores de suscripción",
|
||||
"create": "No se pudo crear el balanceador de suscripción",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "جایگاه در فهرست اشتراک، درهمتنیده با ترتیب اینباندها؛ با شمارهٔ برابر، موزانکننده بعد از اینباند میآید.",
|
||||
"inbounds": "اینباندها",
|
||||
"inboundsCount": "{count} اینباندها",
|
||||
"weights": "وزن اعضا",
|
||||
"weightsHelp": "فقط برای LeastLoad: وزن کمتر بیشتر انتخاب میشود؛ عضوی که مقداری نداشته باشد وزن ۱ دارد.",
|
||||
"enabled": "فعال",
|
||||
"empty": "هنوز موزانکنندهای وجود ندارد",
|
||||
"deleteConfirm": "این موزانکننده حذف شود؟",
|
||||
"errRemarkRequired": "توضیح الزامی است",
|
||||
"errInboundsRequired": "حداقل یک اینباند انتخاب کنید",
|
||||
"errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد",
|
||||
"errWeightPositive": "وزنها باید بزرگتر از ۰ باشند",
|
||||
"toasts": {
|
||||
"list": "فهرستسازی موزانکنندههای اشتراک ناموفق بود",
|
||||
"create": "ایجاد موزانکننده اشتراک ناموفق بود",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Posisi dalam daftar langganan, berselang-seling dengan urutan inbound; jika sama, penyeimbang berada setelah inbound.",
|
||||
"inbounds": "Inbound",
|
||||
"inboundsCount": "{count} Inbound",
|
||||
"weights": "Bobot anggota",
|
||||
"weightsHelp": "Hanya untuk LeastLoad: bobot lebih rendah lebih sering dipilih; anggota tanpa nilai berbobot 1.",
|
||||
"enabled": "Aktif",
|
||||
"empty": "Belum ada penyeimbang",
|
||||
"deleteConfirm": "Hapus penyeimbang ini?",
|
||||
"errRemarkRequired": "Keterangan wajib diisi",
|
||||
"errInboundsRequired": "Pilih minimal satu inbound",
|
||||
"errSortOrder": "Urutan harus bilangan bulat ≥ 1",
|
||||
"errWeightPositive": "Bobot harus lebih besar dari 0",
|
||||
"toasts": {
|
||||
"list": "Gagal menampilkan daftar penyeimbang langganan",
|
||||
"create": "Gagal membuat penyeimbang langganan",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "サブスクリプション一覧内の位置。インバウンドの順序と交互に並び、同番号の場合はインバウンドの後ろになります。",
|
||||
"inbounds": "インバウンド",
|
||||
"inboundsCount": "{count} インバウンド",
|
||||
"weights": "メンバーの重み",
|
||||
"weightsHelp": "LeastLoad のみ:値が小さいほど選ばれやすくなります。未指定のメンバーは重み 1 です。",
|
||||
"enabled": "有効",
|
||||
"empty": "バランサーはまだありません",
|
||||
"deleteConfirm": "このバランサーを削除しますか?",
|
||||
"errRemarkRequired": "備考を入力してください",
|
||||
"errInboundsRequired": "インバウンドを1つ以上選択してください",
|
||||
"errSortOrder": "順序は1以上の整数にしてください",
|
||||
"errWeightPositive": "重みは 0 より大きい必要があります",
|
||||
"toasts": {
|
||||
"list": "サブスクリプションバランサーの一覧取得に失敗しました",
|
||||
"create": "サブスクリプションバランサーの作成に失敗しました",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Posição na lista da assinatura, intercalada com a ordem dos inbounds; em caso de empate, o balanceador vem depois do inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"weights": "Pesos dos membros",
|
||||
"weightsHelp": "Apenas para LeastLoad: peso menor é escolhido com mais frequência; membros sem valor têm peso 1.",
|
||||
"enabled": "Ativado",
|
||||
"empty": "Ainda não há balanceadores",
|
||||
"deleteConfirm": "Excluir este balanceador?",
|
||||
"errRemarkRequired": "A descrição é obrigatória",
|
||||
"errInboundsRequired": "Selecione ao menos um inbound",
|
||||
"errSortOrder": "A ordem deve ser um inteiro ≥ 1",
|
||||
"errWeightPositive": "Os pesos devem ser maiores que 0",
|
||||
"toasts": {
|
||||
"list": "Falha ao listar os balanceadores de assinatura",
|
||||
"create": "Falha ao criar o balanceador de assinatura",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.",
|
||||
"inbounds": "Инбаунды",
|
||||
"inboundsCount": "{count} Инбаунды",
|
||||
"weights": "Веса участников",
|
||||
"weightsHelp": "Только для LeastLoad: чем меньше вес, тем чаще выбирается участник; без значения вес равен 1.",
|
||||
"enabled": "Включён",
|
||||
"empty": "Балансировщиков пока нет",
|
||||
"deleteConfirm": "Удалить этот балансировщик?",
|
||||
"errRemarkRequired": "Укажите примечание",
|
||||
"errInboundsRequired": "Выберите хотя бы один инбаунд",
|
||||
"errSortOrder": "Порядок — целое число ≥ 1",
|
||||
"errWeightPositive": "Веса должны быть больше 0",
|
||||
"toasts": {
|
||||
"list": "Не удалось получить список балансировщиков подписки",
|
||||
"create": "Не удалось создать балансировщик подписки",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Abonelik listesindeki konumu, inbound sırası ile iç içe yerleşir; eşit numarada dengeleyici inbound'dan sonra gelir.",
|
||||
"inbounds": "Inbound'lar",
|
||||
"inboundsCount": "{count} Inbound'lar",
|
||||
"weights": "Üye ağırlıkları",
|
||||
"weightsHelp": "Yalnızca LeastLoad için: daha düşük ağırlık daha sık seçilir; değeri olmayan üyelerin ağırlığı 1’dir.",
|
||||
"enabled": "Etkin",
|
||||
"empty": "Henüz dengeleyici yok",
|
||||
"deleteConfirm": "Bu dengeleyici silinsin mi?",
|
||||
"errRemarkRequired": "Açıklama zorunludur",
|
||||
"errInboundsRequired": "En az bir inbound seçin",
|
||||
"errSortOrder": "Sıra 1 veya daha büyük bir tam sayı olmalı",
|
||||
"errWeightPositive": "Ağırlıklar 0’dan büyük olmalıdır",
|
||||
"toasts": {
|
||||
"list": "Abonelik dengeleyicileri listelenemedi",
|
||||
"create": "Abonelik dengeleyicisi oluşturulamadı",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.",
|
||||
"inbounds": "Інбаунди",
|
||||
"inboundsCount": "{count} Інбаунди",
|
||||
"weights": "Ваги учасників",
|
||||
"weightsHelp": "Лише для LeastLoad: чим менша вага, тим частіше обирається учасник; без значення вага дорівнює 1.",
|
||||
"enabled": "Увімкнено",
|
||||
"empty": "Балансувальників ще немає",
|
||||
"deleteConfirm": "Видалити цей балансувальник?",
|
||||
"errRemarkRequired": "Вкажіть примітку",
|
||||
"errInboundsRequired": "Виберіть хоча б один інбаунд",
|
||||
"errSortOrder": "Порядок — ціле число ≥ 1",
|
||||
"errWeightPositive": "Ваги повинні бути більшими за 0",
|
||||
"toasts": {
|
||||
"list": "Не вдалося отримати список балансувальників підписки",
|
||||
"create": "Не вдалося створити балансувальник підписки",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "Vị trí trong danh sách đăng ký, xen kẽ với thứ tự inbound; khi cùng số, bộ cân bằng đứng sau inbound.",
|
||||
"inbounds": "Inbound",
|
||||
"inboundsCount": "{count} Inbound",
|
||||
"weights": "Trọng số thành viên",
|
||||
"weightsHelp": "Chỉ dành cho LeastLoad: trọng số nhỏ hơn được chọn thường xuyên hơn; thành viên không có giá trị mang trọng số 1.",
|
||||
"enabled": "Đã bật",
|
||||
"empty": "Chưa có bộ cân bằng nào",
|
||||
"deleteConfirm": "Xóa bộ cân bằng này?",
|
||||
"errRemarkRequired": "Cần nhập ghi chú",
|
||||
"errInboundsRequired": "Chọn ít nhất một inbound",
|
||||
"errSortOrder": "Thứ tự phải là số nguyên ≥ 1",
|
||||
"errWeightPositive": "Trọng số phải lớn hơn 0",
|
||||
"toasts": {
|
||||
"list": "Không thể liệt kê các bộ cân bằng đăng ký",
|
||||
"create": "Không thể tạo bộ cân bằng đăng ký",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。",
|
||||
"inbounds": "入站",
|
||||
"inboundsCount": "{count} 入站",
|
||||
"weights": "成员权重",
|
||||
"weightsHelp": "仅适用于 LeastLoad:权重越小越常被选中;未设置的成员权重为 1。",
|
||||
"enabled": "启用",
|
||||
"empty": "暂无均衡器",
|
||||
"deleteConfirm": "确定删除此均衡器?",
|
||||
"errRemarkRequired": "请填写备注",
|
||||
"errInboundsRequired": "请至少选择一个入站",
|
||||
"errSortOrder": "顺序必须为不小于 1 的整数",
|
||||
"errWeightPositive": "权重必须大于 0",
|
||||
"toasts": {
|
||||
"list": "列出订阅均衡器失败",
|
||||
"create": "创建订阅均衡器失败",
|
||||
|
||||
@@ -1420,12 +1420,15 @@
|
||||
"sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。",
|
||||
"inbounds": "入站",
|
||||
"inboundsCount": "{count} 入站",
|
||||
"weights": "成員權重",
|
||||
"weightsHelp": "僅適用於 LeastLoad:權重越小越常被選中;未設定的成員權重為 1。",
|
||||
"enabled": "啟用",
|
||||
"empty": "尚無平衡器",
|
||||
"deleteConfirm": "確定刪除此平衡器?",
|
||||
"errRemarkRequired": "請填寫備註",
|
||||
"errInboundsRequired": "請至少選擇一個入站",
|
||||
"errSortOrder": "順序必須為不小於 1 的整數",
|
||||
"errWeightPositive": "權重必須大於 0",
|
||||
"toasts": {
|
||||
"list": "列出訂閱平衡器失敗",
|
||||
"create": "建立訂閱平衡器失敗",
|
||||
|
||||
Reference in New Issue
Block a user