2 Commits 02002dc1c3 ... da01b7637d

Author SHA1 Message Date
  DIMFLIX da01b7637d feat(sub): client-side balancers for the JSON subscription (#6243) 13 hours ago
  Sanaei 81fcacab11 chore(build): bump Go toolchain to 1.27.0 14 hours ago
82 changed files with 3989 additions and 401 deletions
  1. 1 1
      .github/claude/repo-context.md
  2. 3 4
      CLAUDE.md
  3. 1 1
      CONTRIBUTING.md
  4. 1 1
      Dockerfile
  5. 2 2
      bot_context_test.go
  6. 1 1
      docs/architecture.md
  7. 3 7
      docs/content/docs/en/reference/api/api-tokens.mdx
  8. 89 56
      docs/content/docs/en/reference/api/clients.mdx
  9. 42 43
      docs/content/docs/en/reference/api/hosts.mdx
  10. 25 8
      docs/content/docs/en/reference/api/inbounds.mdx
  11. 1 0
      docs/content/docs/en/reference/api/meta.json
  12. 26 20
      docs/content/docs/en/reference/api/nodes.mdx
  13. 66 11
      docs/content/docs/en/reference/api/server.mdx
  14. 19 1
      docs/content/docs/en/reference/api/settings.mdx
  15. 60 0
      docs/content/docs/en/reference/api/subscription-balancers.mdx
  16. 8 6
      docs/content/docs/en/reference/api/subscription-server.mdx
  17. 47 13
      docs/content/docs/en/reference/api/xray-settings.mdx
  18. 2 0
      docs/lib/xray/subscription.ts
  19. 134 158
      docs/public/openapi.json
  20. 351 0
      frontend/public/openapi.json
  21. 41 0
      frontend/src/api/queries/useSubBalancerMutations.ts
  22. 31 0
      frontend/src/api/queries/useSubBalancersQuery.ts
  23. 4 0
      frontend/src/api/queryKeys.ts
  24. 15 0
      frontend/src/generated/examples.ts
  25. 73 0
      frontend/src/generated/schemas.ts
  26. 13 0
      frontend/src/generated/types.ts
  27. 14 0
      frontend/src/generated/zod.ts
  28. 10 1
      frontend/src/layouts/AppSidebar.tsx
  29. 1 0
      frontend/src/models/setting.ts
  30. 78 0
      frontend/src/pages/api-docs/endpoints.ts
  31. 4 0
      frontend/src/pages/settings/SettingsPage.tsx
  32. 172 0
      frontend/src/pages/settings/SubBalancerFormModal.tsx
  33. 359 0
      frontend/src/pages/settings/SubscriptionBalancersTab.tsx
  34. 1 1
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  35. 1 1
      frontend/src/schemas/observatory.ts
  36. 1 0
      frontend/src/schemas/setting.ts
  37. 36 0
      frontend/src/schemas/subBalancer.ts
  38. 136 0
      frontend/src/test/sub-balancer-form-modal.test.tsx
  39. 5 5
      go.mod
  40. 1 0
      internal/database/db.go
  41. 1 0
      internal/database/migrate_data.go
  42. 15 0
      internal/database/model/model.go
  43. 1 1
      internal/pia/catalog_test.go
  44. 1 1
      internal/pia/validation.go
  45. 8 1
      internal/sub/controller.go
  46. 313 11
      internal/sub/json_service.go
  47. 3 2
      internal/sub/mutation_audit_test.go
  48. 2 2
      internal/sub/remote_routing.go
  49. 6 0
      internal/sub/sub.go
  50. 60 0
      internal/sub/sub_balancer_protocol_tag_test.go
  51. 422 0
      internal/sub/sub_balancer_test.go
  52. 53 0
      internal/sub/sub_json_observatory_test.go
  53. 6 3
      internal/web/controller/api.go
  54. 126 0
      internal/web/controller/sub_balancer.go
  55. 105 0
      internal/web/controller/sub_balancer_test.go
  56. 1 0
      internal/web/entity/entity.go
  57. 4 5
      internal/web/job/ip_limit_allowlist.go
  58. 2 2
      internal/web/network/auto_https_conn.go
  59. 3 7
      internal/web/service/client_external_link_test.go
  60. 5 6
      internal/web/service/client_test.go
  61. 17 0
      internal/web/service/inbound.go
  62. 1 1
      internal/web/service/inbound_autorenew_calendar_test.go
  63. 5 0
      internal/web/service/setting.go
  64. 1 1
      internal/web/service/setting_mtls_test.go
  65. 101 0
      internal/web/service/sub_balancer.go
  66. 36 0
      internal/web/service/sub_balancer_inbound_cleanup_test.go
  67. 161 0
      internal/web/service/sub_balancer_test.go
  68. 50 1
      internal/web/translation/ar-EG.json
  69. 50 1
      internal/web/translation/en-US.json
  70. 50 1
      internal/web/translation/es-ES.json
  71. 50 1
      internal/web/translation/fa-IR.json
  72. 50 1
      internal/web/translation/id-ID.json
  73. 50 1
      internal/web/translation/ja-JP.json
  74. 50 1
      internal/web/translation/pt-BR.json
  75. 50 1
      internal/web/translation/ru-RU.json
  76. 50 1
      internal/web/translation/tr-TR.json
  77. 50 1
      internal/web/translation/uk-UA.json
  78. 50 1
      internal/web/translation/vi-VN.json
  79. 50 1
      internal/web/translation/zh-CN.json
  80. 50 1
      internal/web/translation/zh-TW.json
  81. 1 4
      internal/xray/geodata/query.go
  82. 1 0
      tools/openapigen/main.go

+ 1 - 1
.github/claude/repo-context.md

@@ -15,7 +15,7 @@ question it already answers.
 
 3x-ui is an open-source web control panel for managing Xray-core servers.
 
-- Backend: Go 1.26, module `github.com/mhsanaei/3x-ui/v3`, Gin and GORM.
+- Backend: Go 1.27, module `github.com/mhsanaei/3x-ui/v3`, Gin and GORM.
 - It runs Xray-core as a managed child process (`internal/xray/process.go`) and
   imports `github.com/xtls/xray-core` for config types and the gRPC
   stats/handler/router API. The release the panel BUNDLES is pinned in

+ 3 - 4
CLAUDE.md

@@ -8,7 +8,7 @@ index, layering rules), read `docs/architecture.md` on demand — do not guess
 file locations when it can answer in one hop.
 
 ## Stack
-- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
+- Backend: Go 1.27 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
   Runs Xray-core as a managed child process (`internal/xray/process.go`) and
   imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
   API. MTProto inbounds run a second managed child — the `mtg-multi` binary
@@ -125,7 +125,7 @@ file locations when it can answer in one hop.
 
 ## Frontend conventions (summary; full version in frontend/CLAUDE.md)
 - Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
-- TS strict; oxlint's `typescript/no-explicit-any` is an error. Zod schemas in
+- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
   `src/schemas/` are the source of truth; infer types with `z.infer`, never
   hand-write. Do not edit `src/generated/`.
 - Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
@@ -147,8 +147,7 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
 `make` Go target already depends on it, which is why `make test-go` beats
 `go test ./...`. Run `make help` for all targets. The local gate:
 
