mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
7100fbcd08
* feat(model): add MemberWeights to SubBalancer Per-inbound leastLoad weights, stored with the same gorm json serializer as InboundIds so AutoMigrate adds the text column on every dialect (postgresModelSettled sees the missing column and re-runs). Absent entries mean weight 1.0; only meaningful for strategy leastLoad. * feat(sub): accept memberWeights on the sub-balancer API Parsed as one JSON form field (gin cannot bind bracket-keyed maps from urlencoded bodies). validate() rejects weights under any strategy but leastLoad — xray would silently ignore costs there, so storing them would pretend a knob exists. Non-positive weights error instead of defaulting: a zero usually means a typo'd "never pick this node". Entries for inbounds no longer selected are dropped on save. * feat(sub): emit leastLoad strategy costs from member weights costs[] is built after the tagging loop reuses the exact retagged tags (bal-N-protocol[-k]) and each member's owning inbound id. Members without a configured weight default to 1.0, but costs are omitted entirely unless at least one explicit weight survives — an all-1.0 array would bloat every subscription response for no effect. * feat(sub-balancers): leastLoad member weight inputs Weight fields render only under leastLoad and hide on strategy change without dropping their values, so an accidental toggle away and back loses nothing until save; non-leastLoad submits strip them entirely because xray would ignore costs. Weights travel as one JSON form field (gin cannot bind bracket-keyed maps) and every locale gets the three new keys in the same commit per the dead-keys rule. * docs(api): document memberWeights on sub-balancers leastLoad-only JSON form field; update notes that omitting it clears stored weights. Regenerated openapi artifacts via make gen + the docs copy/gen:api step nothing checks automatically. * fix(api-docs): use the allowed object ParamType for memberWeights * fix(sub-balancers): cap the member-weight list height Many selected inbounds pushed the modal body past the viewport. The weight rows now scroll inside a 220px viewport, mirroring the inbound picker's listHeight so both lists read the same. * fix(sub): anchor leastLoad cost matches to exact member tags Verified against xray-core: without regexp, WeightManager matches costs by substring (strings.Index), so the bare tag "bal-1-vless" also hits the deduplicated "bal-1-vless-2" and both members get the first entry's weight. Anchored ^tag$ regexps make every cost entry match only its own member. Also confirmed value<=0 makes xray derive a weight from the first digit of the matched tag — validating weights > 0 server-side was the right call. * fix(sub-balancers): keep member weights across the enabled toggle The table's toggleEnabled re-posted a full-row payload without memberWeights, and the update path treats an absent key as "erase" — flipping the switch silently dropped every configured weight. Round-trip the stored weights through the toggle payload, and prove persistence with a re-Get in the weight-validation test (the returned struct alone would stay green even if Save skipped the column). * fix(sub-balancers): address review on member weights - omitempty on MemberWeights: the panel sends null for every pre-existing and non-leastLoad balancer, which failed the hand-written zod response schema on every fetch (zod .optional() accepts undefined only; switched to .nullish() per repo convention) and drifted the generated contract. Regenerated openapi artifacts + docs copy + MDX. - Bound weights to the positive float32 range: xray decodes costs as float32, so an over-range value makes clients reject the whole subscription document and an underflow decays to the tag-digit fallback weight. Tests for both directions. - Trim six comment blocks to the 2-line cap from CLAUDE.md. --------- Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
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,
|
|
memberWeights: balancer.memberWeights ?? undefined,
|
|
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|