BalancersTab.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip, message } from 'antd';
  4. import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons';
  5. import type { ColumnsType } from 'antd/es/table';
  6. import BalancerFormModal from './BalancerFormModal';
  7. import type { BalancerFormValue } from './BalancerFormModal';
  8. import { syncObservatories } from './balancer-helpers';
  9. import {
  10. isBalancerLoopbackTag,
  11. loopbackTagFor,
  12. resolveLoopbackFallback,
  13. ensureBalancerLoopback,
  14. removeBalancerLoopback,
  15. removeBalancerLoopbackIfOrphaned,
  16. propagateBalancerTagRename,
  17. } from './balancer-loopback';
  18. import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
  19. import DeletionImpactList from '../DeletionImpactList';
  20. import ObservatorySettingsTab from './ObservatorySettingsTab';
  21. import { catTabLabel } from '@/pages/settings/catTabLabel';
  22. import { HttpUtil } from '@/utils';
  23. import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
  24. import type {
  25. BalancerObject,
  26. BalancerStrategySettings,
  27. BalancerStrategyType,
  28. } from '@/schemas/routing';
  29. // Live state of one balancer inside the running core, as reported by the
  30. // panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
  31. interface BalancerLiveStatus {
  32. tag: string;
  33. running: boolean;
  34. override: string;
  35. selected: string[];
  36. }
  37. interface BalancersTabProps {
  38. templateSettings: XraySettingsValue | null;
  39. setTemplateSettings: SetTemplate;
  40. clientReverseTags: string[];
  41. subscriptionOutboundTags?: string[];
  42. isMobile: boolean;
  43. }
  44. type BalancerRecord = BalancerObject;
  45. interface BalancerRow {
  46. key: number;
  47. tag: string;
  48. strategy: BalancerStrategyType;
  49. selector: string[];
  50. fallbackTag: string;
  51. displayFallbackTag: string;
  52. settings?: BalancerStrategySettings;
  53. }
  54. const STRATEGY_LABELS: Record<string, string> = {
  55. random: 'Random',
  56. roundRobin: 'Round robin',
  57. leastLoad: 'Least load',
  58. leastPing: 'Least ping',
  59. };
  60. export default function BalancersTab({
  61. templateSettings,
  62. setTemplateSettings,
  63. clientReverseTags,
  64. subscriptionOutboundTags,
  65. isMobile,
  66. }: BalancersTabProps) {
  67. const { t } = useTranslation();
  68. const [modal, modalContextHolder] = Modal.useModal();
  69. const [messageApi, messageContextHolder] = message.useMessage();
  70. const [modalOpen, setModalOpen] = useState(false);
  71. const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
  72. const [editingIndex, setEditingIndex] = useState<number | null>(null);
  73. const balancerObjects = useMemo(
  74. () => (templateSettings?.routing?.balancers || []) as BalancerObject[],
  75. [templateSettings?.routing?.balancers],
  76. );
  77. const rows: BalancerRow[] = useMemo(() => {
  78. const list = balancerObjects;
  79. return list.map((b, idx) => ({
  80. key: idx,
  81. tag: b.tag || '',
  82. strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
  83. selector: b.selector || [],
  84. fallbackTag: b.fallbackTag || '',
  85. displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
  86. settings: b.strategy?.settings,
  87. }));
  88. }, [balancerObjects, templateSettings]);
  89. const outboundTags = useMemo(() => {
  90. const tags = new Set<string>();
  91. for (const o of templateSettings?.outbounds || []) {
  92. if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag);
  93. }
  94. for (const tag of clientReverseTags || []) {
  95. if (tag) tags.add(tag);
  96. }
  97. for (const tag of subscriptionOutboundTags || []) {
  98. if (tag) tags.add(tag);
  99. }
  100. return [...tags];
  101. }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
  102. const otherTags = useMemo(() => {
  103. if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
  104. return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean);
  105. }, [rows, editingIndex]);
  106. const balancerTags = useMemo(() => {
  107. return otherTags.filter((tg) => !isBalancerLoopbackTag(tg));
  108. }, [otherTags]);
  109. const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => {
  110. return outboundTags.map((tag) => ({ value: tag, label: tag }));
  111. }, [outboundTags]);
  112. const mutate = useCallback(
  113. (mutator: (next: XraySettingsValue) => void) => {
  114. setTemplateSettings((prev) => {
  115. if (!prev) return prev;
  116. const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
  117. mutator(clone);
  118. return clone;
  119. });
  120. },
  121. [setTemplateSettings],
  122. );
  123. const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
  124. const [liveLoading, setLiveLoading] = useState(false);
  125. const liveTags = useMemo(
  126. () => rows.map((r) => r.tag).filter(Boolean).join(','),
  127. [rows],
  128. );
  129. const refreshLive = useCallback(async () => {
  130. if (!liveTags) {
  131. setLiveStatus({});
  132. return;
  133. }
  134. setLiveLoading(true);
  135. try {
  136. const msg = await HttpUtil.post('/panel/api/xray/balancerStatus', { tags: liveTags }, { silent: true });
  137. if (msg?.success && msg.obj && typeof msg.obj === 'object') {
  138. setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
  139. }
  140. } finally {
  141. setLiveLoading(false);
  142. }
  143. }, [liveTags]);
  144. useEffect(() => {
  145. refreshLive();
  146. }, [refreshLive]);
  147. async function setOverride(tag: string, target: string) {
  148. const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
  149. if (msg?.success) await refreshLive();
  150. }
  151. function openAdd() {
  152. setEditingBalancer(null);
  153. setEditingIndex(null);
  154. setModalOpen(true);
  155. }
  156. function openEdit(idx: number) {
  157. const row = rows[idx];
  158. const resolved: BalancerFormValue = {
  159. ...row,
  160. fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
  161. };
  162. setEditingBalancer(resolved);
  163. setEditingIndex(idx);
  164. setModalOpen(true);
  165. }
  166. function onConfirm(form: BalancerFormValue) {
  167. mutate((tt) => {
  168. if (!tt.routing) tt.routing = { rules: [], balancers: [] };
  169. if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
  170. const list = tt.routing.balancers as BalancerRecord[];
  171. const wire: BalancerRecord = {
  172. tag: form.tag,
  173. selector: [...form.selector],
  174. fallbackTag: '',
  175. };
  176. if (form.strategy && form.strategy !== 'random') {
  177. wire.strategy = { type: form.strategy };
  178. if (form.strategy === 'leastLoad' && form.settings) {
  179. wire.strategy.settings = form.settings;
  180. }
  181. }
  182. const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag);
  183. if (isFallbackABalancer) {
  184. wire.fallbackTag = loopbackTagFor(form.fallbackTag);
  185. } else {
  186. wire.fallbackTag = form.fallbackTag || '';
  187. }
  188. if (editingIndex == null) {
  189. list.push(wire);
  190. if (isFallbackABalancer) {
  191. ensureBalancerLoopback(tt, form.fallbackTag);
  192. }
  193. } else {
  194. const oldTag = list[editingIndex]?.tag;
  195. const oldFallback = list[editingIndex]?.fallbackTag || '';
  196. list[editingIndex] = wire;
  197. if (oldTag && oldTag !== wire.tag) {
  198. const rules = tt.routing.rules || [];
  199. for (const rule of rules) {
  200. if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
  201. }
  202. propagateBalancerTagRename(tt, oldTag, wire.tag);
  203. }
  204. const oldTarget = isBalancerLoopbackTag(oldFallback)
  205. ? (oldFallback.slice(4))
  206. : null;
  207. if (oldTarget && oldTarget !== form.fallbackTag) {
  208. removeBalancerLoopbackIfOrphaned(tt, oldTarget);
  209. }
  210. if (isFallbackABalancer) {
  211. ensureBalancerLoopback(tt, form.fallbackTag);
  212. }
  213. }
  214. syncObservatories(tt);
  215. });
  216. setModalOpen(false);
  217. }
  218. function confirmDelete(idx: number) {
  219. const deletedTag = rows[idx]?.tag;
  220. const lbTag = loopbackTagFor(deletedTag);
  221. const dependents = (templateSettings?.routing?.balancers || [])
  222. .filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
  223. .map((b) => b.tag);
  224. if (dependents.length > 0) {
  225. messageApi.error(t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }));
  226. return;
  227. }
  228. const impact = templateSettings
  229. ? planBalancerDeletion(templateSettings, idx)
  230. : { rules: [], balancers: [], observatory: false, burst: false };
  231. modal.confirm({
  232. title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
  233. content: <DeletionImpactList impact={impact} />,
  234. okText: t('delete'),
  235. okType: 'danger',
  236. cancelText: t('cancel'),
  237. onOk: () => mutate((tt) => {
  238. const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
  239. removeBalancerLoopback(tt, tag);
  240. applyBalancerDeletion(tt, idx);
  241. }),
  242. });
  243. }
  244. const columns: ColumnsType<BalancerRow> = [
  245. {
  246. title: '#',
  247. key: 'action',
  248. align: 'center',
  249. width: 100,
  250. render: (_v, _record, index) => (
  251. <div className="action-cell">
  252. <span className="row-index">{index + 1}</span>
  253. <div className={!isMobile ? 'action-buttons' : ''}>
  254. {!isMobile && (
  255. <Button aria-label={t('edit')} shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
  256. )}
  257. <Dropdown
  258. trigger={['click']}
  259. menu={{
  260. items: [
  261. ...(isMobile
  262. ? [
  263. {
  264. key: 'edit',
  265. label: (
  266. <>
  267. <EditOutlined /> {t('edit')}
  268. </>
  269. ),
  270. onClick: () => openEdit(index),
  271. },
  272. ]
  273. : []),
  274. {
  275. key: 'del',
  276. danger: true,
  277. label: (
  278. <>
  279. <DeleteOutlined /> {t('delete')}
  280. </>
  281. ),
  282. onClick: () => confirmDelete(index),
  283. },
  284. ],
  285. }}
  286. >
  287. <Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
  288. </Dropdown>
  289. </div>
  290. </div>
  291. ),
  292. },
  293. { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
  294. {
  295. title: 'Strategy',
  296. key: 'strategy',
  297. align: 'center',
  298. width: 140,
  299. render: (_v, record) => (
  300. <Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
  301. {STRATEGY_LABELS[record.strategy] || record.strategy}
  302. </Tag>
  303. ),
  304. },
  305. {
  306. title: 'Selector',
  307. key: 'selector',
  308. align: 'center',
  309. render: (_v, record) =>
  310. (record.selector || []).map((sel) => (
  311. <Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
  312. {sel}
  313. </Tag>
  314. )),
  315. },
  316. { title: 'Fallback', dataIndex: 'displayFallbackTag', key: 'displayFallbackTag', align: 'center', width: 160 },
  317. {
  318. title: t('pages.xray.balancerLive'),
  319. key: 'live',
  320. align: 'center',
  321. width: 170,
  322. render: (_v, record) => {
  323. const live = liveStatus[record.tag];
  324. if (!live?.running) {
  325. return (
  326. <Tooltip title={t('pages.xray.balancerNotRunning')}>
  327. <Tag>—</Tag>
  328. </Tooltip>
  329. );
  330. }
  331. const resolve = (tag: string) => isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
  332. const picked = live.override ? resolve(live.override) : live.selected?.[0] ? resolve(live.selected[0]) : record.displayFallbackTag;
  333. const tooltipText = live.override
  334. ? resolve(live.override)
  335. : (live.selected || []).map(resolve).join(', ');
  336. return (
  337. <Tooltip title={tooltipText || undefined}>
  338. <Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
  339. </Tooltip>
  340. );
  341. },
  342. },
  343. {
  344. title: t('pages.xray.balancerOverride'),
  345. key: 'overrideTarget',
  346. align: 'center',
  347. width: 200,
  348. render: (_v, record) => {
  349. const live = liveStatus[record.tag];
  350. const resolvedFB = record.displayFallbackTag;
  351. let options = overrideOptions;
  352. if (resolvedFB && !outboundTags.includes(resolvedFB)) {
  353. options = [...overrideOptions, {
  354. value: resolvedFB,
  355. label: (
  356. <span>
  357. <Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
  358. {resolvedFB}
  359. </span>
  360. ),
  361. }];
  362. }
  363. const rawOverride = live?.override || undefined;
  364. const resolvedOverride = rawOverride && isBalancerLoopbackTag(rawOverride)
  365. ? resolveLoopbackFallback(templateSettings!, rawOverride)
  366. : rawOverride;
  367. return (
  368. <Select
  369. size="small"
  370. style={{ width: 170 }}
  371. placeholder={t('pages.xray.balancerOverridePh')}
  372. allowClear
  373. disabled={!live?.running}
  374. value={resolvedOverride}
  375. options={options}
  376. onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
  377. />
  378. );
  379. },
  380. },
  381. ];
  382. const balancerSettingsTab = (
  383. <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
  384. {rows.length === 0 ? (
  385. <Empty description={t('emptyBalancersDesc')}>
  386. <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
  387. {t('pages.xray.Balancers')}
  388. </Button>
  389. </Empty>
  390. ) : (
  391. <>
  392. <Space>
  393. <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
  394. {t('pages.xray.Balancers')}
  395. </Button>
  396. <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
  397. <Button aria-label={t('pages.xray.balancerLiveRefresh')} icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
  398. </Tooltip>
  399. </Space>
  400. <Table
  401. columns={columns}
  402. dataSource={rows}
  403. rowKey={(r) => r.key}
  404. pagination={false}
  405. size="small"
  406. scroll={{ x: 700 }}
  407. />
  408. </>
  409. )}
  410. </Space>
  411. );
  412. return (
  413. <>
  414. {modalContextHolder}
  415. {messageContextHolder}
  416. <Tabs
  417. items={[
  418. {
  419. key: 'balancers',
  420. label: catTabLabel(<DeploymentUnitOutlined />, t('pages.xray.tabBalancerSettings'), isMobile),
  421. children: balancerSettingsTab,
  422. },
  423. {
  424. key: 'observatory',
  425. label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
  426. children: (
  427. <ObservatorySettingsTab
  428. templateSettings={templateSettings}
  429. mutate={mutate}
  430. />
  431. ),
  432. },
  433. ]}
  434. />
  435. <BalancerFormModal
  436. key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
  437. open={modalOpen}
  438. balancer={editingBalancer}
  439. outboundTags={outboundTags}
  440. balancerTags={balancerTags}
  441. balancers={balancerObjects}
  442. templateSettings={templateSettings}
  443. otherTags={otherTags}
  444. onClose={() => setModalOpen(false)}
  445. onConfirm={onConfirm}
  446. />
  447. </>
  448. );
  449. }