From 7100fbcd086954d4a714492f570f3498321b9f20 Mon Sep 17 00:00:00 2001 From: DIMFLIX Date: Wed, 2 Sep 2026 21:21:27 +0300 Subject: [PATCH] feat(sub): leastLoad member weights for subscription balancers (#6304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../reference/api/subscription-balancers.mdx | 10 ++- docs/public/openapi.json | 14 ++- frontend/public/openapi.json | 14 ++- .../api/queries/useSubBalancerMutations.ts | 16 +++- frontend/src/generated/examples.ts | 1 + frontend/src/generated/schemas.ts | 7 ++ frontend/src/generated/types.ts | 1 + frontend/src/generated/zod.ts | 1 + frontend/src/pages/api-docs/endpoints.ts | 8 +- .../pages/settings/SubBalancerFormModal.tsx | 75 +++++++++++++++- .../settings/SubscriptionBalancersTab.tsx | 1 + frontend/src/schemas/subBalancer.ts | 10 +++ .../src/test/sub-balancer-form-modal.test.tsx | 72 +++++++++++++++ internal/database/model/model.go | 5 +- internal/sub/json_service.go | 44 +++++++++- internal/sub/sub_balancer_test.go | 88 +++++++++++++++++++ internal/web/controller/sub_balancer.go | 11 +++ internal/web/service/sub_balancer.go | 35 ++++++++ internal/web/service/sub_balancer_test.go | 88 +++++++++++++++++++ internal/web/translation/ar-EG.json | 3 + internal/web/translation/en-US.json | 3 + internal/web/translation/es-ES.json | 3 + internal/web/translation/fa-IR.json | 3 + internal/web/translation/id-ID.json | 3 + internal/web/translation/ja-JP.json | 3 + internal/web/translation/pt-BR.json | 3 + internal/web/translation/ru-RU.json | 3 + internal/web/translation/tr-TR.json | 3 + internal/web/translation/uk-UA.json | 3 + internal/web/translation/vi-VN.json | 3 + internal/web/translation/zh-CN.json | 3 + internal/web/translation/zh-TW.json | 3 + 32 files changed, 525 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/en/reference/api/subscription-balancers.mdx b/docs/content/docs/en/reference/api/subscription-balancers.mdx index b1b655cd6..61b526c04 100644 --- a/docs/content/docs/en/reference/api/subscription-balancers.mdx +++ b/docs/content/docs/en/reference/api/subscription-balancers.mdx @@ -18,8 +18,9 @@ _openapi: url: '#create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound' - depth: 2 title: Update a balancer by id. Accepts the same form fields as create (full-row - update, including the enabled toggle). - url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle' + update, including the enabled toggle); omitting memberWeights clears + stored weights. + url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights' - depth: 2 title: Delete a balancer by id. url: '#delete-a-balancer-by-id' @@ -35,8 +36,9 @@ _openapi: every client that sits on at least one selected inbound. id: create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound - content: Update a balancer by id. Accepts the same form fields as create - (full-row update, including the enabled toggle). - id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle + (full-row update, including the enabled toggle); omitting + memberWeights clears stored weights. + id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights - content: Delete a balancer by id. id: delete-a-balancer-by-id - content: Delete a balancer by id (POST alias of DELETE for clients that cannot diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e29982637..909f0ce79 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3390,6 +3390,13 @@ }, "type": "array" }, + "memberWeights": { + "additionalProperties": { + "type": "number" + }, + "description": "inboundId -> leastLoad weight; absent entries mean 1.0. Only meaningful\nwith Strategy \"leastLoad\" — xray ignores costs on every other strategy.", + "type": "object" + }, "remark": { "example": "auto-fastest", "maxLength": 256, @@ -12388,6 +12395,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12435,6 +12443,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12452,7 +12461,7 @@ "tags": [ "Subscription Balancers" ], - "summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).", + "summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.", "operationId": "post_panel_api_sub_balancers_id", "parameters": [ { @@ -12494,6 +12503,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12551,6 +12561,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12610,6 +12621,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e29982637..909f0ce79 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -3390,6 +3390,13 @@ }, "type": "array" }, + "memberWeights": { + "additionalProperties": { + "type": "number" + }, + "description": "inboundId -> leastLoad weight; absent entries mean 1.0. Only meaningful\nwith Strategy \"leastLoad\" — xray ignores costs on every other strategy.", + "type": "object" + }, "remark": { "example": "auto-fastest", "maxLength": 256, @@ -12388,6 +12395,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12435,6 +12443,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12452,7 +12461,7 @@ "tags": [ "Subscription Balancers" ], - "summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).", + "summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.", "operationId": "post_panel_api_sub_balancers_id", "parameters": [ { @@ -12494,6 +12503,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12551,6 +12561,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", @@ -12610,6 +12621,7 @@ 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", diff --git a/frontend/src/api/queries/useSubBalancerMutations.ts b/frontend/src/api/queries/useSubBalancerMutations.ts index 5a12f2706..20b74eda5 100644 --- a/frontend/src/api/queries/useSubBalancerMutations.ts +++ b/frontend/src/api/queries/useSubBalancerMutations.ts @@ -4,15 +4,23 @@ import { HttpUtil } from '@/utils'; import { keys } from '@/api/queryKeys'; import type { SubBalancerFormValues } from '@/schemas/subBalancer'; -// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from -// repeated form keys, which is exactly how HttpUtil encodes arrays. +// Deliberately urlencoded: Go binds inboundIds from repeated form keys; weights +// go as one JSON string — gin cannot bind bracket-keyed maps from form bodies. +function toWirePayload(values: SubBalancerFormValues): Record { + const { memberWeights, ...rest } = values; + if (values.strategy === 'leastLoad' && memberWeights && Object.keys(memberWeights).length > 0) { + return { ...rest, memberWeights: JSON.stringify(memberWeights) }; + } + return rest; +} + export function useSubBalancerMutations() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() }); const createMut = useMutation({ mutationFn: (payload: SubBalancerFormValues) => - HttpUtil.post('/panel/api/sub-balancers', payload), + HttpUtil.post('/panel/api/sub-balancers', toWirePayload(payload)), onSuccess: (msg) => { if (msg?.success) invalidate(); }, @@ -20,7 +28,7 @@ export function useSubBalancerMutations() { const updateMut = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) => - HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload), + HttpUtil.post(`/panel/api/sub-balancers/${id}`, toWirePayload(payload)), onSuccess: (msg) => { if (msg?.success) invalidate(); }, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index a66eeec7b..4f2551c4c 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -813,6 +813,7 @@ export const EXAMPLES: Record = { 1, 3 ], + "memberWeights": {}, "remark": "auto-fastest", "sortOrder": 1, "strategy": "random", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 84c9820a5..e422e69db 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -3364,6 +3364,13 @@ export const SCHEMAS: Record = { }, "type": "array" }, + "memberWeights": { + "additionalProperties": { + "type": "number" + }, + "description": "inboundId -\u003e leastLoad weight; absent entries mean 1.0. Only meaningful\nwith Strategy \"leastLoad\" — xray ignores costs on every other strategy.", + "type": "object" + }, "remark": { "example": "auto-fastest", "maxLength": 256, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 215772233..84a3ec62a 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -768,6 +768,7 @@ export interface SubBalancer { enabled: boolean; id: number; inboundIds: number[]; + memberWeights?: Record; remark: string; sortOrder: number; strategy: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e95ea30b2..79b1ae646 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -819,6 +819,7 @@ export const SubBalancerSchema = z.object({ enabled: z.boolean(), id: z.number().int(), inboundIds: z.array(z.number().int()), + memberWeights: z.record(z.number().int(), z.number()).optional(), remark: z.string().max(256), sortOrder: z.number().int().min(1), strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index beff3de9e..f5217191b 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -2248,6 +2248,12 @@ export const sections: readonly Section[] = [ type: 'integer[]', desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).', }, + { + name: 'memberWeights', + in: 'body (form)', + type: 'object', + desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.', + }, { name: 'sortOrder', in: 'body (form)', @@ -2267,7 +2273,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/sub-balancers/:id', summary: - 'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).', + 'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.', params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }], responseSchema: 'SubBalancer', }, diff --git a/frontend/src/pages/settings/SubBalancerFormModal.tsx b/frontend/src/pages/settings/SubBalancerFormModal.tsx index 6151d21d8..9c81d684f 100644 --- a/frontend/src/pages/settings/SubBalancerFormModal.tsx +++ b/frontend/src/pages/settings/SubBalancerFormModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import SelectAllClearButtons from '@/components/form/SelectAllClearButtons'; @@ -38,6 +38,7 @@ function initialState(balancer: SubBalancer | null): SubBalancerFormValues { remark: balancer?.remark ?? '', strategy: balancer?.strategy ?? 'random', inboundIds: [...(balancer?.inboundIds ?? [])], + memberWeights: balancer?.memberWeights ? { ...balancer.memberWeights } : undefined, sortOrder: balancer?.sortOrder ?? 1, enabled: balancer?.enabled ?? true, }; @@ -66,6 +67,10 @@ export default function SubBalancerFormModal({ }, [open, balancer, methods]); const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' }); + const strategy = useWatch({ control: methods.control, name: 'strategy' }); + // Weights only make sense for leastLoad; the fields hide but keep their + // values so an accidental toggle away and back loses nothing until submit. + const showWeights = strategy === 'leastLoad'; const { data: inboundOptionsRaw } = useInboundOptions(); const inboundOptions = useMemo( @@ -82,7 +87,20 @@ export default function SubBalancerFormModal({ ); function onFinish(values: SubBalancerFormValues) { - const parsed = SubBalancerFormSchema.safeParse(values); + const candidate: SubBalancerFormValues = { ...values }; + if (candidate.memberWeights) { + const cleaned = Object.fromEntries( + Object.entries(candidate.memberWeights).filter( + ([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0, + ), + ); + candidate.memberWeights = Object.keys(cleaned).length > 0 ? cleaned : undefined; + } + // xray ignores costs on every strategy but leastLoad — never send them. + if (candidate.strategy !== 'leastLoad') { + delete candidate.memberWeights; + } + const parsed = SubBalancerFormSchema.safeParse(candidate); if (!parsed.success) { messageApi.error( t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'), @@ -158,6 +176,59 @@ export default function SubBalancerFormModal({ onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })} /> + {showWeights && (inboundIds ?? []).length > 0 && ( + +
+ {(inboundIds ?? []).map((id) => { + const option = inboundOptions.find((o) => o.value === id); + return ( +
+ + {option?.label ?? `#${id}`} + + ( + field.onChange(typeof v === 'number' ? v : undefined)} + /> + )} + /> +
+ ); + })} +
+
+ )} + leastLoad weight; absent members weigh 1.0. + memberWeights: z + .record( + z.string(), + z + .number({ message: 'pages.settings.subBalancers.errWeightPositive' }) + .positive('pages.settings.subBalancers.errWeightPositive'), + ) + .optional(), sortOrder: z .number({ message: 'pages.settings.subBalancers.errSortOrder' }) .int('pages.settings.subBalancers.errSortOrder') diff --git a/frontend/src/test/sub-balancer-form-modal.test.tsx b/frontend/src/test/sub-balancer-form-modal.test.tsx index da0a8ce8e..f334e31f2 100644 --- a/frontend/src/test/sub-balancer-form-modal.test.tsx +++ b/frontend/src/test/sub-balancer-form-modal.test.tsx @@ -64,6 +64,26 @@ function selectInbound(optionTitle: string) { fireEvent.keyDown(multi, { key: 'Escape' }); } +function selectStrategy(label: string) { + const single = Array.from(document.querySelectorAll('.ant-select')).find( + (s) => !s.classList.contains('ant-select-multiple'), + ); + if (!single) throw new Error('Strategy select not found'); + fireEvent.mouseDown(single as HTMLElement); + const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find( + (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === label, + ); + if (!option) throw new Error(`Strategy option '${label}' not found`); + fireEvent.click(option); + fireEvent.keyDown(single, { key: 'Escape' }); +} + +function weightInputs(): HTMLInputElement[] { + return Array.from( + document.querySelectorAll('.sub-balancer-weights .ant-input-number-input'), + ); +} + describe('SubBalancerFormModal', () => { it('shows no validation errors when freshly opened in add mode', () => { renderModal(null); @@ -133,4 +153,56 @@ describe('SubBalancerFormModal', () => { }); expect(inboundOptionTitles()).toContain('Disabled'); }); + + // Weights are a leastLoad-only xray knob; the inputs must not exist under + // other strategies rather than merely being hidden. + it('shows weight inputs for selected inbounds only under leastLoad', async () => { + const { onConfirm } = renderModal(null); + fireEvent.change(remarkInput(), { target: { value: 'weighted' } }); + selectInbound('First'); + selectInbound('Second'); + selectStrategy('Least load'); + await waitFor(() => expect(weightInputs()).toHaveLength(2)); + + fireEvent.change(weightInputs()[0], { target: { value: '0.5' } }); + fireEvent.click(primaryButton()); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ strategy: 'leastLoad', memberWeights: { '1': 0.5 } }), + ); + }); + + it('omits memberWeights when a non-leastLoad strategy is saved', async () => { + const { onConfirm } = renderModal(null); + fireEvent.change(remarkInput(), { target: { value: 'plain' } }); + selectInbound('First'); + selectStrategy('Least load'); + await waitFor(() => expect(weightInputs()).toHaveLength(1)); + fireEvent.change(weightInputs()[0], { target: { value: '0.5' } }); + selectStrategy('Random'); + await waitFor(() => expect(document.querySelector('.sub-balancer-weights')).toBeNull()); + fireEvent.click(primaryButton()); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + expect(onConfirm).toHaveBeenCalledWith({ + remark: 'plain', + strategy: 'random', + inboundIds: [1], + sortOrder: 1, + enabled: true, + }); + }); + + it('seeds weight values from the edited balancer', async () => { + renderModal({ + id: 9, + remark: 'existing', + strategy: 'leastLoad', + inboundIds: [2], + memberWeights: { '2': 1.5 }, + sortOrder: 1, + enabled: true, + }); + await waitFor(() => expect(weightInputs()).toHaveLength(1)); + expect(weightInputs()[0].value).toBe('1.5'); + }); }); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 5e2db9dc8..dc4852918 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -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"` diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 15cd824c0..26a0f8f44 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -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 diff --git a/internal/sub/sub_balancer_test.go b/internal/sub/sub_balancer_test.go index f295e1114..483a3f491 100644 --- a/internal/sub/sub_balancer_test.go +++ b/internal/sub/sub_balancer_test.go @@ -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) + } +} diff --git a/internal/web/controller/sub_balancer.go b/internal/web/controller/sub_balancer.go index f1d5725a7..a0debd244 100644 --- a/internal/web/controller/sub_balancer.go +++ b/internal/web/controller/sub_balancer.go @@ -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 } diff --git a/internal/web/service/sub_balancer.go b/internal/web/service/sub_balancer.go index 4ddcd83e1..9dac2b8bd 100644 --- a/internal/web/service/sub_balancer.go +++ b/internal/web/service/sub_balancer.go @@ -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 diff --git a/internal/web/service/sub_balancer_test.go b/internal/web/service/sub_balancer_test.go index 15be667e0..6dfdb52dc 100644 --- a/internal/web/service/sub_balancer_test.go +++ b/internal/web/service/sub_balancer_test.go @@ -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) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 0cc24065a..e6ef851f9 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.", "inbounds": "الإينبوندات", "inboundsCount": "{count} الإينبوندات", + "weights": "أوزان الأعضاء", + "weightsHelp": "لـ LeastLoad فقط: الوزن الأقل يُختار أكثر؛ العضو بدون قيمة وزنه 1.", "enabled": "مُفعّل", "empty": "لا يوجد موزّعات بعد", "deleteConfirm": "حذف هذا الموزّع؟", "errRemarkRequired": "الملاحظة مطلوبة", "errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل", "errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1", + "errWeightPositive": "يجب أن تكون الأوزان أكبر من 0", "toasts": { "list": "تعذّر عرض موزّعات الاشتراك", "create": "تعذّر إنشاء موزّع اشتراك", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index e5c9307e9..c18c9e3bf 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 5f978cc28..f84b58076 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index d18d1e606..378f7dde1 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "جایگاه در فهرست اشتراک، درهم‌تنیده با ترتیب اینباند‌ها؛ با شمارهٔ برابر، موزان‌کننده بعد از اینباند می‌آید.", "inbounds": "اینباند‌ها", "inboundsCount": "{count} اینباند‌ها", + "weights": "وزن اعضا", + "weightsHelp": "فقط برای LeastLoad: وزن کمتر بیشتر انتخاب می‌شود؛ عضوی که مقداری نداشته باشد وزن ۱ دارد.", "enabled": "فعال", "empty": "هنوز موزان‌کننده‌ای وجود ندارد", "deleteConfirm": "این موزان‌کننده حذف شود؟", "errRemarkRequired": "توضیح الزامی است", "errInboundsRequired": "حداقل یک اینباند انتخاب کنید", "errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد", + "errWeightPositive": "وزن‌ها باید بزرگ‌تر از ۰ باشند", "toasts": { "list": "فهرست‌سازی موزان‌کننده‌های اشتراک ناموفق بود", "create": "ایجاد موزان‌کننده اشتراک ناموفق بود", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 568bce8be..cb0b08d61 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 3ed9f5019..6ea8684da 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -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": "サブスクリプションバランサーの作成に失敗しました", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e79eec067..a4942f340 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index c9c0a3503..300139b49 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.", "inbounds": "Инбаунды", "inboundsCount": "{count} Инбаунды", + "weights": "Веса участников", + "weightsHelp": "Только для LeastLoad: чем меньше вес, тем чаще выбирается участник; без значения вес равен 1.", "enabled": "Включён", "empty": "Балансировщиков пока нет", "deleteConfirm": "Удалить этот балансировщик?", "errRemarkRequired": "Укажите примечание", "errInboundsRequired": "Выберите хотя бы один инбаунд", "errSortOrder": "Порядок — целое число ≥ 1", + "errWeightPositive": "Веса должны быть больше 0", "toasts": { "list": "Не удалось получить список балансировщиков подписки", "create": "Не удалось создать балансировщик подписки", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 5f2ecdf7e..184709591 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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ı", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9717949a9..6ceb6b85b 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.", "inbounds": "Інбаунди", "inboundsCount": "{count} Інбаунди", + "weights": "Ваги учасників", + "weightsHelp": "Лише для LeastLoad: чим менша вага, тим частіше обирається учасник; без значення вага дорівнює 1.", "enabled": "Увімкнено", "empty": "Балансувальників ще немає", "deleteConfirm": "Видалити цей балансувальник?", "errRemarkRequired": "Вкажіть примітку", "errInboundsRequired": "Виберіть хоча б один інбаунд", "errSortOrder": "Порядок — ціле число ≥ 1", + "errWeightPositive": "Ваги повинні бути більшими за 0", "toasts": { "list": "Не вдалося отримати список балансувальників підписки", "create": "Не вдалося створити балансувальник підписки", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 5eac0d2aa..631bc6ee2 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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ý", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index ea9ff6cbd..7f433947a 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。", "inbounds": "入站", "inboundsCount": "{count} 入站", + "weights": "成员权重", + "weightsHelp": "仅适用于 LeastLoad:权重越小越常被选中;未设置的成员权重为 1。", "enabled": "启用", "empty": "暂无均衡器", "deleteConfirm": "确定删除此均衡器?", "errRemarkRequired": "请填写备注", "errInboundsRequired": "请至少选择一个入站", "errSortOrder": "顺序必须为不小于 1 的整数", + "errWeightPositive": "权重必须大于 0", "toasts": { "list": "列出订阅均衡器失败", "create": "创建订阅均衡器失败", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index fdd510d2c..603516fd3 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1420,12 +1420,15 @@ "sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。", "inbounds": "入站", "inboundsCount": "{count} 入站", + "weights": "成員權重", + "weightsHelp": "僅適用於 LeastLoad:權重越小越常被選中;未設定的成員權重為 1。", "enabled": "啟用", "empty": "尚無平衡器", "deleteConfirm": "確定刪除此平衡器?", "errRemarkRequired": "請填寫備註", "errInboundsRequired": "請至少選擇一個入站", "errSortOrder": "順序必須為不小於 1 的整數", + "errWeightPositive": "權重必須大於 0", "toasts": { "list": "列出訂閱平衡器失敗", "create": "建立訂閱平衡器失敗",