-    make verify   # gen-check + lint + format-check + typecheck + test + build
-                  # + build-storybook
+    make verify   # gen-check + lint + typecheck + test + build + build-storybook
 
 That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
 `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a

+ 1 - 1
CONTRIBUTING.md

@@ -4,7 +4,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
 
 ## Prerequisites
 
-- **Go 1.26+** (the version pinned in `go.mod`)
+- **Go 1.27+** (the version pinned in `go.mod`)
 - **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
 - **Git**
 - **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.

+ 1 - 1
Dockerfile

@@ -12,7 +12,7 @@ RUN npm run build
 # ========================================================
 # Stage: Builder
 # ========================================================
-FROM golang:1.26-alpine AS builder
+FROM golang:1.27-alpine AS builder
 WORKDIR /app
 ARG TARGETARCH
 

+ 2 - 2
bot_context_test.go

@@ -37,8 +37,8 @@ func section(t *testing.T, doc, from, to string) string {
 		t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
 	}
 	rest := doc[i+len(from):]
-	if j := strings.Index(rest, to); j >= 0 {
-		return rest[:j]
+	if before, _, ok := strings.Cut(rest, to); ok {
+		return before
 	}
 	return rest
 }

+ 1 - 1
docs/architecture.md

@@ -51,7 +51,7 @@ Two key ideas that explain most of the complexity:
 
 ## 2. Tech stack
 
-**Backend (Go 1.26):**
+**Backend (Go 1.27):**
 
 - Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
 - ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).

+ 3 - 7
docs/content/docs/en/reference/api/api-tokens.mdx

@@ -1,12 +1,8 @@
 ---
 title: API Tokens
-description: 'Manage Bearer tokens used for programmatic auth (bots, central
-  panels acting on this node, CI). Each token has a unique name and an enabled
-  flag — disable to revoke without deleting, delete to revoke permanently.
-  Tokens are stored as SHA-256 hashes and the plaintext is returned only once,
-  in the create response — it cannot be retrieved afterwards, so copy it then.
-  Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any
-  /panel/api/* request — the token is a full-admin credential.'
+description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
+  admin, monitor, or node-sync access, may expire, and are stored as SHA-256
+  hashes. The plaintext is returned only once at creation.
 full: true
 _openapi:
   preload:

File diff suppressed because it is too large
+ 89 - 56
docs/content/docs/en/reference/api/clients.mdx


+ 42 - 43
docs/content/docs/en/reference/api/hosts.mdx

@@ -13,66 +13,65 @@ _openapi:
         sort order.
       url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
     - depth: 2
-      title: Fetch a single host by ID.
-      url: '#fetch-a-single-host-by-id'
+      title: Fetch a single host group by Group ID.
+      url: '#fetch-a-single-host-group-by-group-id'
     - depth: 2
-      title: Fetch one inbound's hosts, ordered by sort order then id.
-      url: '#fetch-one-inbounds-hosts-ordered-by-sort-order-then-id'
+      title: Fetch one inbound's hosts, grouped by host group.
+      url: '#fetch-one-inbounds-hosts-grouped-by-host-group'
     - depth: 2
       title: Distinct, sorted set of tags used across all hosts.
       url: '#distinct-sorted-set-of-tags-used-across-all-hosts'
     - depth: 2
-      title: Create a host on an inbound. inboundId and remark are required; security
-        defaults to "same" (inherit the inbound).
-      url: '#create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound'
+      title: Create a host group on inbounds.
+      url: '#create-a-host-group-on-inbounds'
     - depth: 2
-      title: Replace a host’s content. The inbound and sort order are immutable here
-        (use /reorder for ordering).
-      url: '#replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering'
+      title: Replace a host group’s content.
+      url: '#replace-a-host-groups-content'
     - depth: 2
-      title: Delete a host.
-      url: '#delete-a-host'
+      title: Delete a host group.
+      url: '#delete-a-host-group'
     - depth: 2
-      title: Enable or disable a single host (disabled hosts are skipped in
-        subscriptions).
-      url: '#enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions'
+      title: Enable or disable a host group.
+      url: '#enable-or-disable-a-host-group'
     - depth: 2
-      title: Set host sort order by the position of each id in the array.
-      url: '#set-host-sort-order-by-the-position-of-each-id-in-the-array'
+      title: Set host group sort order by the position of each groupId in the array.
+      url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
     - depth: 2
-      title: Enable or disable many hosts in one call.
-      url: '#enable-or-disable-many-hosts-in-one-call'
+      title: Add a host group to inbounds (same as /add).
+      url: '#add-a-host-group-to-inbounds-same-as-add'
     - depth: 2
-      title: Delete many hosts in one call.
-      url: '#delete-many-hosts-in-one-call'
+      title: Enable or disable many host groups in one call.
+      url: '#enable-or-disable-many-host-groups-in-one-call'
+    - depth: 2
+      title: Delete many host groups in one call.
+      url: '#delete-many-host-groups-in-one-call'
   structuredData:
     headings:
       - content: List every host across all inbounds, grouped by inbound then ordered by
           sort order.
         id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
-      - content: Fetch a single host by ID.
-        id: fetch-a-single-host-by-id
-      - content: Fetch one inbound's hosts, ordered by sort order then id.
-        id: fetch-one-inbounds-hosts-ordered-by-sort-order-then-id
+      - content: Fetch a single host group by Group ID.
+        id: fetch-a-single-host-group-by-group-id
+      - content: Fetch one inbound's hosts, grouped by host group.
+        id: fetch-one-inbounds-hosts-grouped-by-host-group
       - content: Distinct, sorted set of tags used across all hosts.
         id: distinct-sorted-set-of-tags-used-across-all-hosts
-      - content: Create a host on an inbound. inboundId and remark are required;
-          security defaults to "same" (inherit the inbound).
-        id: create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound
-      - content: Replace a host’s content. The inbound and sort order are immutable here
-          (use /reorder for ordering).
-        id: replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering
-      - content: Delete a host.
-        id: delete-a-host
-      - content: Enable or disable a single host (disabled hosts are skipped in
-          subscriptions).
-        id: enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions
-      - content: Set host sort order by the position of each id in the array.
-        id: set-host-sort-order-by-the-position-of-each-id-in-the-array
-      - content: Enable or disable many hosts in one call.
-        id: enable-or-disable-many-hosts-in-one-call
-      - content: Delete many hosts in one call.
-        id: delete-many-hosts-in-one-call
+      - content: Create a host group on inbounds.
+        id: create-a-host-group-on-inbounds
+      - content: Replace a host group’s content.
+        id: replace-a-host-groups-content
+      - content: Delete a host group.
+        id: delete-a-host-group
+      - content: Enable or disable a host group.
+        id: enable-or-disable-a-host-group
+      - content: Set host group sort order by the position of each groupId in the array.
+        id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
+      - content: Add a host group to inbounds (same as /add).
+        id: add-a-host-group-to-inbounds-same-as-add
+      - content: Enable or disable many host groups in one call.
+        id: enable-or-disable-many-host-groups-in-one-call
+      - content: Delete many host groups in one call.
+        id: delete-many-host-groups-in-one-call
     contents: []
 ---
 
@@ -85,7 +84,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{id}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
     </>
   );
 }

+ 25 - 8
docs/content/docs/en/reference/api/inbounds.mdx

@@ -33,6 +33,15 @@ _openapi:
         clientStats so the payload stays small even on panels with thousands of
         clients.
       url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
+    - depth: 2
+      title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
+        hysteria://, mtproto) across all inbounds and all of their clients.
+        Links are rendered through the subscription engine, so the configured
+        remark template (name-only display part) is applied per client — the
+        same output the client info/QR pages use. Protocols without a URL form
+        (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
+        Used by the panel’s "Export all inbound links" action.
+      url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action'
     - depth: 2
       title: Fetch a single inbound by numeric ID.
       url: '#fetch-a-single-inbound-by-numeric-id'
@@ -59,6 +68,10 @@ _openapi:
       title: Toggle only the enable flag without serialising the whole settings JSON.
         Recommended for UI switches on large inbounds.
       url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
+    - depth: 2
+      title: Set only the subscription sort order. Reads the stored inbound, so a
+        reorder cannot carry a stale client list over a concurrent edit.
+      url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
     - depth: 2
       title: Zero out upload + download counters for a single inbound. Does not touch
         per-client counters.
@@ -94,10 +107,6 @@ _openapi:
       title: Replace the entire fallback list for a master inbound. Body is JSON.
         Triggers an Xray restart.
       url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
-    - depth: 2
-      title: Set only the subscription sort order. Reads the stored inbound, so a
-        reorder cannot carry a stale client list over a concurrent edit.
-      url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
   structuredData:
     headings:
       - content: List every inbound owned by the authenticated user, including each
@@ -121,6 +130,14 @@ _openapi:
           clientStats so the payload stays small even on panels with thousands
           of clients.
         id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
+      - content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
+          hysteria://, mtproto) across all inbounds and all of their clients.
+          Links are rendered through the subscription engine, so the configured
+          remark template (name-only display part) is applied per client — the
+          same output the client info/QR pages use. Protocols without a URL form
+          (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
+          Used by the panel’s "Export all inbound links" action.
+        id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action
       - content: Fetch a single inbound by numeric ID.
         id: fetch-a-single-inbound-by-numeric-id
       - content: Create a new inbound. Send the full inbound payload (protocol, port,
@@ -141,6 +158,9 @@ _openapi:
       - content: Toggle only the enable flag without serialising the whole settings
           JSON. Recommended for UI switches on large inbounds.
         id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
+      - content: Set only the subscription sort order. Reads the stored inbound, so a
+          reorder cannot carry a stale client list over a concurrent edit.
+        id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
       - content: Zero out upload + download counters for a single inbound. Does not
           touch per-client counters.
         id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
@@ -169,9 +189,6 @@ _openapi:
       - content: Replace the entire fallback list for a master inbound. Body is JSON.
           Triggers an Xray restart.
         id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
-      - content: Set only the subscription sort order. Reads the stored inbound, so a
-          reorder cannot carry a stale client list over a concurrent edit.
-        id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
     contents: []
 ---
 
@@ -184,7 +201,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
     </>
   );
 }

+ 1 - 0
docs/content/docs/en/reference/api/meta.json

@@ -11,6 +11,7 @@
     "settings",
     "xray-settings",
     "subscription-server",
+    "subscription-balancers",
     "hosts",
     "nodes",
     "backup",

+ 26 - 20
docs/content/docs/en/reference/api/nodes.mdx

@@ -22,6 +22,11 @@ _openapi:
         CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
         must be a PEM certificate. Applied on the next panel restart.
       url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
+    - depth: 2
+      title: Validate the stored master mTLS client credential and invalidate cached
+        transports. Each transport closes its old idle pool and rebuilds with
+        the rotated certificate before its next request.
+      url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
     - depth: 2
       title: Fetch a single node by ID.
       url: '#fetch-a-single-node-by-id'
@@ -32,12 +37,15 @@ _openapi:
         panel.
       url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
     - depth: 2
-      title: Register a new remote node. Provide its URL, apiToken, and optional
-        remark / allowPrivateAddress flag.
-      url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
+      title: Register a new remote node. Provide its URL, write-only apiToken, and
+        optional remark / allowPrivateAddress flag. Responses expose hasApiToken
+        only.
+      url: '#register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only'
     - depth: 2
-      title: Replace a node’s connection details. Same body shape as /add.
-      url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
+      title: 'Replace a node’s connection details. apiToken is write-only: omit it or
+        send an empty string to keep the stored token; set clearApiToken=true to
+        clear it.'
+      url: '#replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it'
     - depth: 2
       title: Delete a node. Inbounds bound to it are not auto-migrated.
       url: '#delete-a-node-inbounds-bound-to-it-are-not-auto-migrated'
@@ -72,11 +80,6 @@ _openapi:
       title: Aggregated metric history for a node — same shape as /server/history,
         scoped to one node.
       url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
-    - depth: 2
-      title: Validate the stored master mTLS client credential and invalidate cached
-        transports. Each transport closes its old idle pool and rebuilds with
-        the rotated certificate before its next request.
-      url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
   structuredData:
     headings:
       - content: List every configured node with its connection details, health, and
@@ -91,6 +94,10 @@ _openapi:
           CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
           value must be a PEM certificate. Applied on the next panel restart.
         id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
+      - content: Validate the stored master mTLS client credential and invalidate cached
+          transports. Each transport closes its old idle pool and rebuilds with
+          the rotated certificate before its next request.
+        id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
       - content: Fetch a single node by ID.
         id: fetch-a-single-node-by-id
       - content: Fetch a node's own web TLS certificate/key file paths (proxied to the
@@ -98,11 +105,14 @@ _openapi:
           node-assigned inbound gets paths that exist on the node, not the
           central panel.
         id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
-      - content: Register a new remote node. Provide its URL, apiToken, and optional
-          remark / allowPrivateAddress flag.
-        id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
-      - content: Replace a node’s connection details. Same body shape as /add.
-        id: replace-a-nodes-connection-details-same-body-shape-as-add
+      - content: Register a new remote node. Provide its URL, write-only apiToken, and
+          optional remark / allowPrivateAddress flag. Responses expose
+          hasApiToken only.
+        id: register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only
+      - content: 'Replace a node’s connection details. apiToken is write-only: omit it
+          or send an empty string to keep the stored token; set
+          clearApiToken=true to clear it.'
+        id: replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it
       - content: Delete a node. Inbounds bound to it are not auto-migrated.
         id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
       - content: Pause or resume traffic sync with this node.
@@ -129,10 +139,6 @@ _openapi:
       - content: Aggregated metric history for a node — same shape as /server/history,
           scoped to one node.
         id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
-      - content: Validate the stored master mTLS client credential and invalidate cached
-          transports. Each transport closes its old idle pool and rebuilds with
-          the rotated certificate before its next request.
-        id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
     contents: []
 ---
 
@@ -145,7 +151,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
     </>
   );
 }

File diff suppressed because it is too large
+ 66 - 11
docs/content/docs/en/reference/api/server.mdx


+ 19 - 1
docs/content/docs/en/reference/api/settings.mdx

@@ -15,11 +15,21 @@ _openapi:
       title: Return the computed default settings based on the request host. Useful to
         preview what a fresh install would use.
       url: '#return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use'
+    - depth: 2
+      title: Return the shipped (factory) default value per browser-safe setting key,
+        so clients can tell a stored value apart from the default it would fall
+        back to. Per-install material (secret, panelGuid, mTLS keys) and
+        credential fields are never included.
+      url: '#return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included'
     - depth: 2
       title: Persist every setting at once. The body mirrors the shape returned by
         /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected
         before write.
       url: '#persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write'
+    - depth: 2
+      title: Validate any regular expression with the backend Go RE2 compiler without
+        saving it.
+      url: '#validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it'
     - depth: 2
       title: Change the panel admin username and password. Requires the current
         credentials for verification. The session is refreshed with the new
@@ -50,10 +60,18 @@ _openapi:
       - content: Return the computed default settings based on the request host. Useful
           to preview what a fresh install would use.
         id: return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use
+      - content: Return the shipped (factory) default value per browser-safe setting
+          key, so clients can tell a stored value apart from the default it
+          would fall back to. Per-install material (secret, panelGuid, mTLS
+          keys) and credential fields are never included.
+        id: return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included
       - content: Persist every setting at once. The body mirrors the shape returned by
           /all. Invalid values (bad ports, missing cert pairs, etc.) are
           rejected before write.
         id: persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write
+      - content: Validate any regular expression with the backend Go RE2 compiler
+          without saving it.
+        id: validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it
       - content: Change the panel admin username and password. Requires the current
           credentials for verification. The session is refreshed with the new
           values on success.
@@ -83,7 +101,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
     </>
   );
 }

+ 60 - 0
docs/content/docs/en/reference/api/subscription-balancers.mdx

@@ -0,0 +1,60 @@
+---
+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).
+      url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle'
+    - 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).
+        id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle
+      - 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 />
+    </>
+  );
+}

+ 8 - 6
docs/content/docs/en/reference/api/subscription-server.mdx

@@ -13,9 +13,10 @@ _openapi:
     - depth: 2
       title: 'Return base64-encoded subscription links for all enabled clients
         matching the subscription ID. When the request has an Accept: text/html
-        header or ?html=1, renders a styled info page instead. Default path:
-        /sub/:subid.'
-      url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid'
+        header or ?html=1, renders a styled info page instead. With
+        ?format=info, returns the page view-model as JSON (traffic, expiry,
+        online status; no links) for live polling. Default path: /sub/:subid.'
+      url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid'
     - depth: 2
       title: 'Return subscription as a JSON array of proxy configs (one per enabled
         client). Only when JSON subscription is enabled in settings. Default
@@ -30,9 +31,10 @@ _openapi:
     headings:
       - content: 'Return base64-encoded subscription links for all enabled clients
           matching the subscription ID. When the request has an Accept:
-          text/html header or ?html=1, renders a styled info page instead.
-          Default path: /sub/:subid.'
-        id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+          text/html header or ?html=1, renders a styled info page instead. With
+          ?format=info, returns the page view-model as JSON (traffic, expiry,
+          online status; no links) for live polling. Default path: /sub/:subid.'
+        id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid
       - content: 'Return subscription as a JSON array of proxy configs (one per enabled
           client). Only when JSON subscription is enabled in settings. Default
           path: /json/:subid.'

+ 47 - 13
docs/content/docs/en/reference/api/xray-settings.mdx

@@ -1,8 +1,7 @@
 ---
 title: Xray Settings
-description: >-
-  Xray configuration template, outbound management, Warp/Nord/PIA integration, and
-  config testing. All endpoints under /panel/api/xray.
+description: Xray configuration template, outbound management, Warp/Nord/PIA
+  integration, and config testing. All endpoints under /panel/api/xray.
 full: true
 _openapi:
   preload:
@@ -37,7 +36,8 @@ _openapi:
       title: Manage NordVPN integration. The action parameter selects the operation.
       url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation'
     - depth: 2
-      title: Manage PIA WireGuard integration. The action parameter selects the operation.
+      title: Manage PIA WireGuard integration. The action parameter selects the
+        operation.
       url: '#manage-pia-wireguard-integration-the-action-parameter-selects-the-operation'
     - depth: 2
       title: Reset traffic counters for a specific outbound by tag.
@@ -66,6 +66,25 @@ _openapi:
       title: Ask the running core which outbound its router would pick for a synthetic
         connection (RoutingService.TestRoute). No traffic is sent.
       url: '#ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent'
+    - depth: 2
+      title: List the geo databases (.dat files) in the Xray asset folder, with the
+        layout detected from their contents, size, modification time and
+        category count. A database that fails to parse is still listed, with the
+        reason in "error".
+      url: '#list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error'
+    - depth: 2
+      title: One page of a database's categories, each with its entry count and the
+        attributes its domains carry (e.g. "ads", "cn").
+      url: '#one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn'
+    - depth: 2
+      title: One page of the rules inside a category — domain rules typed as
+        domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.
+      url: '#one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones'
+    - depth: 2
+      title: 'Check routing tokens against the databases on disk and return only the
+        ones that do not resolve. Plain domains and CIDRs are ignored. Each
+        issue carries a reason: syntax, fileMissing or categoryMissing.'
+      url: '#check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing'
     - depth: 2
       title: List all outbound subscriptions (remote URLs that supply additional
         outbounds), newest first.
@@ -83,9 +102,9 @@ _openapi:
       title: Delete an outbound subscription by id.
       url: '#delete-an-outbound-subscription-by-id'
     - depth: 2
-      title: Delete an outbound subscription by id (POST alias of DELETE for
-        axios-friendly clients).
-      url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients'
+      title: Delete an outbound subscription by id (POST alias of DELETE for clients
+        that cannot send DELETE).
+      url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
     - depth: 2
       title: Force an immediate re-fetch of the subscription and return the parsed
         outbounds. Signals Xray to reload.
@@ -121,8 +140,7 @@ _openapi:
         id: manage-cloudflare-warp-integration-the-action-parameter-selects-the-operation
       - content: Manage NordVPN integration. The action parameter selects the operation.
         id: manage-nordvpn-integration-the-action-parameter-selects-the-operation
-      - content: >-
-          Manage PIA WireGuard integration. The action parameter selects the
+      - content: Manage PIA WireGuard integration. The action parameter selects the
           operation.
         id: manage-pia-wireguard-integration-the-action-parameter-selects-the-operation
       - content: Reset traffic counters for a specific outbound by tag.
@@ -147,6 +165,22 @@ _openapi:
       - content: Ask the running core which outbound its router would pick for a
           synthetic connection (RoutingService.TestRoute). No traffic is sent.
         id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent
+      - content: List the geo databases (.dat files) in the Xray asset folder, with the
+          layout detected from their contents, size, modification time and
+          category count. A database that fails to parse is still listed, with
+          the reason in "error".
+        id: list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error
+      - content: One page of a database's categories, each with its entry count and the
+          attributes its domains carry (e.g. "ads", "cn").
+        id: one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn
+      - content: One page of the rules inside a category — domain rules typed as
+          domain/full/keyword/regexp for geosite databases, CIDRs for geoip
+          ones.
+        id: one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones
+      - content: 'Check routing tokens against the databases on disk and return only the
+          ones that do not resolve. Plain domains and CIDRs are ignored. Each
+          issue carries a reason: syntax, fileMissing or categoryMissing.'
+        id: check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing
       - content: List all outbound subscriptions (remote URLs that supply additional
           outbounds), newest first.
         id: list-all-outbound-subscriptions-remote-urls-that-supply-additional-outbounds-newest-first
@@ -159,9 +193,9 @@ _openapi:
         id: update-an-existing-outbound-subscription-by-id-accepts-the-same-form-fields-as-create
       - content: Delete an outbound subscription by id.
         id: delete-an-outbound-subscription-by-id
-      - content: Delete an outbound subscription by id (POST alias of DELETE for
-          axios-friendly clients).
-        id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients
+      - content: Delete an outbound subscription by id (POST alias of DELETE for clients
+          that cannot send DELETE).
+        id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
       - content: Force an immediate re-fetch of the subscription and return the parsed
           outbounds. Signals Xray to reload.
         id: force-an-immediate-re-fetch-of-the-subscription-and-return-the-parsed-outbounds-signals-xray-to-reload
@@ -183,7 +217,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 0
docs/lib/xray/subscription.ts

@@ -241,6 +241,8 @@ function proxyOutbound(c: SubClient): Record<string, unknown> {
   };
 }
 
+// Mirrors the one-document-per-client model only; the panel also emits
+// balancer documents (sub_balancers) that are intentionally out of scope here.
 function jsonConfig(c: SubClient): Record<string, unknown> {
   return {
     remarks: c.remark,

File diff suppressed because it is too large
+ 134 - 158
docs/public/openapi.json


+ 351 - 0
frontend/public/openapi.json

@@ -241,6 +241,9 @@
           "subJsonMux": {
             "type": "string"
           },
+          "subJsonObservatory": {
+            "type": "string"
+          },
           "subJsonPath": {
             "type": "string"
           },
@@ -438,6 +441,7 @@
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
+          "subJsonObservatory",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
@@ -716,6 +720,9 @@
           "subJsonMux": {
             "type": "string"
           },
+          "subJsonObservatory": {
+            "type": "string"
+          },
           "subJsonPath": {
             "type": "string"
           },
@@ -920,6 +927,7 @@
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
+          "subJsonObservatory",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
@@ -3091,6 +3099,71 @@
         ],
         "type": "object"
       },
+      "SubBalancer": {
+        "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
+        "properties": {
+          "createdAt": {
+            "example": 1710000000000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "enabled": {
+            "description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
+            "example": true,
+            "type": "boolean"
+          },
+          "id": {
+            "example": 1,
+            "type": "integer"
+          },
+          "inboundIds": {
+            "example": [
+              1,
+              3
+            ],
+            "items": {
+              "type": "integer"
+            },
+            "type": "array"
+          },
+          "remark": {
+            "example": "auto-fastest",
+            "maxLength": 256,
+            "type": "string"
+          },
+          "sortOrder": {
+            "example": 1,
+            "minimum": 1,
+            "type": "integer"
+          },
+          "strategy": {
+            "enum": [
+              "leastLoad",
+              "leastPing",
+              "random",
+              "roundRobin"
+            ],
+            "example": "random",
+            "type": "string"
+          },
+          "updatedAt": {
+            "example": 1710000000000,
+            "format": "int64",
+            "type": "integer"
+          }
+        },
+        "required": [
+          "createdAt",
+          "enabled",
+          "id",
+          "inboundIds",
+          "remark",
+          "sortOrder",
+          "strategy",
+          "updatedAt"
+        ],
+        "type": "object"
+      },
       "User": {
         "description": "User represents a user account in the 3x-ui panel.",
         "properties": {
@@ -3162,6 +3235,10 @@
       "name": "Xray Settings",
       "description": "Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray."
     },
+    {
+      "name": "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."
+    },
     {
       "name": "Subscription Server",
       "description": "A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info."
@@ -11930,6 +12007,280 @@
         }
       }
     },
+    "/panel/api/sub-balancers": {
+      "get": {
+        "tags": [
+          "Subscription Balancers"
+        ],
+        "summary": "List all subscription balancers in sort order (sort_order asc, id asc).",
+        "operationId": "get_panel_api_sub_balancers",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "type": "array",
+                      "items": {
+                        "$ref": "#/components/schemas/SubBalancer"
+                      }
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "createdAt": 1710000000000,
+                      "enabled": true,
+                      "id": 1,
+                      "inboundIds": [
+                        1,
+                        3
+                      ],
+                      "remark": "auto-fastest",
+                      "sortOrder": 1,
+                      "strategy": "random",
+                      "updatedAt": 1710000000000
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "tags": [
+          "Subscription Balancers"
+        ],
+        "summary": "Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.",
+        "operationId": "post_panel_api_sub_balancers",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SubBalancer"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "createdAt": 1710000000000,
+                    "enabled": true,
+                    "id": 1,
+                    "inboundIds": [
+                      1,
+                      3
+                    ],
+                    "remark": "auto-fastest",
+                    "sortOrder": 1,
+                    "strategy": "random",
+                    "updatedAt": 1710000000000
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/sub-balancers/{id}": {
+      "post": {
+        "tags": [
+          "Subscription Balancers"
+        ],
+        "summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).",
+        "operationId": "post_panel_api_sub_balancers_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Balancer id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SubBalancer"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "createdAt": 1710000000000,
+                    "enabled": true,
+                    "id": 1,
+                    "inboundIds": [
+                      1,
+                      3
+                    ],
+                    "remark": "auto-fastest",
+                    "sortOrder": 1,
+                    "strategy": "random",
+                    "updatedAt": 1710000000000
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Subscription Balancers"
+        ],
+        "summary": "Delete a balancer by id.",
+        "operationId": "delete_panel_api_sub_balancers_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Balancer id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SubBalancer"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "createdAt": 1710000000000,
+                    "enabled": true,
+                    "id": 1,
+                    "inboundIds": [
+                      1,
+                      3
+                    ],
+                    "remark": "auto-fastest",
+                    "sortOrder": 1,
+                    "strategy": "random",
+                    "updatedAt": 1710000000000
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/sub-balancers/{id}/del": {
+      "post": {
+        "tags": [
+          "Subscription Balancers"
+        ],
+        "summary": "Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).",
+        "operationId": "post_panel_api_sub_balancers_id_del",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Balancer id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SubBalancer"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "createdAt": 1710000000000,
+                    "enabled": true,
+                    "id": 1,
+                    "inboundIds": [
+                      1,
+                      3
+                    ],
+                    "remark": "auto-fastest",
+                    "sortOrder": 1,
+                    "strategy": "random",
+                    "updatedAt": 1710000000000
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/{subPath}{subid}": {
       "get": {
         "tags": [

+ 41 - 0
frontend/src/api/queries/useSubBalancerMutations.ts

@@ -0,0 +1,41 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+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.
+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),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
+  });
+
+  const updateMut = useMutation({
+    mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
+      HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
+  });
+
+  const removeMut = useMutation({
+    mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
+  });
+
+  return {
+    create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
+    update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
+    remove: (id: number) => removeMut.mutateAsync(id),
+  };
+}

+ 31 - 0
frontend/src/api/queries/useSubBalancersQuery.ts

@@ -0,0 +1,31 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { keys } from '@/api/queryKeys';
+import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
+
+async function fetchSubBalancers(): Promise<SubBalancer[]> {
+  const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
+  const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
+  return Array.isArray(validated.obj) ? validated.obj : [];
+}
+
+export function useSubBalancersQuery() {
+  const query = useQuery({
+    queryKey: keys.subBalancers.list(),
+    queryFn: fetchSubBalancers,
+  });
+
+  const balancers = useMemo(() => query.data ?? [], [query.data]);
+
+  return {
+    balancers,
+    loading: query.isFetching,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
+    refetch: query.refetch,
+  };
+}

+ 4 - 0
frontend/src/api/queryKeys.ts

@@ -13,6 +13,10 @@ export const keys = {
     byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
     tags: () => ['hosts', 'tags'] as const,
   },
+  subBalancers: {
+    root: () => ['sub-balancers'] as const,
+    list: () => ['sub-balancers', 'list'] as const,
+  },
   settings: {
     root: () => ['settings'] as const,
     all: () => ['settings', 'all'] as const,

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

@@ -66,6 +66,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
+    "subJsonObservatory": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
@@ -179,6 +180,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
+    "subJsonObservatory": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
@@ -728,6 +730,19 @@ export const EXAMPLES: Record<string, unknown> = {
     "key": "",
     "value": ""
   },
+  "SubBalancer": {
+    "createdAt": 1710000000000,
+    "enabled": true,
+    "id": 1,
+    "inboundIds": [
+      1,
+      3
+    ],
+    "remark": "auto-fastest",
+    "sortOrder": 1,
+    "strategy": "random",
+    "updatedAt": 1710000000000
+  },
   "User": {
     "id": 0,
     "password": "",

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

@@ -215,6 +215,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux": {
         "type": "string"
       },
+      "subJsonObservatory": {
+        "type": "string"
+      },
       "subJsonPath": {
         "type": "string"
       },
@@ -412,6 +415,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
+      "subJsonObservatory",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
@@ -690,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux": {
         "type": "string"
       },
+      "subJsonObservatory": {
+        "type": "string"
+      },
       "subJsonPath": {
         "type": "string"
       },
@@ -894,6 +901,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
+      "subJsonObservatory",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
@@ -3065,6 +3073,71 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "SubBalancer": {
+    "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
+    "properties": {
+      "createdAt": {
+        "example": 1710000000000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "enabled": {
+        "description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
+        "example": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": 1,
+        "type": "integer"
+      },
+      "inboundIds": {
+        "example": [
+          1,
+          3
+        ],
+        "items": {
+          "type": "integer"
+        },
+        "type": "array"
+      },
+      "remark": {
+        "example": "auto-fastest",
+        "maxLength": 256,
+        "type": "string"
+      },
+      "sortOrder": {
+        "example": 1,
+        "minimum": 1,
+        "type": "integer"
+      },
+      "strategy": {
+        "enum": [
+          "leastLoad",
+          "leastPing",
+          "random",
+          "roundRobin"
+        ],
+        "example": "random",
+        "type": "string"
+      },
+      "updatedAt": {
+        "example": 1710000000000,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "createdAt",
+      "enabled",
+      "id",
+      "inboundIds",
+      "remark",
+      "sortOrder",
+      "strategy",
+      "updatedAt"
+    ],
+    "type": "object"
+  },
   "User": {
     "description": "User represents a user account in the 3x-ui panel.",
     "properties": {

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

@@ -74,6 +74,7 @@ export interface AllSetting {
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
+  subJsonObservatory: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
@@ -188,6 +189,7 @@ export interface AllSettingView {
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
+  subJsonObservatory: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
@@ -698,6 +700,17 @@ export interface Setting {
   value: string;
 }
 
+export interface SubBalancer {
+  createdAt: number;
+  enabled: boolean;
+  id: number;
+  inboundIds: number[];
+  remark: string;
+  sortOrder: number;
+  strategy: string;
+  updatedAt: number;
+}
+
 export interface User {
   id: number;
   password: string;

+ 14 - 0
frontend/src/generated/zod.ts

@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
+  subJsonObservatory: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
@@ -205,6 +206,7 @@ export const AllSettingViewSchema = z.object({
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
+  subJsonObservatory: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
@@ -746,6 +748,18 @@ export const SettingSchema = z.object({
 });
 export type Setting = z.infer<typeof SettingSchema>;
 
+export const SubBalancerSchema = z.object({
+  createdAt: z.number().int(),
+  enabled: z.boolean(),
+  id: z.number().int(),
+  inboundIds: z.array(z.number().int()),
+  remark: z.string().max(256),
+  sortOrder: z.number().int().min(1),
+  strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
+  updatedAt: z.number().int(),
+});
+export type SubBalancer = z.infer<typeof SubBalancerSchema>;
+
 export const UserSchema = z.object({
   id: z.number().int(),
   password: z.string(),

+ 10 - 1
frontend/src/layouts/AppSidebar.tsx

@@ -6,6 +6,7 @@ import { Drawer, Layout, Menu } from 'antd';
 import type { MenuProps } from 'antd';
 import {
   ApiOutlined,
+  ApartmentOutlined,
   CloseOutlined,
   CloudServerOutlined,
   ClusterOutlined,
@@ -177,6 +178,7 @@ export default function AppSidebar() {
   const { pathname, hash } = useLocation();
   const { allSetting } = useAllSettings();
   const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
+  const showSubBalancers = !!allSetting.subJsonEnable;
 
   const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
   const [pinned, setPinned] = useState(readSidebarPinned);
@@ -262,8 +264,15 @@ export default function AppSidebar() {
         label: t('menu.subFormats'),
       });
     }
+    if (showSubBalancers) {
+      children.push({
+        key: '/settings#subscription-balancers',
+        icon: <ApartmentOutlined />,
+        label: t('pages.settings.subBalancers.menu'),
+      });
+    }
     return children;
-  }, [t, showSubFormats]);
+  }, [t, showSubFormats, showSubBalancers]);
 
   const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
     () => [

+ 1 - 0
frontend/src/models/setting.ts

@@ -66,6 +66,7 @@ export class AllSetting {
   subJsonMux = '';
   subJsonRules = '';
   subJsonFinalMask = '';
+  subJsonObservatory = '';
   subThemeDir = '';
   subHideSettings = false;
 

+ 78 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -2189,6 +2189,84 @@ export const sections: readonly Section[] = [
     ],
   },
 
+  {
+    id: 'sub-balancers',
+    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.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/sub-balancers',
+        summary: 'List all subscription balancers in sort order (sort_order asc, id asc).',
+        responseSchema: 'SubBalancer',
+        responseSchemaArray: true,
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/sub-balancers',
+        summary:
+          'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
+        params: [
+          {
+            name: 'remark',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Display label, used as the config remarks (required).',
+          },
+          {
+            name: 'strategy',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
+          },
+          {
+            name: 'inboundIds',
+            in: 'body (form)',
+            type: 'integer[]',
+            desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
+          },
+          {
+            name: 'sortOrder',
+            in: 'body (form)',
+            type: 'integer',
+            desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
+          },
+          {
+            name: 'enabled',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Whether the balancer is emitted. Default true.',
+          },
+        ],
+        responseSchema: 'SubBalancer',
+      },
+      {
+        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).',
+        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
+        responseSchema: 'SubBalancer',
+      },
+      {
+        method: 'DELETE',
+        path: '/panel/api/sub-balancers/:id',
+        summary: 'Delete a balancer by id.',
+        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
+        responseSchema: 'SubBalancer',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/sub-balancers/:id/del',
+        summary:
+          'Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).',
+        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
+        responseSchema: 'SubBalancer',
+      },
+    ],
+  },
+
   {
     id: 'subscription',
     title: 'Subscription Server',

+ 4 - 0
frontend/src/pages/settings/SettingsPage.tsx

@@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab';
 import EmailTab from './EmailTab';
 import SubscriptionGeneralTab from './SubscriptionGeneralTab';
 import SubscriptionFormatsTab from './SubscriptionFormatsTab';
+import SubscriptionBalancersTab from './SubscriptionBalancersTab';
 import './SettingsPage.css';
 
 interface ApiMsg {
@@ -42,6 +43,7 @@ const tabSlugs = [
   'email',
   'subscription',
   'subscription-formats',
+  'subscription-balancers',
 ];
 
 function isIp(h: string): boolean {
@@ -219,6 +221,8 @@ export default function SettingsPage() {
         return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
       case 'subscription-formats':
         return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'subscription-balancers':
+        return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
       default:
         return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
     }

+ 172 - 0
frontend/src/pages/settings/SubBalancerFormModal.tsx

@@ -0,0 +1,172 @@
+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 { FormField, rhfZodValidate } from '@/components/form/rhf';
+import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import {
+  SubBalancerFormSchema,
+  SubBalancerStrategySchema,
+  type SubBalancer,
+  type SubBalancerFormValues,
+  type SubBalancerStrategy,
+} from '@/schemas/subBalancer';
+
+// The JSON subscription only builds proxy outbounds for these protocols;
+// mtproto has no proxy-outbound case, so it is excluded from balancer members.
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks',
+  'vless',
+  'vmess',
+  'trojan',
+  'hysteria',
+  'wireguard',
+]);
+
+const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
+  leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
+  leastPing: 'pages.settings.subBalancers.strategyLeastPing',
+  random: 'pages.settings.subBalancers.strategyRandom',
+  roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
+};
+
+function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
+  return {
+    remark: balancer?.remark ?? '',
+    strategy: balancer?.strategy ?? 'random',
+    inboundIds: [...(balancer?.inboundIds ?? [])],
+    sortOrder: balancer?.sortOrder ?? 1,
+    enabled: balancer?.enabled ?? true,
+  };
+}
+
+interface SubBalancerFormModalProps {
+  open: boolean;
+  balancer: SubBalancer | null;
+  onClose: () => void;
+  onConfirm: (values: SubBalancerFormValues) => void;
+}
+
+export default function SubBalancerFormModal({
+  open,
+  balancer,
+  onClose,
+  onConfirm,
+}: SubBalancerFormModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
+  const isEdit = balancer != null;
+
+  useEffect(() => {
+    if (open) methods.reset(initialState(balancer));
+  }, [open, balancer, methods]);
+
+  const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
+
+  const { data: inboundOptionsRaw } = useInboundOptions();
+  const inboundOptions = useMemo(
+    () =>
+      (inboundOptionsRaw ?? [])
+        .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
+        .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
+        .map((ib) => ({
+          label: formatInboundLabel(ib.tag, ib.remark),
+          value: ib.id,
+          title: formatInboundLabel(ib.tag, ib.remark),
+        })),
+    [inboundOptionsRaw, inboundIds],
+  );
+
+  function onFinish(values: SubBalancerFormValues) {
+    const parsed = SubBalancerFormSchema.safeParse(values);
+    if (!parsed.success) {
+      messageApi.error(
+        t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
+      );
+      return;
+    }
+    onConfirm(parsed.data);
+  }
+
+  const strategies = SubBalancerStrategySchema.options.map((value) => ({
+    value,
+    label: t(STRATEGY_LABEL_KEYS[value]),
+  }));
+
+  return (
+    <Modal
+      open={open}
+      title={
+        isEdit
+          ? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
+          : `+ ${t('pages.settings.subBalancers.add')}`
+      }
+      okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
+      cancelText={t('close')}
+      mask={{ closable: false }}
+      width="640px"
+      onOk={methods.handleSubmit(onFinish)}
+      onCancel={onClose}
+    >
+      {messageContextHolder}
+      <FormProvider {...methods}>
+        <Form layout="vertical">
+          <FormField
+            label={t('pages.settings.subBalancers.remark')}
+            name="remark"
+            required
+            rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
+          >
+            <Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
+          </FormField>
+
+          <FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
+            <Select options={strategies} />
+          </FormField>
+
+          <FormField
+            label={t('pages.settings.subBalancers.sortOrder')}
+            name="sortOrder"
+            required
+            tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
+            rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
+          >
+            <InputNumber min={1} precision={0} style={{ width: '100%' }} />
+          </FormField>
+
+          <FormField
+            label={t('pages.settings.subBalancers.inbounds')}
+            name="inboundIds"
+            required
+            rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
+          >
+            <Select
+              mode="multiple"
+              options={inboundOptions}
+              maxTagCount="responsive"
+              listHeight={220}
+              showSearch={{ optionFilterProp: 'label' }}
+            />
+          </FormField>
+          <SelectAllClearButtons
+            options={inboundOptions}
+            value={inboundIds || []}
+            onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
+          />
+
+          <FormField
+            label={t('pages.settings.subBalancers.enabled')}
+            name="enabled"
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+        </Form>
+      </FormProvider>
+    </Modal>
+  );
+}

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

@@ -0,0 +1,359 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Alert,
+  Button,
+  Input,
+  InputNumber,
+  Popconfirm,
+  Select,
+  Space,
+  Switch,
+  Table,
+  Tabs,
+  Tag,
+  Tooltip,
+} from 'antd';
+import {
+  DeleteOutlined,
+  DeploymentUnitOutlined,
+  EditOutlined,
+  PlusOutlined,
+  RadarChartOutlined,
+} from '@ant-design/icons';
+
+import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
+import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import type { AllSetting } from '@/models/setting';
+import { onNumber } from '@/utils/onNumber';
+import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
+import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
+import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
+import SubBalancerFormModal from './SubBalancerFormModal';
+import { catTabLabel } from './catTabLabel';
+import './SubscriptionFormatsTab.css';
+
+const STRATEGY_COLORS: Record<string, string> = {
+  leastLoad: 'geekblue',
+  leastPing: 'green',
+  random: 'orange',
+  roundRobin: 'purple',
+};
+
+// Single source for the burst-observatory ping defaults: the Zod schema and
+// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
+const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
+
+function parsePingConfig(raw: string): PingConfigObject {
+  try {
+    return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
+  } catch {
+    return DEFAULT_PING_CONFIG;
+  }
+}
+
+interface SubscriptionBalancersTabProps {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+}
+
+export default function SubscriptionBalancersTab({
+  allSetting,
+  updateSetting,
+}: SubscriptionBalancersTabProps) {
+  const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
+  const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
+  const { create, update, remove } = useSubBalancerMutations();
+  const { data: inboundOptionsRaw } = useInboundOptions();
+  const [modalOpen, setModalOpen] = useState(false);
+  const [editing, setEditing] = useState<SubBalancer | null>(null);
+
+  const inboundLabels = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const ib of inboundOptionsRaw ?? []) {
+      map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
+    }
+    return map;
+  }, [inboundOptionsRaw]);
+
+  async function onConfirm(values: SubBalancerFormValues) {
+    const msg = editing ? await update(editing.id, values) : await create(values);
+    if (msg?.success) setModalOpen(false);
+  }
+
+  async function toggleEnabled(balancer: SubBalancer) {
+    await update(balancer.id, {
+      remark: balancer.remark,
+      strategy: balancer.strategy,
+      inboundIds: balancer.inboundIds,
+      sortOrder: balancer.sortOrder,
+      enabled: !balancer.enabled,
+    });
+  }
+
+  const observatoryEnabled = allSetting.subJsonObservatory !== '';
+  const observatoryObj = useMemo(
+    () => parsePingConfig(allSetting.subJsonObservatory),
+    [allSetting.subJsonObservatory],
+  );
+
+  function setObservatoryEnabled(v: boolean) {
+    updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
+  }
+
+  function setObservatoryField<K extends keyof PingConfigObject>(
+    key: K,
+    value: PingConfigObject[K],
+  ) {
+    const next = { ...observatoryObj, [key]: value };
+    updateSetting({ subJsonObservatory: JSON.stringify(next) });
+  }
+
+  const columns = [
+    {
+      title: t('pages.settings.subBalancers.sortOrder'),
+      dataIndex: 'sortOrder',
+      key: 'sortOrder',
+      width: 80,
+      align: 'center' as const,
+    },
+    {
+      title: t('pages.settings.subBalancers.remark'),
+      dataIndex: 'remark',
+      key: 'remark',
+    },
+    {
+      title: t('pages.settings.subBalancers.strategy'),
+      dataIndex: 'strategy',
+      key: 'strategy',
+      width: 120,
+      render: (strategy: string) => (
+        <Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
+      ),
+    },
+    {
+      title: t('pages.settings.subBalancers.inbounds'),
+      key: 'inbounds',
+      render: (_: unknown, r: SubBalancer) => {
+        const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
+        return (
+          <Tooltip title={labels.join(', ')}>
+            <span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
+          </Tooltip>
+        );
+      },
+    },
+    {
+      title: t('pages.settings.subBalancers.enabled'),
+      dataIndex: 'enabled',
+      key: 'enabled',
+      width: 80,
+      align: 'center' as const,
+      render: (_: unknown, r: SubBalancer) => (
+        <Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
+      ),
+    },
+    {
+      title: '',
+      key: 'actions',
+      width: 96,
+      render: (_: unknown, r: SubBalancer) => (
+        <Space>
+          <Button
+            aria-label={t('edit')}
+            size="small"
+            icon={<EditOutlined />}
+            title={t('edit')}
+            onClick={() => {
+              setEditing(r);
+              setModalOpen(true);
+            }}
+          />
+          <Popconfirm
+            title={t('pages.settings.subBalancers.deleteConfirm')}
+            okText={t('delete')}
+            cancelText={t('cancel')}
+            onConfirm={() => remove(r.id)}
+          >
+            <Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  const balancersTab = (
+    <div>
+      <Alert
+        type="info"
+        showIcon
+        style={{ marginBottom: 16 }}
+        title={t('pages.settings.subBalancers.desc')}
+      />
+      {fetchError && (
+        <Alert
+          type="error"
+          showIcon
+          style={{ marginBottom: 16 }}
+          title={fetchError}
+          action={
+            <Button size="small" onClick={() => refetch()}>
+              {t('refresh')}
+            </Button>
+          }
+        />
+      )}
+      <div style={{ marginBottom: 12 }}>
+        <Button
+          type="primary"
+          icon={<PlusOutlined />}
+          onClick={() => {
+            setEditing(null);
+            setModalOpen(true);
+          }}
+        >
+          {t('pages.settings.subBalancers.add')}
+        </Button>
+      </div>
+      <Table
+        size="small"
+        dataSource={balancers}
+        rowKey={(r) => r.id}
+        pagination={false}
+        loading={loading && !fetched}
+        scroll={{ x: true }}
+        locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
+        columns={columns}
+      />
+    </div>
+  );
+
+  const observatoryTab = (
+    <>
+      <Alert
+        type="info"
+        showIcon
+        style={{ marginBottom: 16 }}
+        title={t('pages.settings.subBalancers.observatory.note')}
+      />
+      <SettingListItem
+        paddings="small"
+        title={t('pages.settings.subBalancers.observatory.title')}
+        description={t('pages.settings.subBalancers.observatory.desc')}
+      >
+        <Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
+      </SettingListItem>
+      {observatoryEnabled && (
+        <div className="format-settings">
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.destination')}
+            description={t('pages.settings.subBalancers.observatory.destinationDesc')}
+          >
+            <Input
+              value={observatoryObj.destination}
+              placeholder="https://www.google.com/generate_204"
+              onChange={(e) => setObservatoryField('destination', e.target.value)}
+            />
+          </SettingListItem>
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.connectivity')}
+            description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
+          >
+            <Input
+              value={observatoryObj.connectivity}
+              placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
+              onChange={(e) => setObservatoryField('connectivity', e.target.value)}
+            />
+          </SettingListItem>
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.interval')}
+            description={t('pages.settings.subBalancers.observatory.intervalDesc')}
+          >
+            <Input
+              value={observatoryObj.interval}
+              placeholder="1m"
+              onChange={(e) => setObservatoryField('interval', e.target.value)}
+            />
+          </SettingListItem>
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.timeout')}
+            description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
+          >
+            <Input
+              value={observatoryObj.timeout}
+              placeholder="5s"
+              onChange={(e) => setObservatoryField('timeout', e.target.value)}
+            />
+          </SettingListItem>
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.sampling')}
+            description={t('pages.settings.subBalancers.observatory.samplingDesc')}
+          >
+            <InputNumber
+              value={observatoryObj.sampling}
+              min={1}
+              style={{ width: '100%' }}
+              onChange={onNumber((v) => setObservatoryField('sampling', v))}
+            />
+          </SettingListItem>
+          <SettingListItem
+            paddings="small"
+            title={t('pages.settings.subBalancers.observatory.httpMethod')}
+            description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
+          >
+            <Select
+              value={observatoryObj.httpMethod}
+              style={{ width: '100%' }}
+              onChange={(v) => setObservatoryField('httpMethod', v)}
+              options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
+            />
+          </SettingListItem>
+        </div>
+      )}
+    </>
+  );
+
+  return (
+    <>
+      <Tabs
+        defaultActiveKey="balancers"
+        items={[
+          {
+            key: 'balancers',
+            label: catTabLabel(
+              <DeploymentUnitOutlined />,
+              t('pages.settings.subBalancers.tabBalancers'),
+              isMobile,
+            ),
+            children: balancersTab,
+          },
+          {
+            key: 'observatory',
+            label: catTabLabel(
+              <RadarChartOutlined />,
+              t('pages.settings.subBalancers.tabObservatory'),
+              isMobile,
+            ),
+            children: observatoryTab,
+          },
+        ]}
+      />
+      <SubBalancerFormModal
+        open={modalOpen}
+        balancer={editing}
+        onClose={() => setModalOpen(false)}
+        onConfirm={onConfirm}
+      />
+    </>
+  );
+}

+ 1 - 1
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -13,7 +13,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
   pingConfig: {
     destination: 'https://www.google.com/generate_204',
     interval: '1m',
-    connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
+    connectivity: '',
     timeout: '5s',
     sampling: 2,
     httpMethod: 'HEAD',

+ 1 - 1
frontend/src/schemas/observatory.ts

@@ -16,7 +16,7 @@ export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
 export const PingConfigSchema = z
   .object({
     destination: z.string().default('https://www.google.com/generate_204'),
-    connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'),
+    connectivity: z.string().default(''),
     interval: z.string().default('1m'),
     timeout: z.string().default('5s'),
     sampling: z.number().int().min(1).default(2),

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -72,6 +72,7 @@ export const AllSettingSchema = z
     subJsonMux: z.string().optional(),
     subJsonRules: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
+    subJsonObservatory: z.string().optional(),
     subHideSettings: z.boolean().optional(),
     timeLocation: z.string().optional(),
     ldapEnable: z.boolean().optional(),

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

@@ -0,0 +1,36 @@
+import { z } from 'zod';
+
+export const SubBalancerStrategySchema = z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']);
+export type SubBalancerStrategy = z.infer<typeof SubBalancerStrategySchema>;
+
+export const SubBalancerSchema = z.object({
+  id: z.number(),
+  remark: z.string(),
+  strategy: SubBalancerStrategySchema,
+  inboundIds: z.array(z.number()),
+  sortOrder: z.number(),
+  enabled: z.boolean(),
+  createdAt: z.number().optional(),
+  updatedAt: z.number().optional(),
+});
+export type SubBalancer = z.infer<typeof SubBalancerSchema>;
+
+export const SubBalancerListSchema = z.array(SubBalancerSchema);
+
+export const SubBalancerFormSchema = z.object({
+  remark: z
+    .string()
+    .trim()
+    .min(1, 'pages.settings.subBalancers.errRemarkRequired')
+    .max(256, 'pages.settings.subBalancers.errRemarkRequired'),
+  strategy: SubBalancerStrategySchema,
+  inboundIds: z
+    .array(z.number().int().positive())
+    .min(1, 'pages.settings.subBalancers.errInboundsRequired'),
+  sortOrder: z
+    .number({ message: 'pages.settings.subBalancers.errSortOrder' })
+    .int('pages.settings.subBalancers.errSortOrder')
+    .min(1, 'pages.settings.subBalancers.errSortOrder'),
+  enabled: z.boolean(),
+});
+export type SubBalancerFormValues = z.infer<typeof SubBalancerFormSchema>;

+ 136 - 0
frontend/src/test/sub-balancer-form-modal.test.tsx

@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, waitFor } from '@testing-library/react';
+
+import SubBalancerFormModal from '@/pages/settings/SubBalancerFormModal';
+import type { SubBalancer } from '@/schemas/subBalancer';
+import { renderWithProviders } from './test-utils';
+
+vi.mock('@/api/queries/useInboundOptions', () => ({
+  useInboundOptions: () => ({
+    data: [
+      { id: 1, tag: 'inb-vless', remark: 'First', protocol: 'vless', port: 443, enable: true },
+      { id: 2, tag: 'inb-ws', remark: 'Second', protocol: 'vmess', port: 8443, enable: true },
+      { id: 3, tag: 'inb-off', remark: 'Disabled', protocol: 'vless', port: 8080, enable: false },
+    ],
+    isLoading: false,
+  }),
+}));
+
+function renderModal(balancer: SubBalancer | null, onConfirm = vi.fn()) {
+  renderWithProviders(
+    <SubBalancerFormModal open balancer={balancer} onClose={() => {}} onConfirm={onConfirm} />,
+  );
+  return { onConfirm };
+}
+
+function primaryButton(): HTMLElement {
+  const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
+  if (!btn) throw new Error('Primary button not found');
+  return btn as HTMLElement;
+}
+
+function erroredItemCount(): number {
+  return document.querySelectorAll('.ant-form-item-has-error').length;
+}
+
+function remarkInput(): HTMLInputElement {
+  const el = Array.from(document.querySelectorAll('.ant-modal input')).find((i) =>
+    (i as HTMLInputElement).placeholder.includes('Auto'),
+  );
+  if (!el) throw new Error('Remark input not found');
+  return el as HTMLInputElement;
+}
+
+function inboundOptionTitles(): string[] {
+  const multi = document.querySelector('.ant-select-multiple');
+  if (!multi) throw new Error('Inbound multi-select not found');
+  fireEvent.mouseDown(multi as HTMLElement);
+  return Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
+    (o.getAttribute('title') ?? o.textContent ?? '').trim(),
+  );
+}
+
+function selectInbound(optionTitle: string) {
+  const multi = document.querySelector('.ant-select-multiple');
+  if (!multi) throw new Error('Inbound multi-select not found');
+  // AntD 6 multiple selects have no .ant-select-selector; mousedown on the
+  // root toggles the dropdown.
+  fireEvent.mouseDown(multi as HTMLElement);
+  const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+    (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionTitle,
+  );
+  if (!option) throw new Error(`Option '${optionTitle}' not found`);
+  fireEvent.click(option);
+  fireEvent.keyDown(multi, { key: 'Escape' });
+}
+
+describe('SubBalancerFormModal', () => {
+  it('shows no validation errors when freshly opened in add mode', () => {
+    renderModal(null);
+    expect(document.querySelector('.ant-modal')).toBeTruthy();
+    expect(erroredItemCount()).toBe(0);
+    expect(primaryButton().hasAttribute('disabled')).toBe(false);
+  });
+
+  it('reveals required-field errors after a save attempt, without confirming', async () => {
+    const { onConfirm } = renderModal(null);
+    fireEvent.click(primaryButton());
+    await waitFor(() => expect(erroredItemCount()).toBe(2));
+    expect(onConfirm).not.toHaveBeenCalled();
+  });
+
+  it('confirms with parsed values once remark and an inbound are set', async () => {
+    const { onConfirm } = renderModal(null);
+    fireEvent.change(remarkInput(), { target: { value: '  auto  ' } });
+    selectInbound('First');
+    fireEvent.click(primaryButton());
+    await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
+    expect(onConfirm).toHaveBeenCalledWith({
+      remark: 'auto',
+      strategy: 'random',
+      inboundIds: [1],
+      sortOrder: 1,
+      enabled: true,
+    });
+  });
+
+  it('seeds the form from the edited balancer', async () => {
+    const { onConfirm } = renderModal({
+      id: 7,
+      remark: 'existing',
+      strategy: 'leastPing',
+      inboundIds: [2],
+      sortOrder: 3,
+      enabled: false,
+    });
+    expect(remarkInput().value).toBe('existing');
+    fireEvent.click(primaryButton());
+    await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
+    expect(onConfirm).toHaveBeenCalledWith({
+      remark: 'existing',
+      strategy: 'leastPing',
+      inboundIds: [2],
+      sortOrder: 3,
+      enabled: false,
+    });
+  });
+
+  // A disabled member is dropped by the sub server, so offering it here would
+  // silently stop the balancer document from being emitted (#5645).
+  it('hides disabled inbounds from the member picker', () => {
+    renderModal(null);
+    expect(inboundOptionTitles()).toEqual(['First', 'Second']);
+  });
+
+  it('keeps an already-selected disabled inbound visible when editing', () => {
+    renderModal({
+      id: 8,
+      remark: 'existing',
+      strategy: 'random',
+      inboundIds: [3],
+      sortOrder: 1,
+      enabled: true,
+    });
+    expect(inboundOptionTitles()).toContain('Disabled');
+  });
+});

+ 5 - 5
go.mod

@@ -1,6 +1,6 @@
 module github.com/mhsanaei/3x-ui/v3
 
-go 1.26.6
+go 1.27.0
 
 require (
 	github.com/gin-contrib/gzip v1.2.6
@@ -13,10 +13,12 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
+	github.com/klauspost/compress v1.19.2
 	github.com/mattn/go-sqlite3 v1.14.49
 	github.com/mymmrac/telego v1.11.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
+	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.7
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
@@ -25,9 +27,11 @@ require (
 	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.55.0
+	golang.org/x/net v0.58.0
 	golang.org/x/sys v0.47.0
 	golang.org/x/text v0.41.0
 	google.golang.org/grpc v1.83.0
+	google.golang.org/protobuf v1.36.12
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.2
 	gorm.io/driver/sqlite v1.6.0
@@ -64,7 +68,6 @@ require (
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
-	github.com/klauspost/compress v1.19.2
 	github.com/klauspost/cpuid/v2 v2.4.0 // indirect
 	github.com/leodido/go-urn v1.5.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
@@ -81,7 +84,6 @@ require (
 	github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.61.0 // indirect
-	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/sagernet/sing v0.8.13 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
@@ -101,7 +103,6 @@ require (
 	golang.org/x/arch v0.30.0 // indirect
 	golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect
 	golang.org/x/mod v0.40.0 // indirect
-	golang.org/x/net v0.58.0
 	golang.org/x/sync v0.22.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.49.0 // indirect
@@ -109,7 +110,6 @@ require (
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
-	google.golang.org/protobuf v1.36.12
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )

+ 1 - 0
internal/database/db.go

@@ -84,6 +84,7 @@ func allModels() []any {
 		&model.NodeClientIp{},
 		&model.ClientGlobalTraffic{},
 		&model.OutboundSubscription{},
+		&model.SubBalancer{},
 	}
 }
 

+ 1 - 0
internal/database/migrate_data.go

@@ -57,6 +57,7 @@ func migrationModels() []any {
 		&model.NodeClientIp{},
 		&model.ClientGlobalTraffic{},
 		&model.OutboundSubscription{},
+		&model.SubBalancer{},
 	}
 }
 

+ 15 - 0
internal/database/model/model.go

@@ -1227,6 +1227,21 @@ type OutboundSubscription struct {
 	OutboundCount        int    `json:"outboundCount" gorm:"-"`
 }
 
+// SubBalancer is one extra JSON-subscription config document whose members are
+// the selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.
+type SubBalancer struct {
+	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
+	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"`
+	// 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"`
+	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1710000000000"`
+	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1710000000000"`
+}
+
 func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
 	var conflicts []ClientMergeConflict
 	keep := func(field string, oldV, newV, kept any) {

+ 1 - 1
internal/pia/catalog_test.go

@@ -103,7 +103,7 @@ func TestCatalogCoalescesConcurrentRefresh(t *testing.T) {
 		time.Sleep(time.Millisecond)
 	}
 	close(source.release)
-	for i := 0; i < 2; i++ {
+	for range 2 {
 		if err := <-errc; err != nil {
 			t.Fatal(err)
 		}

+ 1 - 1
internal/pia/validation.go

@@ -23,7 +23,7 @@ func validHostname(host string) bool {
 	if host == "" || len(host) > 253 || net.ParseIP(host) != nil || strings.HasSuffix(host, ".") {
 		return false
 	}
-	for _, label := range strings.Split(host, ".") {
+	for label := range strings.SplitSeq(host, ".") {
 		if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
 			return false
 		}

+ 8 - 1
internal/sub/controller.go

@@ -99,6 +99,7 @@ type subControllerConfig struct {
 	subJsonMux            string
 	subJsonRules          string
 	subJsonFinalMask      string
+	subJsonObservatory    string
 	subClashEnableRouting bool
 	subClashRules         string
 
@@ -180,6 +181,10 @@ func WithSUBJsonFinalMask(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 }
 
+func WithSUBJsonObservatory(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonObservatory = value }
+}
+
 func WithSUBClashEnableRouting(value bool) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subClashEnableRouting = value }
 }
@@ -243,6 +248,8 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 	}
 
 	sub := NewSubService(config.remarkTemplate)
+	subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
+	subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
 	a := &SUBController{
 		subTitle:         config.subTitle,
 		subSupportUrl:    config.subSupportURL,
@@ -269,7 +276,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 		updateInterval:     config.updateInterval,
 
 		subService:      sub,
-		subJsonService:  NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
+		subJsonService:  subJsonSvc,
 		subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
 
 		subTemplateCache: map[string]*cachedSubTemplate{},

+ 313 - 11
internal/sub/json_service.go

@@ -5,9 +5,15 @@ import (
 	"encoding/json"
 	"fmt"
 	"maps"
+	"net/url"
+	"slices"
+	"sort"
 	"strings"
+	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -22,6 +28,7 @@ type SubJsonService struct {
 	defaultOutbounds []json_util.RawMessage
 	finalMask        string
 	mux              string
+	observatory      subBalancerObservatoryConfig
 
 	SubService *SubService
 }
@@ -53,6 +60,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		defaultOutbounds: defaultOutbounds,
 		finalMask:        finalMask,
 		mux:              mux,
+		observatory:      defaultSubBalancerObservatoryConfig(),
 		SubService:       subService,
 	}
 }
@@ -74,9 +82,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	}
 
 	var header string
-	var configArray []json_util.RawMessage
 
 	seenEmails := make(map[string]struct{})
+	entries := make([]subConfigEntry, 0, len(inbounds))
 	// Prepare Inbounds
 	for _, inbound := range inbounds {
 		clients := subReq.matchingClients(inbound, subId)
@@ -88,10 +96,35 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 			injectExternalProxy(inbound, hostEps)
 		}
 
+		var inboundConfigs []json_util.RawMessage
 		for _, client := range clients {
 			seenEmails[client.Email] = struct{}{}
-			configArray = append(configArray, s.getConfig(subReq, inbound, client, host)...)
+			inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
+		}
+		if len(inboundConfigs) > 0 {
+			entries = append(entries, subConfigEntry{
+				sortIndex: inbound.SubSortIndex,
+				id:        inbound.Id,
+				configs:   inboundConfigs,
+			})
+		}
+	}
+	entries = s.appendBalancerEntries(entries)
+
+	// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
+	// the same key and, on an equal number, follow the inbound group.
+	sort.SliceStable(entries, func(i, j int) bool {
+		if entries[i].sortIndex != entries[j].sortIndex {
+			return entries[i].sortIndex < entries[j].sortIndex
+		}
+		if entries[i].kind != entries[j].kind {
+			return entries[i].kind < entries[j].kind
 		}
+		return entries[i].id < entries[j].id
+	})
+	var configArray []json_util.RawMessage
+	for _, entry := range entries {
+		configArray = append(configArray, entry.configs...)
 	}
 	for _, ext := range externalLinks {
 		for _, el := range expandEntry(ext) {
@@ -136,6 +169,275 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	return string(finalJson), header, nil
 }
 
+// subConfigEntry is one ordered block of the JSON subscription: an inbound's
+// configs (kind 0) or a balancer config (kind 1).
+type subConfigEntry struct {
+	sortIndex int
+	kind      int
+	id        int
+	configs   []json_util.RawMessage
+}
+
+const (
+	subBalancerTag      = "balancer"
+	subBalancerProbeURL = "https://www.google.com/generate_204"
+)
+
+// subBalancerObservatoryConfig is the panel-wide burstObservatory ping config
+// emitted into every client-side balancer doc (subJsonObservatory setting).
+type subBalancerObservatoryConfig struct {
+	Destination  string `json:"destination"`
+	Connectivity string `json:"connectivity"`
+	Interval     string `json:"interval"`
+	Sampling     int    `json:"sampling"`
+	Timeout      string `json:"timeout"`
+	HTTPMethod   string `json:"httpMethod"`
+}
+
+func defaultSubBalancerObservatoryConfig() subBalancerObservatoryConfig {
+	return subBalancerObservatoryConfig{
+		Destination:  subBalancerProbeURL,
+		Connectivity: "",
+		Interval:     "1m",
+		Sampling:     2,
+		Timeout:      "5s",
+		HTTPMethod:   "HEAD",
+	}
+}
+
+// SetObservatoryConfig overrides defaults from the panel JSON setting. An empty
+// cfg keeps all defaults; invalid values fall back with a warning, never panic.
+func (s *SubJsonService) SetObservatoryConfig(cfg string) {
+	s.observatory = defaultSubBalancerObservatoryConfig()
+	if cfg == "" {
+		return
+	}
+	var parsed subBalancerObservatoryConfig
+	if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
+		logger.Warningf("subJsonObservatory: invalid JSON %q, using defaults: %v", cfg, err)
+		return
+	}
+	if parsed.Destination != "" {
+		if validProbeURL(parsed.Destination) {
+			s.observatory.Destination = parsed.Destination
+		} else {
+			logger.Warningf("subJsonObservatory: invalid destination %q, keeping default %q", parsed.Destination, s.observatory.Destination)
+		}
+	}
+	if parsed.Connectivity != "" {
+		if validProbeURL(parsed.Connectivity) {
+			s.observatory.Connectivity = parsed.Connectivity
+		} else {
+			logger.Warningf("subJsonObservatory: invalid connectivity %q, keeping default (skip)", parsed.Connectivity)
+		}
+	}
+	if parsed.Interval != "" {
+		if _, err := time.ParseDuration(parsed.Interval); err == nil {
+			s.observatory.Interval = parsed.Interval
+		} else {
+			logger.Warningf("subJsonObservatory: invalid interval %q, keeping default %q", parsed.Interval, s.observatory.Interval)
+		}
+	}
+	if parsed.Sampling > 0 {
+		s.observatory.Sampling = parsed.Sampling
+	}
+	if parsed.Timeout != "" {
+		if _, err := time.ParseDuration(parsed.Timeout); err == nil {
+			s.observatory.Timeout = parsed.Timeout
+		} else {
+			logger.Warningf("subJsonObservatory: invalid timeout %q, keeping default %q", parsed.Timeout, s.observatory.Timeout)
+		}
+	}
+	if parsed.HTTPMethod == "HEAD" || parsed.HTTPMethod == "GET" {
+		s.observatory.HTTPMethod = parsed.HTTPMethod
+	}
+}
+
+// validProbeURL accepts only absolute http(s) URLs so a malformed probe or
+// connectivity value can't slip into the emitted burstObservatory.
+func validProbeURL(s string) bool {
+	u, err := url.Parse(s)
+	if err != nil || u == nil {
+		return false
+	}
+	return u.Scheme == "http" || u.Scheme == "https"
+}
+
+func (s *SubJsonService) balancerObservatory(prefix string) map[string]any {
+	o := s.observatory
+	return map[string]any{
+		"subjectSelector": []string{prefix},
+		"pingConfig": map[string]any{
+			"destination":  o.Destination,
+			"connectivity": o.Connectivity,
+			"interval":     o.Interval,
+			"sampling":     o.Sampling,
+			"timeout":      o.Timeout,
+			"httpMethod":   o.HTTPMethod,
+		},
+	}
+}
+
+// appendBalancerEntries appends one entry per enabled balancer that has at
+// least one member outbound among the inbound entries.
+func (s *SubJsonService) appendBalancerEntries(entries []subConfigEntry) []subConfigEntry {
+	balancers := getEnabledSubBalancers()
+	if len(balancers) == 0 {
+		return entries
+	}
+	// Pre-pass: pull each inbound doc's proxy outbound once so every balancer
+	// reuses it instead of re-unmarshalling the whole document per balancer.
+	entryProxies := make([][]map[string]any, len(entries))
+	for i, entry := range entries {
+		if entry.kind != 0 {
+			continue
+		}
+		for _, config := range entry.configs {
+			if proxy := extractProxyOutbound(config); proxy != nil {
+				entryProxies[i] = append(entryProxies[i], proxy)
+			}
+		}
+	}
+	for i := range balancers {
+		config := s.buildBalancerConfig(&balancers[i], entries, entryProxies)
+		if config == nil {
+			continue
+		}
+		entries = append(entries, subConfigEntry{
+			sortIndex: balancers[i].SortOrder,
+			kind:      1,
+			id:        balancers[i].Id,
+			configs:   []json_util.RawMessage{config},
+		})
+	}
+	return entries
+}
+
+// extractProxyOutbound returns the first outbound of a document when it is the
+// proxy (tag == "proxy"), else nil — the only member shape a balancer retags.
+func extractProxyOutbound(config json_util.RawMessage) map[string]any {
+	var doc map[string]any
+	if json.Unmarshal(config, &doc) != nil {
+		return nil
+	}
+	outbounds, _ := doc["outbounds"].([]any)
+	if len(outbounds) == 0 {
+		return nil
+	}
+	outbound, _ := outbounds[0].(map[string]any)
+	if outbound == nil || outbound["tag"] != "proxy" {
+		return nil
+	}
+	return outbound
+}
+
+func getEnabledSubBalancers() []model.SubBalancer {
+	var balancers []model.SubBalancer
+	if err := database.GetDB().Model(&model.SubBalancer{}).
+		Where("enabled = ?", true).
+		Order("sort_order asc, id asc").Find(&balancers).Error; err != nil {
+		logger.Error("SubJsonService - getEnabledSubBalancers:", err)
+		return nil
+	}
+	return balancers
+}
+
+// Suffix by proxy protocol, not transport network — a vmess/tcp member used to
+// be mislabelled "vless".
+func balancerMemberSuffix(protocol string) string {
+	if protocol == "" {
+		return "other"
+	}
+	return protocol
+}
+
+// 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
+	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.
+	for i, entry := range entries {
+		if entry.kind != 0 || !slices.Contains(balancer.InboundIds, entry.id) {
+			continue
+		}
+		for _, outbound := range entryProxies[i] {
+			protocol, _ := outbound["protocol"].(string)
+			base := prefix + balancerMemberSuffix(protocol)
+			tag := base
+			for suffix := 2; usedTags[tag]; suffix++ {
+				tag = fmt.Sprintf("%s-%d", base, suffix)
+			}
+			usedTags[tag] = true
+			member := maps.Clone(outbound)
+			member["tag"] = tag
+			if raw, err := json.MarshalIndent(member, "", "  "); err == nil {
+				if firstTag == "" {
+					firstTag = tag
+				}
+				proxies = append(proxies, raw)
+			}
+		}
+	}
+	if len(proxies) == 0 {
+		return nil
+	}
+
+	outbounds := append([]json_util.RawMessage{}, proxies...)
+	outbounds = append(outbounds, s.defaultOutbounds...)
+
+	// The routing subtree in s.configJson is shared by every emitted document;
+	// clone it (and each rule map) before pointing rules at the balancer.
+	baseRouting, _ := s.configJson["routing"].(map[string]any)
+	routing := make(map[string]any, len(baseRouting)+1)
+	maps.Copy(routing, baseRouting)
+	baseRules, _ := baseRouting["rules"].([]any)
+	rules := make([]any, 0, len(baseRules)+1)
+	for _, rule := range baseRules {
+		ruleMap, ok := rule.(map[string]any)
+		if !ok {
+			rules = append(rules, rule)
+			continue
+		}
+		ruleMap = maps.Clone(ruleMap)
+		if ruleMap["outboundTag"] == "proxy" {
+			delete(ruleMap, "outboundTag")
+			ruleMap["balancerTag"] = subBalancerTag
+		}
+		rules = append(rules, ruleMap)
+	}
+	routing["rules"] = rules
+	isObservatory := balancer.Strategy == "leastPing" || balancer.Strategy == "leastLoad"
+	balancerEntry := map[string]any{
+		"tag":      subBalancerTag,
+		"selector": []string{prefix},
+		"strategy": map[string]any{"type": balancer.Strategy},
+	}
+	if isObservatory && firstTag != "" {
+		// With all probes failing, route to the first member instead of
+		// failing dispatch.
+		balancerEntry["fallbackTag"] = firstTag
+	}
+	routing["balancers"] = []any{balancerEntry}
+
+	newConfigJson := make(map[string]any, len(s.configJson)+2)
+	maps.Copy(newConfigJson, s.configJson)
+	newConfigJson["outbounds"] = outbounds
+	newConfigJson["remarks"] = balancer.Remark
+	newConfigJson["routing"] = routing
+	// leastPing/leastLoad require a burst observatory (Xray refuses to start
+	// them without one); fallbackTag above covers the probe-outage case.
+	if isObservatory {
+		newConfigJson["burstObservatory"] = s.balancerObservatory(prefix)
+	}
+
+	config, _ := json.MarshalIndent(newConfigJson, "", "  ")
+	return config
+}
+
 func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
 	var newJsonArray []json_util.RawMessage
 	stream := s.streamData(inbound.StreamSettings, subKey(client))
