Forráskód Böngészése

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 <[email protected]>
DIMFLIX 18 órája
szülő
commit
7100fbcd08
32 módosított fájl, 525 hozzáadás és 15 törlés
  1. 6 4
      docs/content/docs/en/reference/api/subscription-balancers.mdx
  2. 13 1
      docs/public/openapi.json
  3. 13 1
      frontend/public/openapi.json
  4. 12 4
      frontend/src/api/queries/useSubBalancerMutations.ts
  5. 1 0
      frontend/src/generated/examples.ts
  6. 7 0
      frontend/src/generated/schemas.ts
  7. 1 0
      frontend/src/generated/types.ts
  8. 1 0
      frontend/src/generated/zod.ts
  9. 7 1
      frontend/src/pages/api-docs/endpoints.ts
  10. 73 2
      frontend/src/pages/settings/SubBalancerFormModal.tsx
  11. 1 0
      frontend/src/pages/settings/SubscriptionBalancersTab.tsx
  12. 10 0
      frontend/src/schemas/subBalancer.ts
  13. 72 0
      frontend/src/test/sub-balancer-form-modal.test.tsx
  14. 4 1
      internal/database/model/model.go
  15. 43 1
      internal/sub/json_service.go
  16. 88 0
      internal/sub/sub_balancer_test.go
  17. 11 0
      internal/web/controller/sub_balancer.go
  18. 35 0
      internal/web/service/sub_balancer.go
  19. 88 0
      internal/web/service/sub_balancer_test.go
  20. 3 0
      internal/web/translation/ar-EG.json
  21. 3 0
      internal/web/translation/en-US.json
  22. 3 0
      internal/web/translation/es-ES.json
  23. 3 0
      internal/web/translation/fa-IR.json
  24. 3 0
      internal/web/translation/id-ID.json
  25. 3 0
      internal/web/translation/ja-JP.json
  26. 3 0
      internal/web/translation/pt-BR.json
  27. 3 0
      internal/web/translation/ru-RU.json
  28. 3 0
      internal/web/translation/tr-TR.json
  29. 3 0
      internal/web/translation/uk-UA.json
  30. 3 0
      internal/web/translation/vi-VN.json
  31. 3 0
      internal/web/translation/zh-CN.json
  32. 3 0
      internal/web/translation/zh-TW.json

+ 6 - 4
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

+ 13 - 1
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",

+ 13 - 1
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",

+ 12 - 4
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<string, unknown> {
+  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();
     },

+ 1 - 0
frontend/src/generated/examples.ts

@@ -813,6 +813,7 @@ export const EXAMPLES: Record<string, unknown> = {
       1,
       3
     ],
+    "memberWeights": {},
     "remark": "auto-fastest",
     "sortOrder": 1,
     "strategy": "random",

+ 7 - 0
frontend/src/generated/schemas.ts

@@ -3364,6 +3364,13 @@ export const SCHEMAS: Record<string, unknown> = {
         },
         "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,

+ 1 - 0
frontend/src/generated/types.ts

@@ -768,6 +768,7 @@ export interface SubBalancer {
   enabled: boolean;
   id: number;
   inboundIds: number[];
+  memberWeights?: Record<number, number>;
   remark: string;
   sortOrder: number;
   strategy: string;

+ 1 - 0
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']),

+ 7 - 1
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',
       },

+ 73 - 2
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 && (
+            <Form.Item
+              className="sub-balancer-weights"
+              label={t('pages.settings.subBalancers.weights')}
+              tooltip={t('pages.settings.subBalancers.weightsHelp')}
+              style={{ marginBottom: 16 }}
+            >
+              <div
+                style={{
+                  display: 'flex',
+                  flexDirection: 'column',
+                  gap: 8,
+                  maxHeight: 220,
+                  overflowY: 'auto',
+                  paddingRight: 4,
+                }}
+              >
+                {(inboundIds ?? []).map((id) => {
+                  const option = inboundOptions.find((o) => o.value === id);
+                  return (
+                    <div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+                      <span
+                        title={option?.title}
+                        style={{
+                          minWidth: 0,
+                          flex: 1,
+                          overflow: 'hidden',
+                          textOverflow: 'ellipsis',
+                        }}
+                      >
+                        {option?.label ?? `#${id}`}
+                      </span>
+                      <Controller
+                        control={methods.control}
+                        name={`memberWeights.${id}`}
+                        render={({ field }) => (
+                          <InputNumber
+                            min={0.1}
+                            step={0.1}
+                            precision={1}
+                            style={{ width: 120 }}
+                            value={(field.value as number | undefined) ?? 1}
+                            onChange={(v) => field.onChange(typeof v === 'number' ? v : undefined)}
+                          />
+                        )}
+                      />
+                    </div>
+                  );
+                })}
+              </div>
+            </Form.Item>
+          )}
+
           <FormField
             label={t('pages.settings.subBalancers.enabled')}
             name="enabled"

+ 1 - 0
frontend/src/pages/settings/SubscriptionBalancersTab.tsx

@@ -91,6 +91,7 @@ export default function SubscriptionBalancersTab({
       remark: balancer.remark,
       strategy: balancer.strategy,
       inboundIds: balancer.inboundIds,
+      memberWeights: balancer.memberWeights ?? undefined,
       sortOrder: balancer.sortOrder,
       enabled: !balancer.enabled,
     });

+ 10 - 0
frontend/src/schemas/subBalancer.ts

@@ -8,6 +8,7 @@ export const SubBalancerSchema = z.object({
   remark: z.string(),
   strategy: SubBalancerStrategySchema,
   inboundIds: z.array(z.number()),
+  memberWeights: z.record(z.string(), z.number()).nullish(),
   sortOrder: z.number(),
   enabled: z.boolean(),
   createdAt: z.number().optional(),
@@ -27,6 +28,15 @@ export const SubBalancerFormSchema = z.object({
   inboundIds: z
     .array(z.number().int().positive())
     .min(1, 'pages.settings.subBalancers.errInboundsRequired'),
+  // inboundId (stringified) -> 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')

+ 72 - 0
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<HTMLInputElement>('.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');
+  });
 });

+ 4 - 1
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"`

+ 43 - 1
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

+ 88 - 0
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)
+	}
+}

+ 11 - 0
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
 }
 

+ 35 - 0
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

+ 88 - 0
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)
+	}
+}

+ 3 - 0
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": "تعذّر إنشاء موزّع اشتراك",

+ 3 - 0
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",

+ 3 - 0
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",

+ 3 - 0
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": "ایجاد موزان‌کننده اشتراک ناموفق بود",

+ 3 - 0
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",

+ 3 - 0
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": "サブスクリプションバランサーの作成に失敗しました",

+ 3 - 0
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",

+ 3 - 0
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": "Не удалось создать балансировщик подписки",

+ 3 - 0
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ı",

+ 3 - 0
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": "Не вдалося створити балансувальник підписки",

+ 3 - 0
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ý",

+ 3 - 0
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": "创建订阅均衡器失败",

+ 3 - 0
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": "建立訂閱平衡器失敗",