r.id}
+ pagination={false}
+ loading={loading && !fetched}
+ scroll={{ x: true }}
+ locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
+ columns={columns}
+ />
+
+ );
+
+ const observatoryTab = (
+ <>
+
+
+
+
+ {observatoryEnabled && (
+
+
+ setObservatoryField('destination', e.target.value)}
+ />
+
+
+ setObservatoryField('connectivity', e.target.value)}
+ />
+
+
+ setObservatoryField('interval', e.target.value)}
+ />
+
+
+ setObservatoryField('timeout', e.target.value)}
+ />
+
+
+ setObservatoryField('sampling', v))}
+ />
+
+
+
+
+ )}
+ >
+ );
+
+ return (
+ <>
+ ,
+ t('pages.settings.subBalancers.tabBalancers'),
+ isMobile,
+ ),
+ children: balancersTab,
+ },
+ {
+ key: 'observatory',
+ label: catTabLabel(
+ ,
+ t('pages.settings.subBalancers.tabObservatory'),
+ isMobile,
+ ),
+ children: observatoryTab,
+ },
+ ]}
+ />
+ setModalOpen(false)}
+ onConfirm={onConfirm}
+ />
+ >
+ );
+}
diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts
index cf421839c..0f22be4d4 100644
--- a/frontend/src/pages/xray/balancers/balancer-helpers.ts
+++ b/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',
diff --git a/frontend/src/schemas/observatory.ts b/frontend/src/schemas/observatory.ts
index fba35206d..7c59820d8 100644
--- a/frontend/src/schemas/observatory.ts
+++ b/frontend/src/schemas/observatory.ts
@@ -16,7 +16,7 @@ export type ObservatoryHttpMethod = z.infer;
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),
diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts
index 690ca46cc..c2453693c 100644
--- a/frontend/src/schemas/setting.ts
+++ b/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(),
diff --git a/frontend/src/schemas/subBalancer.ts b/frontend/src/schemas/subBalancer.ts
new file mode 100644
index 000000000..c9d716e48
--- /dev/null
+++ b/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;
+
+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;
+
+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;
diff --git a/frontend/src/test/sub-balancer-form-modal.test.tsx b/frontend/src/test/sub-balancer-form-modal.test.tsx
new file mode 100644
index 000000000..da0a8ce8e
--- /dev/null
+++ b/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(
+ {}} 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');
+ });
+});
diff --git a/internal/database/db.go b/internal/database/db.go
index ebeb678ac..5dec87142 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -84,6 +84,7 @@ func allModels() []any {
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
+ &model.SubBalancer{},
}
}
diff --git a/internal/database/migrate_data.go b/internal/database/migrate_data.go
index fa27fea8b..3a423c932 100644
--- a/internal/database/migrate_data.go
+++ b/internal/database/migrate_data.go
@@ -57,6 +57,7 @@ func migrationModels() []any {
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
+ &model.SubBalancer{},
}
}
diff --git a/internal/database/model/model.go b/internal/database/model/model.go
index b99912da4..814a75700 100644
--- a/internal/database/model/model.go
+++ b/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) {
diff --git a/internal/sub/controller.go b/internal/sub/controller.go
index f1de709bf..70f3dd335 100644
--- a/internal/sub/controller.go
+++ b/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{},
diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go
index b50bccddb..d34fbd156 100644
--- a/internal/sub/json_service.go
+++ b/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))
diff --git a/internal/sub/sub.go b/internal/sub/sub.go
index 21d7d15d3..3d5a784b0 100644
--- a/internal/sub/sub.go
+++ b/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),
diff --git a/internal/sub/sub_balancer_protocol_tag_test.go b/internal/sub/sub_balancer_protocol_tag_test.go
new file mode 100644
index 000000000..f1a091db6
--- /dev/null
+++ b/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)
+ }
+}
diff --git a/internal/sub/sub_balancer_test.go b/internal/sub/sub_balancer_test.go
new file mode 100644
index 000000000..f295e1114
--- /dev/null
+++ b/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
+}
diff --git a/internal/sub/sub_json_observatory_test.go b/internal/sub/sub_json_observatory_test.go
new file mode 100644
index 000000000..d4aaa5aae
--- /dev/null
+++ b/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)
+ }
+ })
+ }
+}
diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go
index edab090cc..483ed1d5f 100644
--- a/internal/web/controller/api.go
+++ b/internal/web/controller/api.go
@@ -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)
}
diff --git a/internal/web/controller/sub_balancer.go b/internal/web/controller/sub_balancer.go
new file mode 100644
index 000000000..f1d5725a7
--- /dev/null
+++ b/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)
+}
diff --git a/internal/web/controller/sub_balancer_test.go b/internal/web/controller/sub_balancer_test.go
new file mode 100644
index 000000000..12f908caf
--- /dev/null
+++ b/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"])
+ }
+}
diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go
index 159a0d608..bee5cea1f 100644
--- a/internal/web/entity/entity.go
+++ b/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"`
diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go
index ff5edbb3e..b4d934c8d 100644
--- a/internal/web/service/inbound.go
+++ b/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)
}
diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go
index cf8dd2fba..19caedbba 100644
--- a/internal/web/service/setting.go
+++ b/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")
}
diff --git a/internal/web/service/sub_balancer.go b/internal/web/service/sub_balancer.go
new file mode 100644
index 000000000..4ddcd83e1
--- /dev/null
+++ b/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
+}
diff --git a/internal/web/service/sub_balancer_inbound_cleanup_test.go b/internal/web/service/sub_balancer_inbound_cleanup_test.go
new file mode 100644
index 000000000..fe37928d3
--- /dev/null
+++ b/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)
+ }
+}
diff --git a/internal/web/service/sub_balancer_test.go b/internal/web/service/sub_balancer_test.go
new file mode 100644
index 000000000..15be667e0
--- /dev/null
+++ b/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)
+ }
+ })
+ }
+}
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 68ccd0943..bb39d012f 100644
--- a/internal/web/translation/ar-EG.json
+++ b/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": "احفظ",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index b5e8436b1..9a8a0f27c 100644
--- a/internal/web/translation/en-US.json
+++ b/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",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index 7f5195552..376d5b106 100644
--- a/internal/web/translation/es-ES.json
+++ b/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",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 8937e49c8..0afdae1db 100644
--- a/internal/web/translation/fa-IR.json
+++ b/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": "ذخیره",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 8b034e14b..ad8aaf5a2 100644
--- a/internal/web/translation/id-ID.json
+++ b/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",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 92bb4a187..73ee64c48 100644
--- a/internal/web/translation/ja-JP.json
+++ b/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": "ルールをインポート",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 273e0adb9..0806d3c6a 100644
--- a/internal/web/translation/pt-BR.json
+++ b/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",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index 213361919..85fd6aee7 100644
--- a/internal/web/translation/ru-RU.json
+++ b/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": "Импорт правил",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index 66267002d..0d51d8f73 100644
--- a/internal/web/translation/tr-TR.json
+++ b/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",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index d41131e36..f83f37de4 100644
--- a/internal/web/translation/uk-UA.json
+++ b/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": "Зберегти",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 97b36ff00..327af0cd1 100644
--- a/internal/web/translation/vi-VN.json
+++ b/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",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index f9ed8c7eb..e7f920c2a 100644
--- a/internal/web/translation/zh-CN.json
+++ b/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": "导入规则",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 4386e5ae8..16925e656 100644
--- a/internal/web/translation/zh-TW.json
+++ b/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": "儲存",
diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go
index 87533a71a..6c9fd3bc9 100644
--- a/tools/openapigen/main.go
+++ b/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{