@@ -387,10 +689,10 @@ func jsonMux(global, override string) string {
 }
 
 func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
-	outbound := Outbound{}
+	outbound := Outbound{
 
-	outbound.Protocol = string(inbound.Protocol)
-	outbound.Tag = "proxy"
+		Protocol: string(inbound.Protocol),
+		Tag:      "proxy"}
 	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 	}
@@ -410,9 +712,9 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 }
 
 func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
-	outbound := Outbound{}
-	outbound.Protocol = string(inbound.Protocol)
-	outbound.Tag = "proxy"
+	outbound := Outbound{
+		Protocol: string(inbound.Protocol),
+		Tag:      "proxy"}
 	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 	}
@@ -490,10 +792,10 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s
 }
 
 func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
-	outbound := Outbound{}
+	outbound := Outbound{
 
-	outbound.Protocol = string(inbound.Protocol)
-	outbound.Tag = "proxy"
+		Protocol: string(inbound.Protocol),
+		Tag:      "proxy"}
 
 	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)

+ 3 - 2
internal/sub/mutation_audit_test.go

@@ -25,8 +25,9 @@ func initMutDB(t *testing.T) {
 	t.Cleanup(func() { _ = database.CloseDB() })
 }
 
+//go:fix inline
 func externalLinkEnabled(v bool) *bool {
-	return &v
+	return new(v)
 }
 
 // --- json_service.go:40 — rules are merged into routing only when non-empty ---
