mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 21:17:14 +00:00
feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up. * feat(sub): add SubBalancer service List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart. * feat(sub): add SubBalancer API controller and routes GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it. * feat(sub): emit client-side balancers in JSON subscription For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present. * test(sub): cover SubBalancer service and JSON output Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup. * feat(sub): add sub-balancers i18n keys pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales. * feat(sub): add SubBalancer schema and API queries Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key. * feat(sub): add subscription balancers settings tab SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled. * test(sub): add SubBalancer form modal test Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled). * feat(sub): register sub-balancers in API docs and OpenAPI Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it. * docs: sync openapi.json with frontend docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API. * docs: add subscription balancers API reference Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations. * feat(sub): accept roundRobin balancer strategy Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist. * feat(sub): add roundRobin strategy label pages.settings.subBalancers.strategyRoundRobin added to all 13 locales. * feat(sub): expose roundRobin in balancer form Zod strategy enum, form modal label key, and table strategy colour for roundRobin. * docs(sub): list roundRobin in strategy description The create/update strategy param description now mentions roundRobin alongside the other three. * feat(sub): add subJsonObservatory setting Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask. * feat(sub): wire observatory config through sub controller WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction. * feat(sub): emit observatory conditionally with configurable probes burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override. * feat(sub): add subJsonObservatory to AllSetting model Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string. * feat(sub): add balancer observatory config card New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults. * fix(sub): hide save/restart header on sub-balancers tab Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly. * feat(sub): add observatory config i18n keys pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales. * feat(sub): regenerate openapi for subJsonObservatory openapigen picks up the new AllSetting field; openapi.json synced into docs. * feat(sub): add observatory tab to sub-balancers Mirrors the Xray Balancers page: two tabs (Balancers + Observatory). Wires allSetting/updateSetting into the tab and adds tabBalancers / tabObservatory labels to all locales. The page Save header is shown again on this tab so the observatory config can be saved. * refactor(sub): drop observatory tab from sub-formats Now that the observatory config lives under sub-balancers, remove the duplicate tab plus its state and defaults from sub-formats. * fix(sub): add missing inboundsCount i18n key The sub-balancers table rendered the raw key path in the Inbounds column because pages.settings.subBalancers.inboundsCount was not defined. Added it to all 13 locales. * test(sub): pin disabled-inbound exclusion from balancer The balancer builds its members from the subscriber's already-filtered entry set, so an inbound disabled for that user can never surface as a member. Adds tests for both shapes (one of several disabled, and the only selected one disabled). * fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback Three coupled defects on the balancer observatory surface, flagged in PR review: - The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this. - Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value. - routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking. Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it. * fix(sub-balancer): localize controller toasts and reject malformed ids Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success. * fix(sub-balancer): enforce remark length cap server-side The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap. * fix(sub-balancer): exclude mtproto from balancer member picker SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment. * docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope - Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only). - Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX. - Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror. * style(model): trim SubBalancer comment to 2-line cap CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3. * fix(sub-balancer): parse enabled explicitly and preserve it on partial update parseSubBalancerForm treated any non-"false" value as true (so "bogus" silently enabled) and always overwrote Enabled on update, so a PATCH that omitted the toggle reset a disabled balancer back to enabled. Parse the field with strconv.ParseBool and return *bool: absent means "no change" on update and "true" on create; a malformed value is rejected as 400. Update keeps the stored Enabled when the pointer is nil. * fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds DelInbound cascaded hosts but left the deleted inbound id in every sub_balancers.InboundIds, so the balancer kept emitting a member no subscriber could resolve — a dangling outbound tag with no proxy behind it. Strip the id inside the existing delete transaction (same shape as the hosts cascade, #5648); with the last member gone the balancer stops emitting. * fix(sub-balancer): return not-found when deleting a missing balancer Delete returned the gorm result error only, which is nil when no row matched, so the controller reported success:true for an id that never existed — a stale UI row looked like a clean delete. Check RowsAffected and return a not-found error on 0 so the toast reflects reality. * style(sub): shorten leastPing/leastLoad observatory comments The observatory-emission guard comment and its test comment ran a few lines long; trim them to a couple of lines each without dropping the invariant that leastPing/leastLoad require a burst observatory. * fix(sub): validate observatory setting instead of silently dropping it SetObservatoryConfig applied whatever survived json.Unmarshal with no checks, so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even unparseable JSON was either silently applied or silently ignored. Validate each field: parse durations with time.ParseDuration, require http(s) URLs for destination/connectivity, and log a warning naming the field and the bad value on every fallback — including the unmarshal error, which was a quiet return. Bad values now keep the built-in defaults instead of leaking into the emitted burstObservatory. * fix(sub): deduplicate burst-observatory defaults across Go and frontend The burst-observatory ping defaults lived in three places that had drifted: Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity pre-check URL). Align them to one set: https probe destination, sampling 2, and empty connectivity (skip the direct pre-check). The settings tab now parses the stored JSON through PingConfigSchema and seeds its default from DEFAULT_BURST_OBSERVATORY instead of carrying its own literal. * refactor(sub): extract proxy outbounds once before the balancer loop buildBalancerConfig unmarshalled every inbound document and re-extracted its first outbound on each balancer, so with B balancers and N inbound docs the same document was parsed B*N times. Pull each doc's proxy outbound in a single pre-pass over the entries and cache it per entry; buildBalancerConfig now clones the cached map before retagging, so one parse serves every balancer. Output is byte-for-byte unchanged. * fix(sub): form balancer member tags from the inbound protocol, not tcp→vless balancerTransport derived the bal-N tag suffix from the outbound's transport network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was mislabelled "vless" in every client config — the tag lied about the proxy type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless, bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and dedup suffix are unchanged. Update the existing tag assertions and add a vmess case that fails under the old mapping. * fix(sub-balancer): default strategy to random in the create form The create-balancer form seeded strategy to 'leastLoad', but the service validate() defaults an empty strategy to 'random' and the API docs say the default is 'random' — so a freshly opened form showed leastLoad while saving without touching the field silently stored random. Align the form default to 'random' so what the admin sees is what gets persisted. * feat(api-docs): document the SubBalancer response schema The five sub-balancer endpoints carried no responseSchema, so the API docs page rendered them without a typed example. Add example: tags to every SubBalancer field, allow the struct through openapigen, and point the list (responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/. * style(sub-balancer): drop whitespace-only separator lines, add final newline subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as separators between statements and had no trailing newline. Replace them with clean empty blank lines and end each file with a newline. * fix(i18n): translate sub-balancer toasts and observatory note The sub-balancer toast messages (list/create/update/delete/invalidId) and the observatory note were left in English across 11 non-English locales (ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key in the subBalancers block was already translated. Translate them to match the meaning and terminology of the surrounding keys in each file; the JSON structure and keys are unchanged. * fix(sub-balancer): hide disabled inbounds from the member picker The picker offered every protocol-eligible inbound regardless of its enable flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled member is therefore dropped from every subscriber's entries, and when it was the balancer's only member the balancer document silently stops being emitted — with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll MembersDisabled already documents that backend behavior. Filter the way the sibling client picker has since #5645: hide disabled inbounds, but keep one that is already selected so editing an existing balancer cannot silently drop a member. Drop the `?? []` on the useWatch result so the new useMemo dependency stays referentially stable. * style(sub): trim the balancerMemberSuffix comment to the 2-line cap Comment blocks in committed Go are capped at 2 lines; the name already carries what the function picks, so keep only the why. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -2189,6 +2189,84 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sub-balancers',
|
||||
title: 'Subscription Balancers',
|
||||
description:
|
||||
'Client-side balancers for the JSON subscription: each enabled balancer is emitted as one extra config document whose members are the proxy outbounds of the selected inbounds (routing.balancers + burstObservatory). Managed in Settings → Sub Balancers.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/sub-balancers',
|
||||
summary: 'List all subscription balancers in sort order (sort_order asc, id asc).',
|
||||
responseSchema: 'SubBalancer',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/sub-balancers',
|
||||
summary:
|
||||
'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
|
||||
params: [
|
||||
{
|
||||
name: 'remark',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Display label, used as the config remarks (required).',
|
||||
},
|
||||
{
|
||||
name: 'strategy',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
|
||||
},
|
||||
{
|
||||
name: 'inboundIds',
|
||||
in: 'body (form)',
|
||||
type: 'integer[]',
|
||||
desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
|
||||
},
|
||||
{
|
||||
name: 'sortOrder',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Whether the balancer is emitted. Default true.',
|
||||
},
|
||||
],
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/sub-balancers/:id',
|
||||
summary:
|
||||
'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).',
|
||||
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/panel/api/sub-balancers/:id',
|
||||
summary: 'Delete a balancer by id.',
|
||||
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/sub-balancers/:id/del',
|
||||
summary:
|
||||
'Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).',
|
||||
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'subscription',
|
||||
title: 'Subscription Server',
|
||||
|
||||
@@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab';
|
||||
import EmailTab from './EmailTab';
|
||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
|
||||
import './SettingsPage.css';
|
||||
|
||||
interface ApiMsg {
|
||||
@@ -42,6 +43,7 @@ const tabSlugs = [
|
||||
'email',
|
||||
'subscription',
|
||||
'subscription-formats',
|
||||
'subscription-balancers',
|
||||
];
|
||||
|
||||
function isIp(h: string): boolean {
|
||||
@@ -219,6 +221,8 @@ export default function SettingsPage() {
|
||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-formats':
|
||||
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-balancers':
|
||||
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
default:
|
||||
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import {
|
||||
SubBalancerFormSchema,
|
||||
SubBalancerStrategySchema,
|
||||
type SubBalancer,
|
||||
type SubBalancerFormValues,
|
||||
type SubBalancerStrategy,
|
||||
} from '@/schemas/subBalancer';
|
||||
|
||||
// The JSON subscription only builds proxy outbounds for these protocols;
|
||||
// mtproto has no proxy-outbound case, so it is excluded from balancer members.
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks',
|
||||
'vless',
|
||||
'vmess',
|
||||
'trojan',
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
]);
|
||||
|
||||
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
|
||||
leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
|
||||
leastPing: 'pages.settings.subBalancers.strategyLeastPing',
|
||||
random: 'pages.settings.subBalancers.strategyRandom',
|
||||
roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
|
||||
};
|
||||
|
||||
function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
|
||||
return {
|
||||
remark: balancer?.remark ?? '',
|
||||
strategy: balancer?.strategy ?? 'random',
|
||||
inboundIds: [...(balancer?.inboundIds ?? [])],
|
||||
sortOrder: balancer?.sortOrder ?? 1,
|
||||
enabled: balancer?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
interface SubBalancerFormModalProps {
|
||||
open: boolean;
|
||||
balancer: SubBalancer | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (values: SubBalancerFormValues) => void;
|
||||
}
|
||||
|
||||
export default function SubBalancerFormModal({
|
||||
open,
|
||||
balancer,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: SubBalancerFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
|
||||
const isEdit = balancer != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) methods.reset(initialState(balancer));
|
||||
}, [open, balancer, methods]);
|
||||
|
||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||
|
||||
const { data: inboundOptionsRaw } = useInboundOptions();
|
||||
const inboundOptions = useMemo(
|
||||
() =>
|
||||
(inboundOptionsRaw ?? [])
|
||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
|
||||
.map((ib) => ({
|
||||
label: formatInboundLabel(ib.tag, ib.remark),
|
||||
value: ib.id,
|
||||
title: formatInboundLabel(ib.tag, ib.remark),
|
||||
})),
|
||||
[inboundOptionsRaw, inboundIds],
|
||||
);
|
||||
|
||||
function onFinish(values: SubBalancerFormValues) {
|
||||
const parsed = SubBalancerFormSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
messageApi.error(
|
||||
t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onConfirm(parsed.data);
|
||||
}
|
||||
|
||||
const strategies = SubBalancerStrategySchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(STRATEGY_LABEL_KEYS[value]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={
|
||||
isEdit
|
||||
? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
|
||||
: `+ ${t('pages.settings.subBalancers.add')}`
|
||||
}
|
||||
okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
|
||||
cancelText={t('close')}
|
||||
mask={{ closable: false }}
|
||||
width="640px"
|
||||
onOk={methods.handleSubmit(onFinish)}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{messageContextHolder}
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<FormField
|
||||
label={t('pages.settings.subBalancers.remark')}
|
||||
name="remark"
|
||||
required
|
||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
|
||||
>
|
||||
<Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
|
||||
<Select options={strategies} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.settings.subBalancers.sortOrder')}
|
||||
name="sortOrder"
|
||||
required
|
||||
tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
|
||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.settings.subBalancers.inbounds')}
|
||||
name="inboundIds"
|
||||
required
|
||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={inboundOptions}
|
||||
maxTagCount="responsive"
|
||||
listHeight={220}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
</FormField>
|
||||
<SelectAllClearButtons
|
||||
options={inboundOptions}
|
||||
value={inboundIds || []}
|
||||
onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label={t('pages.settings.subBalancers.enabled')}
|
||||
name="enabled"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
RadarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
|
||||
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
|
||||
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
|
||||
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import SubBalancerFormModal from './SubBalancerFormModal';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import './SubscriptionFormatsTab.css';
|
||||
|
||||
const STRATEGY_COLORS: Record<string, string> = {
|
||||
leastLoad: 'geekblue',
|
||||
leastPing: 'green',
|
||||
random: 'orange',
|
||||
roundRobin: 'purple',
|
||||
};
|
||||
|
||||
// Single source for the burst-observatory ping defaults: the Zod schema and
|
||||
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
|
||||
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
|
||||
|
||||
function parsePingConfig(raw: string): PingConfigObject {
|
||||
try {
|
||||
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
|
||||
} catch {
|
||||
return DEFAULT_PING_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
interface SubscriptionBalancersTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
export default function SubscriptionBalancersTab({
|
||||
allSetting,
|
||||
updateSetting,
|
||||
}: SubscriptionBalancersTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
|
||||
const { create, update, remove } = useSubBalancerMutations();
|
||||
const { data: inboundOptionsRaw } = useInboundOptions();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SubBalancer | null>(null);
|
||||
|
||||
const inboundLabels = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const ib of inboundOptionsRaw ?? []) {
|
||||
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
|
||||
}
|
||||
return map;
|
||||
}, [inboundOptionsRaw]);
|
||||
|
||||
async function onConfirm(values: SubBalancerFormValues) {
|
||||
const msg = editing ? await update(editing.id, values) : await create(values);
|
||||
if (msg?.success) setModalOpen(false);
|
||||
}
|
||||
|
||||
async function toggleEnabled(balancer: SubBalancer) {
|
||||
await update(balancer.id, {
|
||||
remark: balancer.remark,
|
||||
strategy: balancer.strategy,
|
||||
inboundIds: balancer.inboundIds,
|
||||
sortOrder: balancer.sortOrder,
|
||||
enabled: !balancer.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
const observatoryEnabled = allSetting.subJsonObservatory !== '';
|
||||
const observatoryObj = useMemo(
|
||||
() => parsePingConfig(allSetting.subJsonObservatory),
|
||||
[allSetting.subJsonObservatory],
|
||||
);
|
||||
|
||||
function setObservatoryEnabled(v: boolean) {
|
||||
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
|
||||
}
|
||||
|
||||
function setObservatoryField<K extends keyof PingConfigObject>(
|
||||
key: K,
|
||||
value: PingConfigObject[K],
|
||||
) {
|
||||
const next = { ...observatoryObj, [key]: value };
|
||||
updateSetting({ subJsonObservatory: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('pages.settings.subBalancers.sortOrder'),
|
||||
dataIndex: 'sortOrder',
|
||||
key: 'sortOrder',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: t('pages.settings.subBalancers.remark'),
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
},
|
||||
{
|
||||
title: t('pages.settings.subBalancers.strategy'),
|
||||
dataIndex: 'strategy',
|
||||
key: 'strategy',
|
||||
width: 120,
|
||||
render: (strategy: string) => (
|
||||
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.settings.subBalancers.inbounds'),
|
||||
key: 'inbounds',
|
||||
render: (_: unknown, r: SubBalancer) => {
|
||||
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
|
||||
return (
|
||||
<Tooltip title={labels.join(', ')}>
|
||||
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.settings.subBalancers.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: SubBalancer) => (
|
||||
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 96,
|
||||
render: (_: unknown, r: SubBalancer) => (
|
||||
<Space>
|
||||
<Button
|
||||
aria-label={t('edit')}
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
title={t('edit')}
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('pages.settings.subBalancers.deleteConfirm')}
|
||||
okText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
onConfirm={() => remove(r.id)}
|
||||
>
|
||||
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const balancersTab = (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.settings.subBalancers.desc')}
|
||||
/>
|
||||
{fetchError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={fetchError}
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.subBalancers.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={balancers}
|
||||
rowKey={(r) => r.id}
|
||||
pagination={false}
|
||||
loading={loading && !fetched}
|
||||
scroll={{ x: true }}
|
||||
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
|
||||
columns={columns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const observatoryTab = (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.settings.subBalancers.observatory.note')}
|
||||
/>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.title')}
|
||||
description={t('pages.settings.subBalancers.observatory.desc')}
|
||||
>
|
||||
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
|
||||
</SettingListItem>
|
||||
{observatoryEnabled && (
|
||||
<div className="format-settings">
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.destination')}
|
||||
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatoryObj.destination}
|
||||
placeholder="https://www.google.com/generate_204"
|
||||
onChange={(e) => setObservatoryField('destination', e.target.value)}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.connectivity')}
|
||||
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatoryObj.connectivity}
|
||||
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
|
||||
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.interval')}
|
||||
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatoryObj.interval}
|
||||
placeholder="1m"
|
||||
onChange={(e) => setObservatoryField('interval', e.target.value)}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.timeout')}
|
||||
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatoryObj.timeout}
|
||||
placeholder="5s"
|
||||
onChange={(e) => setObservatoryField('timeout', e.target.value)}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.sampling')}
|
||||
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
|
||||
>
|
||||
<InputNumber
|
||||
value={observatoryObj.sampling}
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
onChange={onNumber((v) => setObservatoryField('sampling', v))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subBalancers.observatory.httpMethod')}
|
||||
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
|
||||
>
|
||||
<Select
|
||||
value={observatoryObj.httpMethod}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setObservatoryField('httpMethod', v)}
|
||||
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
defaultActiveKey="balancers"
|
||||
items={[
|
||||
{
|
||||
key: 'balancers',
|
||||
label: catTabLabel(
|
||||
<DeploymentUnitOutlined />,
|
||||
t('pages.settings.subBalancers.tabBalancers'),
|
||||
isMobile,
|
||||
),
|
||||
children: balancersTab,
|
||||
},
|
||||
{
|
||||
key: 'observatory',
|
||||
label: catTabLabel(
|
||||
<RadarChartOutlined />,
|
||||
t('pages.settings.subBalancers.tabObservatory'),
|
||||
isMobile,
|
||||
),
|
||||
children: observatoryTab,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SubBalancerFormModal
|
||||
open={modalOpen}
|
||||
balancer={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
pingConfig: {
|
||||
destination: 'https://www.google.com/generate_204',
|
||||
interval: '1m',
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
connectivity: '',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
httpMethod: 'HEAD',
|
||||
|
||||
Reference in New Issue
Block a user