Files
3x-ui/docs/content/docs/en/reference/api/subscription-balancers.mdx
T
DIMFLIX 7100fbcd08 feat(sub): leastLoad member weights for subscription balancers (#6304)
* feat(model): add MemberWeights to SubBalancer

Per-inbound leastLoad weights, stored with the same gorm json serializer
as InboundIds so AutoMigrate adds the text column on every dialect
(postgresModelSettled sees the missing column and re-runs). Absent
entries mean weight 1.0; only meaningful for strategy leastLoad.

* feat(sub): accept memberWeights on the sub-balancer API

Parsed as one JSON form field (gin cannot bind bracket-keyed maps from
urlencoded bodies). validate() rejects weights under any strategy but
leastLoad — xray would silently ignore costs there, so storing them
would pretend a knob exists. Non-positive weights error instead of
defaulting: a zero usually means a typo'd "never pick this node".
Entries for inbounds no longer selected are dropped on save.

* feat(sub): emit leastLoad strategy costs from member weights

costs[] is built after the tagging loop reuses the exact retagged tags
(bal-N-protocol[-k]) and each member's owning inbound id. Members
without a configured weight default to 1.0, but costs are omitted
entirely unless at least one explicit weight survives — an all-1.0
array would bloat every subscription response for no effect.

* feat(sub-balancers): leastLoad member weight inputs

Weight fields render only under leastLoad and hide on strategy change
without dropping their values, so an accidental toggle away and back
loses nothing until save; non-leastLoad submits strip them entirely
because xray would ignore costs. Weights travel as one JSON form field
(gin cannot bind bracket-keyed maps) and every locale gets the three
new keys in the same commit per the dead-keys rule.

* docs(api): document memberWeights on sub-balancers

leastLoad-only JSON form field; update notes that omitting it clears
stored weights. Regenerated openapi artifacts via make gen + the docs
copy/gen:api step nothing checks automatically.

* fix(api-docs): use the allowed object ParamType for memberWeights

* fix(sub-balancers): cap the member-weight list height

Many selected inbounds pushed the modal body past the viewport. The
weight rows now scroll inside a 220px viewport, mirroring the inbound
picker's listHeight so both lists read the same.

* fix(sub): anchor leastLoad cost matches to exact member tags

Verified against xray-core: without regexp, WeightManager matches costs
by substring (strings.Index), so the bare tag "bal-1-vless" also hits
the deduplicated "bal-1-vless-2" and both members get the first
entry's weight. Anchored ^tag$ regexps make every cost entry match only
its own member. Also confirmed value<=0 makes xray derive a weight from
the first digit of the matched tag — validating weights > 0 server-side
was the right call.

* fix(sub-balancers): keep member weights across the enabled toggle

The table's toggleEnabled re-posted a full-row payload without
memberWeights, and the update path treats an absent key as "erase" —
flipping the switch silently dropped every configured weight. Round-trip
the stored weights through the toggle payload, and prove persistence
with a re-Get in the weight-validation test (the returned struct alone
would stay green even if Save skipped the column).

* fix(sub-balancers): address review on member weights

- omitempty on MemberWeights: the panel sends null for every pre-existing
  and non-leastLoad balancer, which failed the hand-written zod response
  schema on every fetch (zod .optional() accepts undefined only; switched
  to .nullish() per repo convention) and drifted the generated contract.
  Regenerated openapi artifacts + docs copy + MDX.
- Bound weights to the positive float32 range: xray decodes costs as
  float32, so an over-range value makes clients reject the whole
  subscription document and an underflow decays to the tag-digit
  fallback weight. Tests for both directions.
- Trim six comment blocks to the 2-line cap from CLAUDE.md.

---------

Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
2026-09-02 20:21:27 +02:00

62 lines
3.4 KiB
Plaintext

---
title: Subscription Balancers
description: 'Client-side balancers for the JSON subscription: each enabled
balancer is emitted as one extra config document whose members are the proxy
outbounds of the selected inbounds (routing.balancers + burstObservatory).
Managed in Settings → Sub Balancers.'
full: true
_openapi:
preload:
- ./public/openapi.json
toc:
- depth: 2
title: List all subscription balancers in sort order (sort_order asc, id asc).
url: '#list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc'
- depth: 2
title: Create a subscription balancer. It appears in the JSON subscription of
every client that sits on at least one selected inbound.
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); 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'
- depth: 2
title: Delete a balancer by id (POST alias of DELETE for clients that cannot
send DELETE).
url: '#delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
structuredData:
headings:
- content: List all subscription balancers in sort order (sort_order asc, id asc).
id: list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc
- content: Create a subscription balancer. It appears in the JSON subscription of
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); 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
send DELETE).
id: delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
contents: []
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
</>
);
}