@@ -312,7 +313,7 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
 		t.Fatalf("seed link a: %v", err)
 	}
-	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: externalLinkEnabled(false), SortIndex: 3}).Error; err != nil {
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: new(false), SortIndex: 3}).Error; err != nil {
 		t.Fatalf("seed disabled link: %v", err)
 	}
 	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil {

+ 2 - 2
internal/sub/remote_routing.go

@@ -370,8 +370,8 @@ func normalizeHappRouting(body []byte) (string, error) {
 
 	payload := ""
 	for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} {
-		if strings.HasPrefix(text, prefix) {
-			payload = strings.TrimPrefix(text, prefix)
+		if after, ok := strings.CutPrefix(text, prefix); ok {
+			payload = after
 			break
 		}
 	}

+ 6 - 0
internal/sub/sub.go

@@ -155,6 +155,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonFinalMask = ""
 	}
 
+	SubJsonObservatory, err := s.settingService.GetSubJsonObservatory()
+	if err != nil {
+		SubJsonObservatory = ""
+	}
+
 	SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
 	if err != nil {
 		SubClashEnableRouting = false
@@ -281,6 +286,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonRules(SubJsonRules),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
+		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBClashEnableRouting(SubClashEnableRouting),
 		WithSUBClashRules(SubClashRules),
 		WithSUBTitle(SubTitle),

+ 60 - 0
internal/sub/sub_balancer_protocol_tag_test.go

@@ -0,0 +1,60 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// seedSubProtocolInbound seeds an inbound of the given protocol with one client
+// wired into the clients/client_inbounds tables so getInboundsBySubId resolves it.
+func seedSubProtocolInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string, protocol model.Protocol) *model.Inbound {
+	t.Helper()
+	db := database.GetDB()
+	uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
+	email := tag + "@e"
+	settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}]}`, uuid, email, subId)
+	ib := &model.Inbound{
+		UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
+		Protocol: protocol, Remark: tag, Settings: settings, StreamSettings: stream,
+		SubSortIndex: subSortIndex,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound %s: %v", tag, err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client %s: %v", email, err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound %s: %v", email, err)
+	}
+	return ib
+}
+
+// The member tag suffix is the inbound's real protocol, not its transport
+// network: a vmess/tcp member is tagged bal-N-vmess, not the old bal-N-vless.
+func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
+	seedSubDB(t)
+	vm := seedSubProtocolInbound(t, "s1", "vm", 4901, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`, model.VMESS)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "proto", Strategy: "random", InboundIds: []int{vm.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)
+	}
+	balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "proto")
+	if balancerDoc == nil {
+		t.Fatalf("balancer doc missing:\n%s", out)
+	}
+	tags := docOutboundTags(balancerDoc)
+	if !strings.Contains(strings.Join(tags, ","), "bal-1-vmess") {
+		t.Fatalf("vmess member tag = %v, want a bal-1-vmess suffix", tags)
+	}
+}

