feat(sub): client-side balancers for the JSON subscription (#6243)

* feat(sub): add SubBalancer model and migration

Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up.

* feat(sub): add SubBalancer service

List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart.

* feat(sub): add SubBalancer API controller and routes

GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it.

* feat(sub): emit client-side balancers in JSON subscription

For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present.

* test(sub): cover SubBalancer service and JSON output

Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup.

* feat(sub): add sub-balancers i18n keys

pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales.

* feat(sub): add SubBalancer schema and API queries

Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key.

* feat(sub): add subscription balancers settings tab

SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled.

* test(sub): add SubBalancer form modal test

Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled).

* feat(sub): register sub-balancers in API docs and OpenAPI

Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it.

* docs: sync openapi.json with frontend

docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API.

* docs: add subscription balancers API reference

Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations.

* feat(sub): accept roundRobin balancer strategy

Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist.

* feat(sub): add roundRobin strategy label

pages.settings.subBalancers.strategyRoundRobin added to all 13 locales.

* feat(sub): expose roundRobin in balancer form

Zod strategy enum, form modal label key, and table strategy colour for roundRobin.

* docs(sub): list roundRobin in strategy description

The create/update strategy param description now mentions roundRobin alongside the other three.

* feat(sub): add subJsonObservatory setting

Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask.

* feat(sub): wire observatory config through sub controller

WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction.

* feat(sub): emit observatory conditionally with configurable probes

burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override.

* feat(sub): add subJsonObservatory to AllSetting model

Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string.

* feat(sub): add balancer observatory config card

New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults.

* fix(sub): hide save/restart header on sub-balancers tab

Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly.

* feat(sub): add observatory config i18n keys

pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales.

* feat(sub): regenerate openapi for subJsonObservatory

openapigen picks up the new AllSetting field; openapi.json synced into docs.

* feat(sub): add observatory tab to sub-balancers

Mirrors the Xray Balancers page: two tabs (Balancers + Observatory).
Wires allSetting/updateSetting into the tab and adds tabBalancers /
tabObservatory labels to all locales. The page Save header is shown
again on this tab so the observatory config can be saved.

* refactor(sub): drop observatory tab from sub-formats

Now that the observatory config lives under sub-balancers, remove the
duplicate tab plus its state and defaults from sub-formats.

* fix(sub): add missing inboundsCount i18n key

The sub-balancers table rendered the raw key path in the Inbounds
column because pages.settings.subBalancers.inboundsCount was not
defined. Added it to all 13 locales.

* test(sub): pin disabled-inbound exclusion from balancer

The balancer builds its members from the subscriber's already-filtered
entry set, so an inbound disabled for that user can never surface as a
member. Adds tests for both shapes (one of several disabled, and the
only selected one disabled).

* fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback

Three coupled defects on the balancer observatory surface, flagged in PR review:

- The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this.

- Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value.

- routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking.

Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it.

* fix(sub-balancer): localize controller toasts and reject malformed ids

Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success.

* fix(sub-balancer): enforce remark length cap server-side

The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap.

* fix(sub-balancer): exclude mtproto from balancer member picker

SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment.

* docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope

- Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only).

- Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX.

- Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror.

* style(model): trim SubBalancer comment to 2-line cap

CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3.

* fix(sub-balancer): parse enabled explicitly and preserve it on partial update

parseSubBalancerForm treated any non-"false" value as true (so "bogus"
silently enabled) and always overwrote Enabled on update, so a PATCH that
omitted the toggle reset a disabled balancer back to enabled. Parse the
field with strconv.ParseBool and return *bool: absent means "no change"
on update and "true" on create; a malformed value is rejected as 400.
Update keeps the stored Enabled when the pointer is nil.

* fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds

DelInbound cascaded hosts but left the deleted inbound id in every
sub_balancers.InboundIds, so the balancer kept emitting a member no
subscriber could resolve — a dangling outbound tag with no proxy behind it.
Strip the id inside the existing delete transaction (same shape as the hosts
cascade, #5648); with the last member gone the balancer stops emitting.

* fix(sub-balancer): return not-found when deleting a missing balancer

Delete returned the gorm result error only, which is nil when no row matched,
so the controller reported success:true for an id that never existed — a stale
UI row looked like a clean delete. Check RowsAffected and return a not-found
error on 0 so the toast reflects reality.

* style(sub): shorten leastPing/leastLoad observatory comments

The observatory-emission guard comment and its test comment ran a few
lines long; trim them to a couple of lines each without dropping the
invariant that leastPing/leastLoad require a burst observatory.

* fix(sub): validate observatory setting instead of silently dropping it

SetObservatoryConfig applied whatever survived json.Unmarshal with no checks,
so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even
unparseable JSON was either silently applied or silently ignored. Validate
each field: parse durations with time.ParseDuration, require http(s) URLs for
destination/connectivity, and log a warning naming the field and the bad value
on every fallback — including the unmarshal error, which was a quiet return.
Bad values now keep the built-in defaults instead of leaking into the emitted
burstObservatory.

* fix(sub): deduplicate burst-observatory defaults across Go and frontend

The burst-observatory ping defaults lived in three places that had drifted:
Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod
PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity
pre-check URL). Align them to one set: https probe destination, sampling 2,
and empty connectivity (skip the direct pre-check). The settings tab now
parses the stored JSON through PingConfigSchema and seeds its default from
DEFAULT_BURST_OBSERVATORY instead of carrying its own literal.

* refactor(sub): extract proxy outbounds once before the balancer loop

buildBalancerConfig unmarshalled every inbound document and re-extracted its
first outbound on each balancer, so with B balancers and N inbound docs the
same document was parsed B*N times. Pull each doc's proxy outbound in a single
pre-pass over the entries and cache it per entry; buildBalancerConfig now
clones the cached map before retagging, so one parse serves every balancer.
Output is byte-for-byte unchanged.

* fix(sub): form balancer member tags from the inbound protocol, not tcp→vless

balancerTransport derived the bal-N tag suffix from the outbound's transport
network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was
mislabelled "vless" in every client config — the tag lied about the proxy
type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless,
bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and
dedup suffix are unchanged. Update the existing tag assertions and add a vmess
case that fails under the old mapping.

* fix(sub-balancer): default strategy to random in the create form

The create-balancer form seeded strategy to 'leastLoad', but the service
validate() defaults an empty strategy to 'random' and the API docs say the
default is 'random' — so a freshly opened form showed leastLoad while saving
without touching the field silently stored random. Align the form default to
'random' so what the admin sees is what gets persisted.

* feat(api-docs): document the SubBalancer response schema

The five sub-balancer endpoints carried no responseSchema, so the API docs
page rendered them without a typed example. Add example: tags to every
SubBalancer field, allow the struct through openapigen, and point the list
(responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate
the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/.

* style(sub-balancer): drop whitespace-only separator lines, add final newline

subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as
separators between statements and had no trailing newline. Replace them with
clean empty blank lines and end each file with a newline.

* fix(i18n): translate sub-balancer toasts and observatory note

The sub-balancer toast messages (list/create/update/delete/invalidId) and
the observatory note were left in English across 11 non-English locales
(ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key
in the subBalancers block was already translated. Translate them to match the
meaning and terminology of the surrounding keys in each file; the JSON
structure and keys are unchanged.

* fix(sub-balancer): hide disabled inbounds from the member picker

The picker offered every protocol-eligible inbound regardless of its enable
flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled
member is therefore dropped from every subscriber's entries, and when it was
the balancer's only member the balancer document silently stops being emitted
— with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll
MembersDisabled already documents that backend behavior.

Filter the way the sibling client picker has since #5645: hide disabled
inbounds, but keep one that is already selected so editing an existing
balancer cannot silently drop a member.

Drop the `?? []` on the useWatch result so the new useMemo dependency stays
referentially stable.

* style(sub): trim the balancerMemberSuffix comment to the 2-line cap

Comment blocks in committed Go are capped at 2 lines; the name already carries
what the function picks, so keep only the why.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
DIMFLIX
2026-08-23 23:34:20 +03:00
committed by GitHub
parent 81fcacab11
commit da01b7637d
65 changed files with 6587 additions and 792 deletions
+351
View File
@@ -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": [
@@ -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),
};
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -66,6 +66,7 @@ export class AllSetting {
subJsonMux = '';
subJsonRules = '';
subJsonFinalMask = '';
subJsonObservatory = '';
subThemeDir = '';
subHideSettings = false;
+78
View File
@@ -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',
@@ -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} />;
}
@@ -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>
);
}
@@ -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}
/>
</>
);
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>;
@@ -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');
});
});