+ 422 - 0
internal/sub/sub_balancer_test.go

@@ -0,0 +1,422 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
+	t.Helper()
+	if err := database.GetDB().Create(b).Error; err != nil {
+		t.Fatalf("seed balancer: %v", err)
+	}
+	return b
+}
+
+func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
+	t.Helper()
+	var docs []map[string]any
+	if err := json.Unmarshal([]byte(out), &docs); err != nil {
+		t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
+	}
+	return docs
+}
+
+func docOutboundTags(doc map[string]any) []string {
+	outbounds, _ := doc["outbounds"].([]any)
+	tags := make([]string, 0, len(outbounds))
+	for _, ob := range outbounds {
+		if m, ok := ob.(map[string]any); ok {
+			tags = append(tags, m["tag"].(string))
+		}
+	}
+	return tags
+}
+
+func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
+	for _, doc := range docs {
+		if doc["remarks"] == remarks {
+			return doc
+		}
+	}
+	return nil
+}
+
+// The balancer document retags members under a per-balancer prefix, points
+// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
+func TestSubJson_BalancerDocument(t *testing.T) {
+	seedSubDB(t)
+	tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+	ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
+	js := NewSubJsonService("", rules, "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 3 {
+		t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
+	}
+
+	balancerDoc := findDocByRemarks(docs, "auto")
+	if balancerDoc == nil {
+		t.Fatalf("balancer doc missing:\n%s", out)
+	}
+	if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
+		t.Fatalf("balancer outbound tags = %v", tags)
+	}
+
+	routing, _ := balancerDoc["routing"].(map[string]any)
+	balancers, _ := routing["balancers"].([]any)
+	if len(balancers) != 1 {
+		t.Fatalf("balancers = %d, want 1", len(balancers))
+	}
+	balancer, _ := balancers[0].(map[string]any)
+	if balancer["tag"] != "balancer" {
+		t.Fatalf("balancer tag = %v", balancer["tag"])
+	}
+	if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
+		t.Fatalf("selector = %v", selector)
+	}
+	strategy, _ := balancer["strategy"].(map[string]any)
+	if strategy["type"] != "leastLoad" {
+		t.Fatalf("strategy = %v", strategy)
+	}
+	if balancer["fallbackTag"] != "bal-1-vless" {
+		t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
+	}
+
+	ruleJSON, _ := json.Marshal(routing["rules"])
+	if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
+		t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
+	}
+	if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
+		t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
+	}
+	proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
+	if proxyRules != 2 { // custom rule + default catch-all
+		t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
+	}
+
+	observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
+	if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
+		t.Fatalf("subjectSelector = %v", selector)
+	}
+	ping, _ := observatory["pingConfig"].(map[string]any)
+	if ping["destination"] != subBalancerProbeURL {
+		t.Fatalf("pingConfig destination = %v", ping["destination"])
+	}
+
+	// The routing rewrite must not leak into the manual documents: s.configJson
+	// is shared, so a missing clone would corrupt every other doc.
+	for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
+		manual := findDocByRemarks(docs, remarks)
+		if manual == nil {
+			t.Fatalf("manual doc %q missing:\n%s", remarks, out)
+		}
+		if tags := docOutboundTags(manual); tags[0] != "proxy" {
+			t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
+		}
+		manualRouting, _ := manual["routing"].(map[string]any)
+		manualRules, _ := json.Marshal(manualRouting["rules"])
+		if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
+			t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
+		}
+		if _, has := manualRouting["balancers"]; has {
+			t.Fatalf("manual doc %q must not carry balancers", remarks)
+		}
+	}
+}
+
+func stringify(values []any) []string {
+	out := make([]string, 0, len(values))
+	for _, v := range values {
+		out = append(out, v.(string))
+	}
+	return out
+}
+
+// The balancer interleaves with inbounds by the same 1-based number and, on a
+// tie, follows the inbound group with that number.
+func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
+	seedSubDB(t)
+	later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
+	first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.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)
+	}
+	docs := parseSubJsonDocs(t, out)
+	var remarks []string
+	for _, doc := range docs {
+		remarks = append(remarks, doc["remarks"].(string))
+	}
+	if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
+		t.Fatalf("doc order = %v, want [first bal later]", remarks)
+	}
+	balancerDoc := findDocByRemarks(docs, "bal")
+	routing, _ := balancerDoc["routing"].(map[string]any)
+	balancers, _ := routing["balancers"].([]any)
+	strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
+	if strategy["type"] != "roundRobin" {
+		t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
+	}
+}
+
+// A disabled balancer is not emitted; an enabled one whose selected inbounds
+// have no configs for this subscriber is skipped rather than emitted empty.
+func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
+	seedSubDB(t)
+	inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
+	})
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 1 {
+		t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
+	}
+	if docs[0]["remarks"] != "only-only@e" {
+		t.Fatalf("remaining doc = %v", docs[0]["remarks"])
+	}
+}
+
+// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
+// the reference makeTag convention.
+func TestSubJson_BalancerTagDedup(t *testing.T) {
+	seedSubDB(t)
+	a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
+	b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "dedup", Strategy: "leastPing", 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)
+	}
+	docs := parseSubJsonDocs(t, out)
+	balancerDoc := findDocByRemarks(docs, "dedup")
+	if balancerDoc == nil {
+		t.Fatalf("balancer doc missing:\n%s", out)
+	}
+	if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
+		t.Fatalf("balancer outbound tags = %v", tags)
+	}
+}
+
+// random/roundRobin have no fallback so they emit no observatory; leastPing
+// carries one, with the panel-wide ping config overriding the defaults.
+func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
+	seedSubDB(t)
+	rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
+	lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
+	})
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+
+	rnd := findDocByRemarks(docs, "rnd")
+	if _, has := rnd["burstObservatory"]; has {
+		t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
+	}
+
+	pinger := findDocByRemarks(docs, "pinger")
+	obs, _ := pinger["burstObservatory"].(map[string]any)
+	if obs == nil {
+		t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
+	}
+	ping, _ := obs["pingConfig"].(map[string]any)
+	if ping["destination"] != "https://probe.example/204" {
+		t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
+	}
+	if ping["httpMethod"] != "GET" {
+		t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
+	}
+	if ping["sampling"] != float64(5) {
+		t.Fatalf("sampling = %v, want 5", ping["sampling"])
+	}
+	if ping["interval"] != "1m" {
+		t.Fatalf("interval = %v, want default 1m", ping["interval"])
+	}
+}
+
+// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
+// filters enable=true, so B never reaches entries. Guards the access scoping.
+func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
+	seedSubDB(t)
+	a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
+	b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable inbound B: %v", err)
+	}
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "bal", Strategy: "random", 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)
+	}
+	docs := parseSubJsonDocs(t, out)
+	balancerDoc := findDocByRemarks(docs, "bal")
+	if balancerDoc == nil {
+		t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
+	}
+	tags := docOutboundTags(balancerDoc)
+	joined := strings.Join(tags, ",")
+	if !strings.Contains(joined, "bal-1-vless") {
+		t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
+	}
+	// B's address must not surface anywhere in the balancer doc — not as an
+	// outbound tag, not as a connection target a client could dial.
+	balJSON, _ := json.Marshal(balancerDoc)
+	if strings.Contains(string(balJSON), "203.0.113.5:4752") {
+		t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
+	}
+}
+
+// A balancer whose only selected inbound is disabled for this subscriber is
+// skipped entirely — never emitted as an empty balancer with zero members.
+func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
+	seedSubDB(t)
+	only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable only inbound: %v", err)
+	}
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "empty", Strategy: "random", InboundIds: []int{only.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)
+	}
+	if strings.TrimSpace(out) == "" {
+		return
+	}
+	docs := parseSubJsonDocs(t, out)
+	if findDocByRemarks(docs, "empty") != nil {
+		t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
+	}
+}
+
+// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
+// value stays empty instead of restoring the old generate_204 default.
+func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
+	seedSubDB(t)
+	inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.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)
+	}
+	ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
+	if ping["connectivity"] != "" {
+		t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
+	}
+
+	js.SetObservatoryConfig(`{"connectivity":""}`)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
+	if ping["connectivity"] != "" {
+		t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
+	}
+
+	js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
+	if ping["connectivity"] != "http://probe.example/204" {
+		t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
+	}
+}
+
+// leastPing/leastLoad always emit a burst observatory (Xray won't start them
+// without one); a stored {"enabled":false} is ignored as it is mandatory.
+func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
+	seedSubDB(t)
+	a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
+	b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	js.SetObservatoryConfig(`{"enabled":false}`)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
+	if pinger == nil {
+		t.Fatalf("balancer doc missing:\n%s", out)
+	}
+	if _, has := pinger["burstObservatory"]; !has {
+		t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
+	}
+	routing, _ := pinger["routing"].(map[string]any)
+	balancers, _ := routing["balancers"].([]any)
+	balancer, _ := balancers[0].(map[string]any)
+	if balancer["fallbackTag"] != "bal-1-vless" {
+		t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
+	}
+}
+
+func observatoryPingConfig(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)
+	}
+	obs, _ := doc["burstObservatory"].(map[string]any)
+	if obs == nil {
+		t.Fatalf("balancer %q has no burstObservatory", remarks)
+	}
+	ping, _ := obs["pingConfig"].(map[string]any)
+	return ping
+}

+ 53 - 0
internal/sub/sub_json_observatory_test.go

@@ -0,0 +1,53 @@
+package sub
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Bad observatory settings (malformed JSON, non-URL destination, bad duration)
+// must not leak into the emitted burstObservatory — each falls back to the
+// built-in defaults instead of poisoning the client config.
+func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
+	seedSubDB(t)
+	inb := seedSubInbound(t, "s1", "lp", 4821, 1, wsTLSStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	def := defaultSubBalancerObservatoryConfig()
+	cases := []struct {
+		name string
+		cfg  string
+	}{
+		{"bad json", `{not-json`},
+		{"bad destination", `{"destination":"not-a-url"}`},
+		{"bad interval", `{"interval":"xyz"}`},
+		{"bad timeout", `{"timeout":"5x"}`},
+		{"bad connectivity", `{"connectivity":"ftp://bad"}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			js := NewSubJsonService("", "", "", NewSubService(""))
+			js.SetObservatoryConfig(tc.cfg)
+			out, _, err := js.GetJson("s1", "req.example.com", true)
+			if err != nil {
+				t.Fatalf("GetJson: %v", err)
+			}
+			ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
+			if ping["destination"] != def.Destination {
+				t.Fatalf("destination = %v, want default %q (cfg=%s)", ping["destination"], def.Destination, tc.cfg)
+			}
+			if ping["interval"] != def.Interval {
+				t.Fatalf("interval = %v, want default %q (cfg=%s)", ping["interval"], def.Interval, tc.cfg)
+			}
+			if ping["timeout"] != def.Timeout {
+				t.Fatalf("timeout = %v, want default %q (cfg=%s)", ping["timeout"], def.Timeout, tc.cfg)
+			}
+			if ping["connectivity"] != def.Connectivity {
+				t.Fatalf("connectivity = %v, want default %q (cfg=%s)", ping["connectivity"], def.Connectivity, tc.cfg)
+			}
+		})
+	}
+}

+ 6 - 3
internal/web/controller/api.go

@@ -155,11 +155,11 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
 
 func relAPIPath(fullPath string) string {
 	const marker = "/panel/api"
-	i := strings.Index(fullPath, marker)
-	if i < 0 {
+	_, after, ok := strings.Cut(fullPath, marker)
+	if !ok {
 		return ""
 	}
-	return fullPath[i+len(marker):]
+	return after
 }
 
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
@@ -201,6 +201,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	a.settingController = NewSettingController(api)
 	a.xraySettingController = NewXraySettingController(api)
 
+	// Subscription balancers — client-side balancers for the JSON sub output
+	NewSubBalancerController(api)
+
 	// Extra routes
 	api.POST("/backuptotgbot", a.BackuptoTgbot)
 }

+ 126 - 0
internal/web/controller/sub_balancer.go

@@ -0,0 +1,126 @@
+package controller
+
+import (
+	"fmt"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// SubBalancerController manages client-side JSON-subscription balancers.
+type SubBalancerController struct {
+	SubBalancerService service.SubBalancerService
+}
+
+func NewSubBalancerController(g *gin.RouterGroup) *SubBalancerController {
+	a := &SubBalancerController{}
+	g = g.Group("/sub-balancers")
+	g.GET("", a.list)
+	g.POST("", a.create)
+	g.POST("/:id", a.update)
+	g.DELETE("/:id", a.del)
+	g.POST("/:id/del", a.del)
+	return a
+}
+
+// parseSubBalancerForm reads the urlencoded form (HttpUtil default): scalars
+// via ShouldBind, inboundIds as repeated keys. enabled is returned as *bool so
+// Update can keep the stored value when the key is absent; a bad value is a 400.
+func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
+	form := struct {
+		Remark    string `form:"remark"`
+		Strategy  string `form:"strategy"`
+		SortOrder int    `form:"sortOrder"`
+	}{}
+	if err := c.ShouldBind(&form); err != nil {
+		return nil, nil, err
+	}
+	var enabled *bool
+	if raw, ok := c.GetPostForm("enabled"); ok {
+		v, err := strconv.ParseBool(raw)
+		if err != nil {
+			return nil, nil, fmt.Errorf("invalid enabled %q: %w", raw, err)
+		}
+		enabled = &v
+	}
+	balancer := &model.SubBalancer{
+		Remark:    form.Remark,
+		Strategy:  form.Strategy,
+		SortOrder: form.SortOrder,
+	}
+	for _, raw := range c.PostFormArray("inboundIds") {
+		id, err := strconv.Atoi(raw)
+		if err != nil {
+			return nil, nil, fmt.Errorf("invalid inbound id %q: %w", raw, err)
+		}
+		balancer.InboundIds = append(balancer.InboundIds, id)
+	}
+	return balancer, enabled, nil
+}
+
+func (a *SubBalancerController) parseID(c *gin.Context) (int, error) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil || id < 1 {
+		return 0, fmt.Errorf("invalid id %q", c.Param("id"))
+	}
+	return id, nil
+}
+
+func (a *SubBalancerController) list(c *gin.Context) {
+	balancers, err := a.SubBalancerService.List()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.list"), err)
+		return
+	}
+	jsonObj(c, balancers, nil)
+}
+
+func (a *SubBalancerController) create(c *gin.Context) {
+	balancer, enabled, err := parseSubBalancerForm(c)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
+		return
+	}
+	balancer.Enabled = enabled == nil || *enabled
+	created, err := a.SubBalancerService.Create(balancer)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
+		return
+	}
+	jsonObj(c, created, nil)
+}
+
+func (a *SubBalancerController) update(c *gin.Context) {
+	id, err := a.parseID(c)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
+		return
+	}
+	balancer, enabled, err := parseSubBalancerForm(c)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
+		return
+	}
+	updated, err := a.SubBalancerService.Update(id, balancer, enabled)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
+		return
+	}
+	jsonObj(c, updated, nil)
+}
+
+func (a *SubBalancerController) del(c *gin.Context) {
+	id, err := a.parseID(c)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
+		return
+	}
+	if err := a.SubBalancerService.Delete(id); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.delete"), err)
+		return
+	}
+	jsonObj(c, "", nil)
+}

+ 105 - 0
internal/web/controller/sub_balancer_test.go

@@ -0,0 +1,105 @@
+package controller
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+func setupSubBalancerRouter(t *testing.T) *gin.Engine {
+	t.Helper()
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSubBalancerController(router.Group("/panel/api"))
+	return router
+}
+
+func subBalancerPost(t *testing.T, router *gin.Engine, path, body string) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp := httptest.NewRecorder()
+	router.ServeHTTP(resp, req)
+	return resp
+}
+
+func responseObj(t *testing.T, body string) map[string]any {
+	t.Helper()
+	var m map[string]any
+	if err := json.Unmarshal([]byte(body), &m); err != nil {
+		t.Fatalf("unmarshal response %q: %v", body, err)
+	}
+	return m
+}
+
+// enabled absent on create defaults to true; "false" disables; a non-boolean
+// value is rejected so a malformed toggle can't silently flip the row.
+func TestSubBalancerController_EnabledParsing(t *testing.T) {
+	router := setupSubBalancerRouter(t)
+	base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
+
+	resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base)
+	if !strings.Contains(resp.Body.String(), `"success":true`) {
+		t.Fatalf("create no enabled: %s", resp.Body.String())
+	}
+	bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
+	if bal["enabled"] != true {
+		t.Fatalf("absent enabled = %v, want true", bal["enabled"])
+	}
+
+	resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
+	bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
+	if bal["enabled"] != false {
+		t.Fatalf("enabled=false -> %v, want false", bal["enabled"])
+	}
+
+	resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=bogus")
+	if !strings.Contains(resp.Body.String(), `"success":false`) {
+		t.Fatalf("enabled=bogus should be rejected: %s", resp.Body.String())
+	}
+}
+
+// An update omitting enabled preserves the stored value instead of resetting it
+// to the create default — a partial PATCH must not clobber the toggle.
+func TestSubBalancerController_UpdatePreservesEnabledWhenAbsent(t *testing.T) {
+	router := setupSubBalancerRouter(t)
+	base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
+
+	resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
+	bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
+	id := strconv.Itoa(int(bal["id"].(float64)))
+	if bal["enabled"] != false {
+		t.Fatalf("setup: enabled = %v, want false", bal["enabled"])
+	}
+
+	resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1")
+	if !strings.Contains(resp.Body.String(), `"success":true`) {
+		t.Fatalf("update: %s", resp.Body.String())
+	}
+	bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
+	if bal["enabled"] != false {
+		t.Fatalf("update without enabled = %v, want preserved false", bal["enabled"])
+	}
+	if bal["remark"] != "renamed" {
+		t.Fatalf("remark = %v, want renamed", bal["remark"])
+	}
+
+	resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1&enabled=true")
+	bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
+	if bal["enabled"] != true {
+		t.Fatalf("enabled=true -> %v, want true", bal["enabled"])
+	}
+}

+ 1 - 0
internal/web/entity/entity.go

@@ -105,6 +105,7 @@ type AllSetting struct {
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
+	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`
 	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`
 

+ 4 - 5
internal/web/job/ip_limit_allowlist.go

@@ -2,6 +2,7 @@ package job
 
 import (
 	"net/netip"
+	"slices"
 	"strings"
 )
 
@@ -16,7 +17,7 @@ type ipLimitAllowlist struct {
 // skipped: the validator uses these same rules, so only a hand-edited DB differs.
 func parseIpLimitAllowlist(raw string) ipLimitAllowlist {
 	var list ipLimitAllowlist
-	for _, field := range strings.Split(raw, ",") {
+	for field := range strings.SplitSeq(raw, ",") {
 		field = strings.TrimSpace(field)
 		if field == "" {
 			continue
@@ -52,10 +53,8 @@ func (l ipLimitAllowlist) contains(ip string) bool {
 		return false
 	}
 	addr = addr.Unmap()
-	for _, allowed := range l.addrs {
-		if allowed == addr {
-			return true
-		}
+	if slices.Contains(l.addrs, addr) {
+		return true
 	}
 	for _, prefix := range l.prefixes {
 		if prefix.Contains(addr) {

+ 2 - 2
internal/web/network/auto_https_conn.go

@@ -46,8 +46,8 @@ func (c *AutoHttpsConn) readRequest() bool {
 	}
 	resp := http.Response{
 		Header: http.Header{},
-	}
-	resp.StatusCode = http.StatusTemporaryRedirect
+
+		StatusCode: http.StatusTemporaryRedirect}
 	location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI)
 	resp.Header.Set("Location", location)
 	_ = resp.Write(c.Conn)

+ 3 - 7
internal/web/service/client_external_link_test.go

@@ -7,10 +7,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
-func externalLinkBool(v bool) *bool {
-	return &v
-}
-
 func TestSetExternalLinksPersistsEnableState(t *testing.T) {
 	setupBulkDB(t)
 	db := database.GetDB()
@@ -22,8 +18,8 @@ func TestSetExternalLinksPersistsEnableState(t *testing.T) {
 	}
 
 	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
-		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#on", Remark: "Primary", Enable: externalLinkBool(true), ExpiryTime: 1767225600000},
-		{Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: externalLinkBool(false), NamePrefix: "[zjh] "},
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#on", Remark: "Primary", Enable: new(true), ExpiryTime: 1767225600000},
+		{Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: new(false), NamePrefix: "[zjh] "},
 		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#default"},
 	}); err != nil {
 		t.Fatalf("set external links: %v", err)
@@ -76,7 +72,7 @@ func TestSetExternalLinksPreservesFetchStatus(t *testing.T) {
 	}
 
 	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
-		{Kind: row.Kind, Value: row.Value, Remark: "new", Enable: externalLinkBool(true)},
+		{Kind: row.Kind, Value: row.Value, Remark: "new", Enable: new(true)},
 	}); err != nil {
 		t.Fatalf("set external links: %v", err)
 	}

+ 5 - 6
internal/web/service/client_test.go

@@ -4,15 +4,14 @@ import (
 	"encoding/json"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
 	c := ClientWithAttachments{
-		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
-		InboundIds:   []int{3, 5},
-		Traffic:      &xray.ClientTraffic{Email: "[email protected]", Up: 1024, Down: 4096, Enable: true},
+		Id: 1, Email: "[email protected]",
+		InboundIds: []int{3, 5},
+		Traffic:    &xray.ClientTraffic{Email: "[email protected]", Up: 1024, Down: 4096, Enable: true},
 	}
 	out, err := json.Marshal(c)
 	if err != nil {
@@ -39,8 +38,8 @@ func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
 
 func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
 	c := ClientWithAttachments{
-		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
-		InboundIds:   nil,
+		Id: 1, Email: "[email protected]",
+		InboundIds: nil,
 	}
 	out, err := json.Marshal(c)
 	if err != nil {

+ 17 - 0
internal/web/service/inbound.go

@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"net"
 	"regexp"
+	"slices"
 	"sort"
 	"strings"
 	"time"
@@ -1188,6 +1189,22 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
 			return err
 		}
+		// Drop the deleted inbound from any sub-balancer that selects it; a
+		// dangling id would emit a member no subscriber can resolve (#5648).
+		var balancers []model.SubBalancer
+		if err := tx.Find(&balancers).Error; err != nil {
+			return err
+		}
+		for i := range balancers {
+			before := balancers[i].InboundIds
+			balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id })
+			if len(balancers[i].InboundIds) == len(before) {
+				continue
+			}
+			if err := tx.Save(&balancers[i]).Error; err != nil {
+				return err
+			}
+		}
 		if loadErr == nil && ib.NodeID != nil {
 			return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
 		}

+ 1 - 1
internal/web/service/inbound_autorenew_calendar_test.go

@@ -118,7 +118,7 @@ func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
 func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
 	t.Helper()
 	cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
-	for i := 0; i < 400; i++ {
+	for range 400 {
 		cur = cur.AddDate(0, 0, 1)
 		want := day
 		if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {

+ 5 - 0
internal/web/service/setting.go

@@ -118,6 +118,7 @@ var defaultValueMap = map[string]string{
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
 	"subJsonFinalMask":            "",
+	"subJsonObservatory":          "",
 	"subThemeDir":                 "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
@@ -893,6 +894,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	return s.getString("subJsonFinalMask")
 }
 
+func (s *SettingService) GetSubJsonObservatory() (string, error) {
+	return s.getString("subJsonObservatory")
+}
+
 func (s *SettingService) GetSubThemeDir() (string, error) {
 	return s.getString("subThemeDir")
 }

+ 1 - 1
internal/web/service/setting_mtls_test.go

@@ -187,7 +187,7 @@ func TestEnsureMasterClientCertConcurrentFirstUseMintsOneCredential(t *testing.T
 	}
 	close(start)
 	var first crypto.CertKeyPEM
-	for i := 0; i < callers; i++ {
+	for i := range callers {
 		credential := <-results
 		if err := <-errs; err != nil {
 			t.Fatalf("caller %d: %v", i, err)

+ 101 - 0
internal/web/service/sub_balancer.go

@@ -0,0 +1,101 @@
+package service
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+var subBalancerStrategies = map[string]struct{}{
+	"leastLoad":  {},
+	"leastPing":  {},
+	"random":     {},
+	"roundRobin": {},
+}
+
+// SubBalancerService manages client-side JSON-subscription balancers; rows
+// are read per request by internal/sub, so mutations need no xray restart.
+type SubBalancerService struct{}
+
+func (s *SubBalancerService) validate(b *model.SubBalancer) error {
+	b.Remark = strings.TrimSpace(b.Remark)
+	if b.Remark == "" {
+		return common.NewError("balancer remark is required")
+	}
+	if len(b.Remark) > 256 {
+		return common.NewError("balancer remark too long (max 256)")
+	}
+	if b.Strategy == "" {
+		b.Strategy = "random"
+	}
+	if _, ok := subBalancerStrategies[b.Strategy]; !ok {
+		return common.NewError("invalid balancer strategy:", b.Strategy)
+	}
+	if len(b.InboundIds) == 0 {
+		return common.NewError("balancer must select at least one inbound")
+	}
+	if b.SortOrder < 1 {
+		b.SortOrder = 1
+	}
+	return nil
+}
+
+// List returns all balancers in subscription order.
+func (s *SubBalancerService) List() ([]*model.SubBalancer, error) {
+	var balancers []*model.SubBalancer
+	err := database.GetDB().Model(&model.SubBalancer{}).
+		Order("sort_order asc, id asc").Find(&balancers).Error
+	return balancers, err
+}
+
+func (s *SubBalancerService) Get(id int) (*model.SubBalancer, error) {
+	var balancer model.SubBalancer
+	if err := database.GetDB().First(&balancer, id).Error; err != nil {
+		return nil, err
+	}
+	return &balancer, nil
+}
+
+func (s *SubBalancerService) Create(balancer *model.SubBalancer) (*model.SubBalancer, error) {
+	if err := s.validate(balancer); err != nil {
+		return nil, err
+	}
+	if err := database.GetDB().Create(balancer).Error; err != nil {
+		return nil, err
+	}
+	return balancer, nil
+}
+
+func (s *SubBalancerService) Update(id int, balancer *model.SubBalancer, enabled *bool) (*model.SubBalancer, error) {
+	if err := s.validate(balancer); err != nil {
+		return nil, err
+	}
+	current, err := s.Get(id)
+	if err != nil {
+		return nil, err
+	}
+	current.Remark = balancer.Remark
+	current.Strategy = balancer.Strategy
+	current.InboundIds = balancer.InboundIds
+	current.SortOrder = balancer.SortOrder
+	if enabled != nil {
+		current.Enabled = *enabled
+	}
+	if err := database.GetDB().Save(current).Error; err != nil {
+		return nil, err
+	}
+	return current, nil
+}
+
+func (s *SubBalancerService) Delete(id int) error {
+	res := database.GetDB().Delete(&model.SubBalancer{}, id)
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return common.NewError("sub balancer not found")
+	}
+	return nil
+}

+ 36 - 0
internal/web/service/sub_balancer_inbound_cleanup_test.go

@@ -0,0 +1,36 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Deleting an inbound that a sub-balancer selects must strip its id from
+// InboundIds, leaving no dangling member reference (#5648 mirrors the hosts
+// cascade). With the only member gone the balancer stops emitting a doc.
+func TestDelInboundClearsSubBalancerInboundIds(t *testing.T) {
+	setupSubBalancerDB(t)
+	ib := &model.Inbound{UserId: 1, Tag: "cleanup", Enable: false, Listen: "203.0.113.7", Port: 5001, Protocol: model.VLESS, Remark: "cleanup", Settings: `{}`, StreamSettings: `{}`}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	balSvc := &SubBalancerService{}
+	bal, err := balSvc.Create(&model.SubBalancer{Remark: "bal", Strategy: "random", InboundIds: []int{ib.Id}, SortOrder: 1, Enabled: true})
+	if err != nil {
+		t.Fatalf("create balancer: %v", err)
+	}
+
+	if _, err := (&InboundService{}).DelInbound(ib.Id); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+
+	stored, err := balSvc.Get(bal.Id)
+	if err != nil {
+		t.Fatalf("get balancer: %v", err)
+	}
+	if len(stored.InboundIds) != 0 {
+		t.Fatalf("InboundIds = %v, want empty (no dangling id)", stored.InboundIds)
+	}
+}

+ 161 - 0
internal/web/service/sub_balancer_test.go

@@ -0,0 +1,161 @@
+package service
+
+import (
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+var subBalancerLoggerOnce sync.Once
+
+func setupSubBalancerDB(t *testing.T) {
+	t.Helper()
+	subBalancerLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := database.CloseDB(); err != nil {
+			t.Logf("CloseDB warning: %v", err)
+		}
+	})
+}
+
+func TestSubBalancerServiceCRUD(t *testing.T) {
+	setupSubBalancerDB(t)
+	svc := &SubBalancerService{}
+
+	created, err := svc.Create(&model.SubBalancer{
+		Remark: "auto", Strategy: "", InboundIds: []int{1, 2}, SortOrder: 0, Enabled: false,
+	})
+	if err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	if created.Strategy != "random" {
+		t.Fatalf("strategy = %q, want normalized random", created.Strategy)
+	}
+	if created.SortOrder != 1 {
+		t.Fatalf("sortOrder = %d, want normalized 1", created.SortOrder)
+	}
+	stored, err := svc.Get(created.Id)
+	if err != nil {
+		t.Fatalf("get: %v", err)
+	}
+	if stored.Enabled {
+		t.Fatal("explicit disabled balancer must be stored disabled")
+	}
+
+	second, err := svc.Create(&model.SubBalancer{
+		Remark: "second", Strategy: "leastPing", InboundIds: []int{1}, SortOrder: 3, Enabled: true,
+	})
+	if err != nil {
+		t.Fatalf("create second: %v", err)
+	}
+
+	list, err := svc.List()
+	if err != nil {
+		t.Fatalf("list: %v", err)
+	}
+	if len(list) != 2 || list[0].Id != created.Id || list[1].Id != second.Id {
+		t.Fatalf("list order = [%d %d], want [%d %d]", list[0].Id, list[1].Id, created.Id, second.Id)
+	}
+
+	enabledFalse := false
+	updated, err := svc.Update(second.Id, &model.SubBalancer{
+		Remark: "renamed", Strategy: "leastLoad", InboundIds: []int{2}, SortOrder: 2,
+	}, &enabledFalse)
+	if err != nil {
+		t.Fatalf("update: %v", err)
+	}
+	if updated.Remark != "renamed" || updated.Strategy != "leastLoad" || updated.SortOrder != 2 || updated.Enabled {
+		t.Fatalf("update stored wrong row: %+v", updated)
+	}
+	after, err := svc.Get(second.Id)
+	if err != nil {
+		t.Fatalf("get after update: %v", err)
+	}
+	if after.Enabled || after.Strategy != "leastLoad" || len(after.InboundIds) != 1 || after.InboundIds[0] != 2 {
+		t.Fatalf("update did not persist: %+v", after)
+	}
+
+	if err := svc.Delete(created.Id); err != nil {
+		t.Fatalf("delete: %v", err)
+	}
+	list, err = svc.List()
+	if err != nil {
+		t.Fatalf("list after delete: %v", err)
+	}
+	if len(list) != 1 || list[0].Id != second.Id {
+		t.Fatalf("list after delete = %v", list)
+	}
+}
+
+// roundRobin is a valid xray routing strategy (selects outbounds in order) and
+// must pass the same validation as the other three.
+func TestSubBalancerServiceRoundRobin(t *testing.T) {
+	setupSubBalancerDB(t)
+	svc := &SubBalancerService{}
+
+	created, err := svc.Create(&model.SubBalancer{
+		Remark: "rr", Strategy: "roundRobin", InboundIds: []int{1, 2}, SortOrder: 1, Enabled: true,
+	})
+	if err != nil {
+		t.Fatalf("create roundRobin: %v", err)
+	}
+	if created.Strategy != "roundRobin" {
+		t.Fatalf("strategy = %q, want roundRobin", created.Strategy)
+	}
+	stored, err := svc.Get(created.Id)
+	if err != nil {
+		t.Fatalf("get: %v", err)
+	}
+	if stored.Strategy != "roundRobin" {
+		t.Fatalf("stored strategy = %q, want roundRobin", stored.Strategy)
+	}
+}
+
+// Deleting a missing balancer reports not-found instead of success:true, so
+// a stale UI row can't claim a delete that touched nothing.
+func TestSubBalancerServiceDeleteNotFound(t *testing.T) {
+	setupSubBalancerDB(t)
+	svc := &SubBalancerService{}
+	if err := svc.Delete(999); err == nil || !strings.Contains(err.Error(), "not found") {
+		t.Fatalf("Delete(999) = %v, want a not-found error", err)
+	}
+}
+
+func TestSubBalancerServiceValidation(t *testing.T) {
+	setupSubBalancerDB(t)
+	svc := &SubBalancerService{}
+
+	cases := []struct {
+		name string
+		row  model.SubBalancer
+		want string
+	}{
+		{"empty remark", model.SubBalancer{Strategy: "random", InboundIds: []int{1}}, "remark is required"},
+		{"bad strategy", model.SubBalancer{Remark: "x", Strategy: "fastest", InboundIds: []int{1}}, "invalid balancer strategy"},
+		{"no inbounds", model.SubBalancer{Remark: "x", Strategy: "random"}, "at least one inbound"},
+		{"long remark", model.SubBalancer{Remark: strings.Repeat("x", 257), Strategy: "random", InboundIds: []int{1}}, "max 256"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := svc.Create(&tc.row)
+			if err == nil {
+				t.Fatal("create must fail")
+			}
+			if !strings.Contains(err.Error(), tc.want) {
+				t.Fatalf("error = %q, want substring %q", err.Error(), tc.want)
+			}
+		})
+	}
+}

+ 50 - 1
internal/web/translation/ar-EG.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "قائمة سماح حد IP",
-      "ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل."
+      "ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل.",
+      "subBalancers": {
+        "menu": "موزّعات الاشتراك",
+        "title": "موزّع الاشتراك",
+        "add": "إضافة موزّع",
+        "desc": "كل موزّع مُفعّل يُضاف إلى اشتراك JSON كملف تعريف إضافي يختار تلقائيًا أفضل نقطة نهاية من الإينبوندات المحددة.",
+        "remark": "ملاحظة",
+        "remarkPlaceholder": "تلقائي · الأسرع",
+        "strategy": "الاستراتيجية",
+        "strategyLeastLoad": "أقل حمل",
+        "strategyLeastPing": "أقل ping",
+        "strategyRandom": "عشوائي",
+        "strategyRoundRobin": "دوران",
+        "sortOrder": "الترتيب",
+        "sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.",
+        "inbounds": "الإينبوندات",
+        "inboundsCount": "{count} الإينبوندات",
+        "enabled": "مُفعّل",
+        "empty": "لا يوجد موزّعات بعد",
+        "deleteConfirm": "حذف هذا الموزّع؟",
+        "errRemarkRequired": "الملاحظة مطلوبة",
+        "errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل",
+        "errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1",
+        "toasts": {
+          "list": "تعذّر عرض موزّعات الاشتراك",
+          "create": "تعذّر إنشاء موزّع اشتراك",
+          "update": "تعذّر تحديث موزّع اشتراك",
+          "delete": "تعذّر حذف موزّع اشتراك",
+          "invalidId": "معرّف غير صالح"
+        },
+        "tabBalancers": "موازنات التحميل",
+        "tabObservatory": "المرصد",
+        "observatory": {
+          "title": "مرصد الموزّع",
+          "desc": "معاملات probe لـ burstObservatory المُضمَّن في كل ملف leastPing/leastLoad. random/roundRobin بلا مرصد. يُحفظ كإعداد شامل لاشتراك JSON.",
+          "destination": "عنوان probe",
+          "destinationDesc": "العنوان الذي يقيس العميل به كل صادر عضو.",
+          "connectivity": "عنوان الاتصالية",
+          "connectivityDesc": "عنوان اختياري للتحقق مرة واحدة من وصول العضو للهدف. اتركه فارغًا للتخطي.",
+          "interval": "فترة probe",
+          "intervalDesc": "الزمن بين جولات probe، مثال 1m.",
+          "timeout": "مهلة probe",
+          "timeoutDesc": "مهلة probe واحدة، مثال 5s.",
+          "sampling": "أخذ العينات",
+          "samplingDesc": "عدد probe المتتالية لقياس الاستقرار.",
+          "httpMethod": "أسلوب HTTP",
+          "httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
+          "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
+        }
+      }
     },
     "xray": {
       "save": "احفظ",

+ 50 - 1
internal/web/translation/en-US.json

@@ -1504,7 +1504,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "IP limit allowlist",
-      "ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR."
+      "ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR.",
+      "subBalancers": {
+        "menu": "Sub Balancers",
+        "title": "Subscription balancer",
+        "add": "Add balancer",
+        "desc": "Each enabled balancer is added to the JSON subscription as one extra profile that automatically picks the best of the selected inbounds' endpoints (routing.balancers + burstObservatory in the client config).",
+        "remark": "Remark",
+        "remarkPlaceholder": "Auto · fastest",
+        "strategy": "Strategy",
+        "strategyLeastLoad": "Least load",
+        "strategyLeastPing": "Least ping",
+        "strategyRandom": "Random",
+        "strategyRoundRobin": "Round robin",
+        "sortOrder": "Order",
+        "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",
+        "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",
+        "toasts": {
+          "list": "Failed to list subscription balancers",
+          "create": "Failed to create subscription balancer",
+          "update": "Failed to update subscription balancer",
+          "delete": "Failed to delete subscription balancer",
+          "invalidId": "Invalid id"
+        },
+        "tabBalancers": "Balancers",
+        "tabObservatory": "Observatory",
+        "observatory": {
+          "title": "Balancer observatory",
+          "desc": "Probe parameters for the burst observatory emitted into each leastPing/leastLoad balancer profile. random/roundRobin balancers get no observatory. Stored as a panel-wide JSON-sub setting.",
+          "destination": "Probe URL",
+          "destinationDesc": "URL the client pings to measure each member outbound.",
+          "connectivity": "Connectivity URL",
+          "connectivityDesc": "Optional URL checked once to confirm the member can reach the probe destination. Leave empty to skip.",
+          "interval": "Probe interval",
+          "intervalDesc": "Time between probe rounds, e.g. 1m.",
+          "timeout": "Probe timeout",
+          "timeoutDesc": "Per-probe timeout, e.g. 5s.",
+          "sampling": "Sampling",
+          "samplingDesc": "Number of consecutive probes averaged for stability.",
+          "httpMethod": "HTTP method",
+          "httpMethodDesc": "Method used for probe requests.",
+          "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
+        }
+      }
     },
     "xray": {
       "save": "Save",

+ 50 - 1
internal/web/translation/es-ES.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "Lista de permitidos del límite de IP",
-      "ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma."
+      "ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma.",
+      "subBalancers": {
+        "menu": "Balanceadores de suscripción",
+        "title": "Balanceador de suscripción",
+        "add": "Añadir balanceador",
+        "desc": "Cada balanceador activo se añade a la suscripción JSON como un perfil adicional que elige automáticamente el mejor de los endpoints de los inbounds seleccionados.",
+        "remark": "Comentario",
+        "remarkPlaceholder": "Auto · el más rápido",
+        "strategy": "Estrategia",
+        "strategyLeastLoad": "Menor carga",
+        "strategyLeastPing": "Menor ping",
+        "strategyRandom": "Aleatorio",
+        "strategyRoundRobin": "Round robin",
+        "sortOrder": "Orden",
+        "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",
+        "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",
+        "toasts": {
+          "list": "No se pudieron listar los balanceadores de suscripción",
+          "create": "No se pudo crear el balanceador de suscripción",
+          "update": "No se pudo actualizar el balanceador de suscripción",
+          "delete": "No se pudo eliminar el balanceador de suscripción",
+          "invalidId": "Id no válido"
+        },
+        "tabBalancers": "Equilibradores",
+        "tabObservatory": "Observatorio",
+        "observatory": {
+          "title": "Observatorio del balanceador",
+          "desc": "Parámetros de probe para el burstObservatory incluido en cada perfil leastPing/leastLoad. random/roundRobin no generan observatorio. Se guarda como ajuste global de la suscripción JSON.",
+          "destination": "URL de probe",
+          "destinationDesc": "Dirección que el cliente sondea para medir cada salida miembro.",
+          "connectivity": "URL de conectividad",
+          "connectivityDesc": "Dirección opcional para verificar una vez que el miembro llega al destino. Vacío para omitir.",
+          "interval": "Intervalo de probe",
+          "intervalDesc": "Tiempo entre rondas de probe, p. ej. 1m.",
+          "timeout": "Tiempo de espera de probe",
+          "timeoutDesc": "Tiempo de espera de cada probe, p. ej. 5s.",
+          "sampling": "Muestreo",
+          "samplingDesc": "Número de probes consecutivos para promediar estabilidad.",
+          "httpMethod": "Método HTTP",
+          "httpMethodDesc": "Método usado para las solicitudes de probe.",
+          "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
+        }
+      }
     },
     "xray": {
       "save": "Guardar configuración",

+ 50 - 1
internal/web/translation/fa-IR.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "فهرست مجاز محدودیت IP",
-      "ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما)."
+      "ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما).",
+      "subBalancers": {
+        "menu": "موزان‌کننده‌های اشتراک",
+        "title": "موزان‌کننده اشتراک",
+        "add": "افزودن موزان‌کننده",
+        "desc": "هر موزان‌کنندهٔ فعال به‌عنوان یک پروفایل اضافه به اشتراک JSON اضافه می‌شود و به‌طور خودکار بهترین نقطهٔ پایانیِ اینباند‌های انتخاب‌شده را برمی‌گزیند.",
+        "remark": "توضیح",
+        "remarkPlaceholder": "خودکار · سریع‌ترین",
+        "strategy": "استراتژی",
+        "strategyLeastLoad": "کم‌ترین بار",
+        "strategyLeastPing": "کم‌ترین پینگ",
+        "strategyRandom": "تصادفی",
+        "strategyRoundRobin": "گردشی",
+        "sortOrder": "ترتیب",
+        "sortOrderHelp": "جایگاه در فهرست اشتراک، درهم‌تنیده با ترتیب اینباند‌ها؛ با شمارهٔ برابر، موزان‌کننده بعد از اینباند می‌آید.",
+        "inbounds": "اینباند‌ها",
+        "inboundsCount": "{count} اینباند‌ها",
+        "enabled": "فعال",
+        "empty": "هنوز موزان‌کننده‌ای وجود ندارد",
+        "deleteConfirm": "این موزان‌کننده حذف شود؟",
+        "errRemarkRequired": "توضیح الزامی است",
+        "errInboundsRequired": "حداقل یک اینباند انتخاب کنید",
+        "errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد",
+        "toasts": {
+          "list": "فهرست‌سازی موزان‌کننده‌های اشتراک ناموفق بود",
+          "create": "ایجاد موزان‌کننده اشتراک ناموفق بود",
+          "update": "به‌روزرسانی موزان‌کننده اشتراک ناموفق بود",
+          "delete": "حذف موزان‌کننده اشتراک ناموفق بود",
+          "invalidId": "شناسه نامعتبر"
+        },
+        "tabBalancers": "بالانسرها",
+        "tabObservatory": "رصدخانه",
+        "observatory": {
+          "title": "رصدگر موزان‌کننده",
+          "desc": "پارامترهای probe برای burstObservatory که در هر پروفایل leastPing/leastLoad نوشته می‌شود. random/roundRobin رصدگر ندارند. به‌صورت تنظیم سراسری اشتراک JSON ذخیره می‌شود.",
+          "destination": "آدرس probe",
+          "destinationDesc": "آدرسی که کلاینت برای سنجش هر خروجی عضو آن را probe می‌کند.",
+          "connectivity": "آدرس اتصال",
+          "connectivityDesc": "آدرس اختیاری برای بررسی یک‌بارهٔ دسترسی به هدف. خالی بگذارید تا رد شود.",
+          "interval": "بازه probe",
+          "intervalDesc": "زمان بین دورهای probe، مثلاً 1m.",
+          "timeout": "مهلت probe",
+          "timeoutDesc": "مهلت هر probe، مثلاً 5s.",
+          "sampling": "نمونه‌برداری",
+          "samplingDesc": "تعداد probe متوالی برای میانگین پایداری.",
+          "httpMethod": "متد HTTP",
+          "httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.",
+          "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
+        }
+      }
     },
     "xray": {
       "save": "ذخیره",

+ 50 - 1
internal/web/translation/id-ID.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "Daftar izin batas IP",
-      "ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma)."
+      "ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma).",
+      "subBalancers": {
+        "menu": "Penyeimbang langganan",
+        "title": "Penyeimbang langganan",
+        "add": "Tambah penyeimbang",
+        "desc": "Setiap penyeimbang yang aktif ditambahkan ke langganan JSON sebagai profil tambahan yang otomatis memilih titik akhir terbaik dari inbound terpilih.",
+        "remark": "Keterangan",
+        "remarkPlaceholder": "Otomatis · tercepat",
+        "strategy": "Strategi",
+        "strategyLeastLoad": "Beban terendah",
+        "strategyLeastPing": "Ping terendah",
+        "strategyRandom": "Acak",
+        "strategyRoundRobin": "Round robin",
+        "sortOrder": "Urutan",
+        "sortOrderHelp": "Posisi dalam daftar langganan, berselang-seling dengan urutan inbound; jika sama, penyeimbang berada setelah inbound.",
+        "inbounds": "Inbound",
+        "inboundsCount": "{count} Inbound",
+        "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",
+        "toasts": {
+          "list": "Gagal menampilkan daftar penyeimbang langganan",
+          "create": "Gagal membuat penyeimbang langganan",
+          "update": "Gagal memperbarui penyeimbang langganan",
+          "delete": "Gagal menghapus penyeimbang langganan",
+          "invalidId": "Id tidak valid"
+        },
+        "tabBalancers": "Penyeimbang",
+        "tabObservatory": "Observatory",
+        "observatory": {
+          "title": "Observatorium penyeimbang",
+          "desc": "Parameter probe untuk burstObservatory yang disisipkan ke setiap profil leastPing/leastLoad. random/roundRobin tanpa observatorium. Disimpan sebagai pengaturan langganan JSON tingkat panel.",
+          "destination": "URL probe",
+          "destinationDesc": "Alamat yang di-probe klien untuk mengukur setiap outbound anggota.",
+          "connectivity": "URL konektivitas",
+          "connectivityDesc": "Alamat opsional untuk memeriksa sekali bahwa anggota menjangkau tujuan. Kosongkan untuk melewati.",
+          "interval": "Interval probe",
+          "intervalDesc": "Waktu antar ronde probe, mis. 1m.",
+          "timeout": "Waktu habis probe",
+          "timeoutDesc": "Waktu habis per probe, mis. 5s.",
+          "sampling": "Pengambilan sampel",
+          "samplingDesc": "Jumlah probe beruntun untuk merata-ratakan stabilitas.",
+          "httpMethod": "Metode HTTP",
+          "httpMethodDesc": "Metode yang dipakai untuk permintaan probe.",
+          "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
+        }
+      }
     },
     "xray": {
       "save": "Simpan",

+ 50 - 1
internal/web/translation/ja-JP.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "IP 制限の許可リスト",
-      "ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。"
+      "ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。",
+      "subBalancers": {
+        "menu": "サブスクリプションバランサー",
+        "title": "サブスクリプションバランサー",
+        "add": "バランサーを追加",
+        "desc": "有効なバランサーは JSON サブスクリプションに追加プロファイルとして加わり、選択したインバウンドのエンドポイントから最適なものを自動選択します。",
+        "remark": "備考",
+        "remarkPlaceholder": "自動 · 最速",
+        "strategy": "方式",
+        "strategyLeastLoad": "最小負荷",
+        "strategyLeastPing": "最小 ping",
+        "strategyRandom": "ランダム",
+        "strategyRoundRobin": "ラウンドロビン",
+        "sortOrder": "順序",
+        "sortOrderHelp": "サブスクリプション一覧内の位置。インバウンドの順序と交互に並び、同番号の場合はインバウンドの後ろになります。",
+        "inbounds": "インバウンド",
+        "inboundsCount": "{count} インバウンド",
+        "enabled": "有効",
+        "empty": "バランサーはまだありません",
+        "deleteConfirm": "このバランサーを削除しますか?",
+        "errRemarkRequired": "備考を入力してください",
+        "errInboundsRequired": "インバウンドを1つ以上選択してください",
+        "errSortOrder": "順序は1以上の整数にしてください",
+        "toasts": {
+          "list": "サブスクリプションバランサーの一覧取得に失敗しました",
+          "create": "サブスクリプションバランサーの作成に失敗しました",
+          "update": "サブスクリプションバランサーの更新に失敗しました",
+          "delete": "サブスクリプションバランサーの削除に失敗しました",
+          "invalidId": "無効な id です"
+        },
+        "tabBalancers": "負荷分散",
+        "tabObservatory": "オブザーバトリ",
+        "observatory": {
+          "title": "バランサー観測",
+          "desc": "各 leastPing/leastLoad バランサープロファイルに埋め込む burstObservatory のプローブ設定。random/roundRobin には観測を入れません。パネル全体の JSON サブ設定として保存されます。",
+          "destination": "プローブ URL",
+          "destinationDesc": "クライアントが各メンバーアウトバウンドを計測するためのアドレス。",
+          "connectivity": "接続確認 URL",
+          "connectivityDesc": "メンバーがプローブ先へ到達できるか一度確認する任意のアドレス。空ならスキップ。",
+          "interval": "プローブ間隔",
+          "intervalDesc": "プローブ周期の間隔(例: 1m)。",
+          "timeout": "プローブタイムアウト",
+          "timeoutDesc": "1回のプローブのタイムアウト(例: 5s)。",
+          "sampling": "サンプリング",
+          "samplingDesc": "安定度を平均するための連続プローブ回数。",
+          "httpMethod": "HTTP メソッド",
+          "httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
+          "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
+        }
+      }
     },
     "xray": {
       "importRules": "ルールをインポート",

+ 50 - 1
internal/web/translation/pt-BR.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "Lista de permissões do limite de IP",
-      "ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula."
+      "ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula.",
+      "subBalancers": {
+        "menu": "Balanceadores de assinatura",
+        "title": "Balanceador de assinatura",
+        "add": "Adicionar balanceador",
+        "desc": "Cada balanceador ativo é adicionado à assinatura JSON como um perfil extra que escolhe automaticamente o melhor endpoint entre os inbounds selecionados.",
+        "remark": "Descrição",
+        "remarkPlaceholder": "Auto · mais rápido",
+        "strategy": "Estratégia",
+        "strategyLeastLoad": "Menor carga",
+        "strategyLeastPing": "Menor ping",
+        "strategyRandom": "Aleatório",
+        "strategyRoundRobin": "Round robin",
+        "sortOrder": "Ordem",
+        "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",
+        "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",
+        "toasts": {
+          "list": "Falha ao listar os balanceadores de assinatura",
+          "create": "Falha ao criar o balanceador de assinatura",
+          "update": "Falha ao atualizar o balanceador de assinatura",
+          "delete": "Falha ao excluir o balanceador de assinatura",
+          "invalidId": "Id inválido"
+        },
+        "tabBalancers": "Balanceadores",
+        "tabObservatory": "Observatório",
+        "observatory": {
+          "title": "Observatório do balanceador",
+          "desc": "Parâmetros de probe para o burstObservatory embutido em cada perfil leastPing/leastLoad. random/roundRobin não geram observatório. Salvo como ajuste global da assinatura JSON.",
+          "destination": "URL de probe",
+          "destinationDesc": "Endereço que o cliente sonda para medir cada saída membro.",
+          "connectivity": "URL de conectividade",
+          "connectivityDesc": "Endereço opcional para verificar uma vez que o membro alcança o destino. Vazio para pular.",
+          "interval": "Intervalo de probe",
+          "intervalDesc": "Tempo entre rodadas de probe, p. ex. 1m.",
+          "timeout": "Tempo limite de probe",
+          "timeoutDesc": "Tempo limite de cada probe, p. ex. 5s.",
+          "sampling": "Amostragem",
+          "samplingDesc": "Número de probes consecutivos para média de estabilidade.",
+          "httpMethod": "Método HTTP",
+          "httpMethodDesc": "Método usado nas requisições de probe.",
+          "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
+        }
+      }
     },
     "xray": {
       "importRules": "Importar regras",

+ 50 - 1
internal/web/translation/ru-RU.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Григорианский (обычный)",
       "calendarJalalian": "Джалали (شمسی)",
       "ipLimitAllowlist": "Доверенные адреса для лимита",
-      "ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть."
+      "ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть.",
+      "subBalancers": {
+        "menu": "Балансировщики подписки",
+        "title": "Балансировщик подписки",
+        "add": "Добавить балансировщик",
+        "desc": "Каждый включённый балансировщик добавляется в JSON-подписку как отдельный профиль, автоматически выбирающий лучший из эндпоинтов выбранных инбаундов (routing.balancers + burstObservatory в клиентском конфиге).",
+        "remark": "Примечание",
+        "remarkPlaceholder": "Авто · самый быстрый",
+        "strategy": "Стратегия",
+        "strategyLeastLoad": "Минимальная нагрузка",
+        "strategyLeastPing": "Минимальный пинг",
+        "strategyRandom": "Случайный",
+        "strategyRoundRobin": "По очереди",
+        "sortOrder": "Порядок",
+        "sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.",
+        "inbounds": "Инбаунды",
+        "inboundsCount": "{count} Инбаунды",
+        "enabled": "Включён",
+        "empty": "Балансировщиков пока нет",
+        "deleteConfirm": "Удалить этот балансировщик?",
+        "errRemarkRequired": "Укажите примечание",
+        "errInboundsRequired": "Выберите хотя бы один инбаунд",
+        "errSortOrder": "Порядок — целое число ≥ 1",
+        "toasts": {
+          "list": "Не удалось получить список балансировщиков подписки",
+          "create": "Не удалось создать балансировщик подписки",
+          "update": "Не удалось обновить балансировщик подписки",
+          "delete": "Не удалось удалить балансировщик подписки",
+          "invalidId": "Некорректный id"
+        },
+        "tabBalancers": "Балансировщик",
+        "tabObservatory": "Обсерватория",
+        "observatory": {
+          "title": "Обсерватория балансировщика",
+          "desc": "Параметры probe-запросов для burstObservatory, добавляемого в профили leastPing/leastLoad. random/roundRobin обходятся без обсерватории. Хранится как общая настройка JSON-подписки.",
+          "destination": "URL проверки",
+          "destinationDesc": "Адрес, по которому клиент проверяет доступность каждого участника.",
+          "connectivity": "URL связности",
+          "connectivityDesc": "Необязательный адрес для однократной проверки доступности цели. Оставьте пустым, чтобы пропустить.",
+          "interval": "Интервал проверок",
+          "intervalDesc": "Время между раундами проверок, например 1m.",
+          "timeout": "Тайм-аут проверки",
+          "timeoutDesc": "Тайм-аут одной проверки, например 5s.",
+          "sampling": "Выборка",
+          "samplingDesc": "Число подряд проверок для усреднения стабильности.",
+          "httpMethod": "HTTP-метод",
+          "httpMethodDesc": "Метод запросов при проверках.",
+          "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
+        }
+      }
     },
     "xray": {
       "importRules": "Импорт правил",

+ 50 - 1
internal/web/translation/tr-TR.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "IP limiti izin listesi",
-      "ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış)."
+      "ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış).",
+      "subBalancers": {
+        "menu": "Abonelik dengeleyicileri",
+        "title": "Abonelik dengeleyici",
+        "add": "Dengeleyici ekle",
+        "desc": "Etkin her dengeleyici, seçilen inbound'ların uç noktalarından en iyisini otomatik seçen ek bir profil olarak JSON aboneliğine eklenir.",
+        "remark": "Açıklama",
+        "remarkPlaceholder": "Otomatik · en hızlı",
+        "strategy": "Strateji",
+        "strategyLeastLoad": "En düşük yük",
+        "strategyLeastPing": "En düşük ping",
+        "strategyRandom": "Rastgele",
+        "strategyRoundRobin": "Sıralı",
+        "sortOrder": "Sıra",
+        "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",
+        "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ı",
+        "toasts": {
+          "list": "Abonelik dengeleyicileri listelenemedi",
+          "create": "Abonelik dengeleyicisi oluşturulamadı",
+          "update": "Abonelik dengeleyicisi güncellenemedi",
+          "delete": "Abonelik dengeleyicisi silinemedi",
+          "invalidId": "Geçersiz id"
+        },
+        "tabBalancers": "Dengeleyiciler",
+        "tabObservatory": "Gözlemci",
+        "observatory": {
+          "title": "Dengeleyici gözlemi",
+          "desc": "Her leastPing/leastLoad dengeleyici profiline gömülen burstObservatory probe parametreleri. random/roundRobin için gözlem eklenmez. Paneller arası JSON abonelik ayarı olarak saklanır.",
+          "destination": "Probe URL'si",
+          "destinationDesc": "İstemcinin her üye çıkışı ölçmek için denediği adres.",
+          "connectivity": "Bağlantı URL'si",
+          "connectivityDesc": "Üyenin hedefe ulaşabildiğini tek kez doğrulamak için isteğe bağlı adres. Atlamak için boş bırakın.",
+          "interval": "Probe aralığı",
+          "intervalDesc": "Probe turları arasındaki süre, örn. 1m.",
+          "timeout": "Probe zaman aşımı",
+          "timeoutDesc": "Tek bir probe için zaman aşımı, örn. 5s.",
+          "sampling": "Örnekleme",
+          "samplingDesc": "Kararlılık ortalaması için ardışık probe sayısı.",
+          "httpMethod": "HTTP yöntemi",
+          "httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.",
+          "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
+        }
+      }
     },
     "xray": {
       "save": "Kaydet",

+ 50 - 1
internal/web/translation/uk-UA.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Григоріанський (звичайний)",
       "calendarJalalian": "Джалалі (شمسی)",
       "ipLimitAllowlist": "Довірені адреси для ліміту",
-      "ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа."
+      "ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа.",
+      "subBalancers": {
+        "menu": "Балансувальники підписки",
+        "title": "Балансувальник підписки",
+        "add": "Додати балансувальник",
+        "desc": "Кожний увімкнений балансувальник додається до JSON-підписки як окремий профіль, що автоматично обирає найкращу з кінцевих точок вибраних інбаундів.",
+        "remark": "Примітка",
+        "remarkPlaceholder": "Авто · найшвидший",
+        "strategy": "Стратегія",
+        "strategyLeastLoad": "Найменше навантаження",
+        "strategyLeastPing": "Найменший ping",
+        "strategyRandom": "Випадково",
+        "strategyRoundRobin": "По черзі",
+        "sortOrder": "Порядок",
+        "sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.",
+        "inbounds": "Інбаунди",
+        "inboundsCount": "{count} Інбаунди",
+        "enabled": "Увімкнено",
+        "empty": "Балансувальників ще немає",
+        "deleteConfirm": "Видалити цей балансувальник?",
+        "errRemarkRequired": "Вкажіть примітку",
+        "errInboundsRequired": "Виберіть хоча б один інбаунд",
+        "errSortOrder": "Порядок — ціле число ≥ 1",
+        "toasts": {
+          "list": "Не вдалося отримати список балансувальників підписки",
+          "create": "Не вдалося створити балансувальник підписки",
+          "update": "Не вдалося оновити балансувальник підписки",
+          "delete": "Не вдалося видалити балансувальник підписки",
+          "invalidId": "Некоректний id"
+        },
+        "tabBalancers": "Балансери",
+        "tabObservatory": "Обсерваторія",
+        "observatory": {
+          "title": "Обсерваторія балансувальника",
+          "desc": "Параметри probe-запитів для burstObservatory, що додається у профілі leastPing/leastLoad. random/roundRobin обходяться без обсерваторії. Зберігається як загальна налаштування JSON-підписки.",
+          "destination": "URL перевірки",
+          "destinationDesc": "Адреса, за якою клієнт перевіряє доступність кожного учасника.",
+          "connectivity": "URL зв’язності",
+          "connectivityDesc": "Необов’язкова адреса для одноразової перевірки доступності цілі. Залиште порожнім, щоб пропустити.",
+          "interval": "Інтервал перевірок",
+          "intervalDesc": "Час між раундами перевірок, наприклад 1m.",
+          "timeout": "Тайм-аут перевірки",
+          "timeoutDesc": "Тайм-аут однієї перевірки, наприклад 5s.",
+          "sampling": "Вибірка",
+          "samplingDesc": "Кількість підряд перевірок для усереднення стабільності.",
+          "httpMethod": "HTTP-метод",
+          "httpMethodDesc": "Метод запитів під час перевірок.",
+          "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
+        }
+      }
     },
     "xray": {
       "save": "Зберегти",

+ 50 - 1
internal/web/translation/vi-VN.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
-      "ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy."
+      "ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy.",
+      "subBalancers": {
+        "menu": "Bộ cân bằng đăng ký",
+        "title": "Bộ cân bằng đăng ký",
+        "add": "Thêm bộ cân bằng",
+        "desc": "Mỗi bộ cân bằng đang bật được thêm vào đăng ký JSON như một hồ sơ riêng, tự động chọn điểm cuối tốt nhất trong các inbound đã chọn.",
+        "remark": "Ghi chú",
+        "remarkPlaceholder": "Tự động · nhanh nhất",
+        "strategy": "Chiến lược",
+        "strategyLeastLoad": "Tải thấp nhất",
+        "strategyLeastPing": "Ping thấp nhất",
+        "strategyRandom": "Ngẫu nhiên",
+        "strategyRoundRobin": "Luân phiên",
+        "sortOrder": "Thứ tự",
+        "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",
+        "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",
+        "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ý",
+          "update": "Không thể cập nhật bộ cân bằng đăng ký",
+          "delete": "Không thể xóa bộ cân bằng đăng ký",
+          "invalidId": "Id không hợp lệ"
+        },
+        "tabBalancers": "Cân bằng",
+        "tabObservatory": "Observatory",
+        "observatory": {
+          "title": "Đài quan sát bộ cân bằng",
+          "desc": "Tham số probe cho burstObservatory nhúng vào mỗi hồ sơ leastPing/leastLoad. random/roundRobin không có đài quan sát. Lưu thành cài đặt chung của đăng ký JSON.",
+          "destination": "URL probe",
+          "destinationDesc": "Địa chỉ client thăm dò để đo mỗi outbound thành viên.",
+          "connectivity": "URL kết nối",
+          "connectivityDesc": "Địa chỉ tuỳ chọn để kiểm tra một lần thành viên có tới đích được không. Để trống để bỏ qua.",
+          "interval": "Khoảng probe",
+          "intervalDesc": "Thời gian giữa các vòng probe, vd. 1m.",
+          "timeout": "Hết giờ probe",
+          "timeoutDesc": "Hết giờ cho mỗi probe, vd. 5s.",
+          "sampling": "Lấy mẫu",
+          "samplingDesc": "Số lần probe liên tiếp để trung bình độ ổn định.",
+          "httpMethod": "Phương thức HTTP",
+          "httpMethodDesc": "Phương thức dùng cho yêu cầu probe.",
+          "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
+        }
+      }
     },
     "xray": {
       "importRules": "Nhập quy tắc",

+ 50 - 1
internal/web/translation/zh-CN.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "IP 限制白名单",
-      "ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。"
+      "ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。",
+      "subBalancers": {
+        "menu": "订阅均衡器",
+        "title": "订阅均衡器",
+        "add": "添加均衡器",
+        "desc": "每个启用的均衡器会作为额外配置加入 JSON 订阅,自动在所选入站的端点中选择最优节点(客户端配置中的 routing.balancers + burstObservatory)。",
+        "remark": "备注",
+        "remarkPlaceholder": "自动 · 最快",
+        "strategy": "策略",
+        "strategyLeastLoad": "最小负载",
+        "strategyLeastPing": "最低延迟",
+        "strategyRandom": "随机",
+        "strategyRoundRobin": "轮询",
+        "sortOrder": "顺序",
+        "sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。",
+        "inbounds": "入站",
+        "inboundsCount": "{count} 入站",
+        "enabled": "启用",
+        "empty": "暂无均衡器",
+        "deleteConfirm": "确定删除此均衡器?",
+        "errRemarkRequired": "请填写备注",
+        "errInboundsRequired": "请至少选择一个入站",
+        "errSortOrder": "顺序必须为不小于 1 的整数",
+        "toasts": {
+          "list": "列出订阅均衡器失败",
+          "create": "创建订阅均衡器失败",
+          "update": "更新订阅均衡器失败",
+          "delete": "删除订阅均衡器失败",
+          "invalidId": "无效的 id"
+        },
+        "tabBalancers": "负载均衡",
+        "tabObservatory": "观测器",
+        "observatory": {
+          "title": "均衡器探活",
+          "desc": "写入每个 leastPing/leastLoad 均衡器配置的 burstObservatory 探活参数。random/roundRobin 不生成探活。作为面板级 JSON 订阅设置保存。",
+          "destination": "探活 URL",
+          "destinationDesc": "客户端探测每个成员出站的地址。",
+          "connectivity": "连通性 URL",
+          "connectivityDesc": "可选地址,检查成员能否到达探活目标。留空则跳过。",
+          "interval": "探活间隔",
+          "intervalDesc": "探活轮次之间的时间,例如 1m。",
+          "timeout": "探活超时",
+          "timeoutDesc": "单次探活超时,例如 5s。",
+          "sampling": "采样",
+          "samplingDesc": "用于稳定度平均的连续探活次数。",
+          "httpMethod": "HTTP 方法",
+          "httpMethodDesc": "探活请求使用的 HTTP 方法。",
+          "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
+        }
+      }
     },
     "xray": {
       "importRules": "导入规则",

+ 50 - 1
internal/web/translation/zh-TW.json

@@ -1386,7 +1386,56 @@
       "calendarGregorian": "Gregorian (Standard)",
       "calendarJalalian": "Jalalian (شمسی)",
       "ipLimitAllowlist": "IP 限制白名單",
-      "ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。"
+      "ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。",
+      "subBalancers": {
+        "menu": "訂閱平衡器",
+        "title": "訂閱平衡器",
+        "add": "新增平衡器",
+        "desc": "每個啟用的平衡器會作為額外設定加入 JSON 訂閱,自動從所選入站的端點中挑選最佳節點(用戶端設定中的 routing.balancers + burstObservatory)。",
+        "remark": "備註",
+        "remarkPlaceholder": "自動 · 最快",
+        "strategy": "策略",
+        "strategyLeastLoad": "最小負載",
+        "strategyLeastPing": "最低延遲",
+        "strategyRandom": "隨機",
+        "strategyRoundRobin": "輪詢",
+        "sortOrder": "順序",
+        "sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。",
+        "inbounds": "入站",
+        "inboundsCount": "{count} 入站",
+        "enabled": "啟用",
+        "empty": "尚無平衡器",
+        "deleteConfirm": "確定刪除此平衡器?",
+        "errRemarkRequired": "請填寫備註",
+        "errInboundsRequired": "請至少選擇一個入站",
+        "errSortOrder": "順序必須為不小於 1 的整數",
+        "toasts": {
+          "list": "列出訂閱平衡器失敗",
+          "create": "建立訂閱平衡器失敗",
+          "update": "更新訂閱平衡器失敗",
+          "delete": "刪除訂閱平衡器失敗",
+          "invalidId": "無效的 id"
+        },
+        "tabBalancers": "負載均衡",
+        "tabObservatory": "觀測器",
+        "observatory": {
+          "title": "平衡器探活",
+          "desc": "寫入每個 leastPing/leastLoad 平衡器設定檔的 burstObservatory 探活參數。random/roundRobin 不產生探活。以面板級 JSON 訂閱設定儲存。",
+          "destination": "探活 URL",
+          "destinationDesc": "用戶端探測每個成員出站的位址。",
+          "connectivity": "連通性 URL",
+          "connectivityDesc": "選用位址,檢查成員能否到達探活目標。留空則跳過。",
+          "interval": "探活間隔",
+          "intervalDesc": "探活輪次之間的時間,例如 1m。",
+          "timeout": "探活逾時",
+          "timeoutDesc": "單次探活逾時,例如 5s。",
+          "sampling": "取樣",
+          "samplingDesc": "用於穩定度平均的連續探活次數。",
+          "httpMethod": "HTTP 方法",
+          "httpMethodDesc": "探活請求使用的 HTTP 方法。",
+          "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
+        }
+      }
     },
     "xray": {
       "save": "儲存",

+ 1 - 4
internal/xray/geodata/query.go

@@ -103,10 +103,7 @@ func sliceBounds(total, offset, limit int) (int, int) {
 	if limit <= 0 || limit > MaxPageSize {
 		limit = MaxPageSize
 	}
-	to := offset + limit
-	if to > total {
-		to = total
-	}
+	to := min(offset+limit, total)
 	return offset, to
 }
 

+ 1 - 0
tools/openapigen/main.go

@@ -39,6 +39,7 @@ func run(root, outDir string) error {
 				"ClientInbound",
 				"InboundFallback",
 				"Host",
+				"SubBalancer",
 			),
 			AliasAllow: setOf("Protocol"),
 			Overrides: map[string][]walkOverride{

Some files were not shown because too many files changed in this diff