import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Dropdown, Empty, Modal, Radio, Space, Table, Tag } from 'antd'; import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import BalancerFormModal from './BalancerFormModal'; import type { BalancerFormValue } from './BalancerFormModal'; import JsonEditor from '@/components/JsonEditor'; import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; import type { BalancerObject, BalancerStrategySettings, BalancerStrategyType, } from '@/schemas/routing'; interface BalancersTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; clientReverseTags: string[]; isMobile: boolean; } type BalancerRecord = BalancerObject; interface BalancerRow { key: number; tag: string; strategy: BalancerStrategyType; selector: string[]; fallbackTag: string; settings?: BalancerStrategySettings; } const STRATEGY_LABELS: Record = { random: 'Random', roundRobin: 'Round robin', leastLoad: 'Least load', leastPing: 'Least ping', }; const DEFAULT_OBSERVATORY = Object.freeze({ subjectSelector: [] as string[], probeURL: 'https://www.google.com/generate_204', probeInterval: '1m', enableConcurrency: true, }); const DEFAULT_BURST_OBSERVATORY = Object.freeze({ subjectSelector: [] as string[], pingConfig: { destination: 'https://www.google.com/generate_204', interval: '1m', connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204', timeout: '5s', sampling: 2, }, }); function collectSelectors(list: BalancerRecord[]): string[] { const out = new Set(); list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s))); return [...out]; } function syncObservatories(t: XraySettingsValue) { const balancers = (t.routing?.balancers || []) as BalancerRecord[]; const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing'); if (leastPings.length > 0) { if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY)); (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings); } else { delete t.observatory; } const burstFeeders = balancers.filter((b) => { const type = b.strategy?.type || 'random'; return type === 'leastLoad' || type === 'random' || type === 'roundRobin'; }); if (burstFeeders.length > 0) { if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(burstFeeders); } else { delete t.burstObservatory; } } export default function BalancersTab({ templateSettings, setTemplateSettings, clientReverseTags, isMobile, }: BalancersTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); const [modalOpen, setModalOpen] = useState(false); const [editingBalancer, setEditingBalancer] = useState(null); const [editingIndex, setEditingIndex] = useState(null); const rows: BalancerRow[] = useMemo(() => { const list = (templateSettings?.routing?.balancers || []) as BalancerRecord[]; return list.map((b, idx) => ({ key: idx, tag: b.tag || '', strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType, selector: b.selector || [], fallbackTag: b.fallbackTag || '', settings: b.strategy?.settings, })); }, [templateSettings?.routing?.balancers]); const outboundTags = useMemo(() => { const tags = new Set(); for (const o of templateSettings?.outbounds || []) { if (o?.tag) tags.add(o.tag); } for (const tag of clientReverseTags || []) { if (tag) tags.add(tag); } return [...tags]; }, [templateSettings?.outbounds, clientReverseTags]); const otherTags = useMemo(() => { if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean); return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean); }, [rows, editingIndex]); const mutate = useCallback( (mutator: (next: XraySettingsValue) => void) => { setTemplateSettings((prev) => { if (!prev) return prev; const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue; mutator(clone); return clone; }); }, [setTemplateSettings], ); function openAdd() { setEditingBalancer(null); setEditingIndex(null); setModalOpen(true); } function openEdit(idx: number) { setEditingBalancer(rows[idx]); setEditingIndex(idx); setModalOpen(true); } function onConfirm(form: BalancerFormValue) { mutate((tt) => { if (!tt.routing) tt.routing = { rules: [], balancers: [] }; if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = []; const list = tt.routing.balancers as BalancerRecord[]; const wire: BalancerRecord = { tag: form.tag, selector: [...form.selector], fallbackTag: form.fallbackTag || '', }; if (form.strategy && form.strategy !== 'random') { wire.strategy = { type: form.strategy }; if (form.strategy === 'leastLoad' && form.settings) { wire.strategy.settings = form.settings; } } if (editingIndex == null) { list.push(wire); } else { const oldTag = list[editingIndex]?.tag; list[editingIndex] = wire; if (oldTag && oldTag !== wire.tag) { const rules = tt.routing.rules || []; for (const rule of rules) { if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag; } } } syncObservatories(tt); }); setModalOpen(false); } function confirmDelete(idx: number) { modal.confirm({ title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`, okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: () => mutate((tt) => { if (tt.routing?.balancers) { tt.routing.balancers.splice(idx, 1); syncObservatories(tt); } }), }); } const columns: ColumnsType = [ { title: '#', key: 'action', align: 'center', width: 100, render: (_v, _record, index) => (
{index + 1}
{!isMobile && (
), }, { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 }, { title: 'Strategy', key: 'strategy', align: 'center', width: 140, render: (_v, record) => ( {STRATEGY_LABELS[record.strategy] || record.strategy} ), }, { title: 'Selector', key: 'selector', align: 'center', render: (_v, record) => (record.selector || []).map((sel) => ( {sel} )), }, { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 }, ]; const hasObservatory = !!templateSettings?.observatory; const hasBurstObservatory = !!templateSettings?.burstObservatory; const showObsEditor = hasObservatory || hasBurstObservatory; const [obsView, setObsView] = useState<'observatory' | 'burstObservatory'>('observatory'); useEffect(() => { if (obsView === 'observatory' && !hasObservatory && hasBurstObservatory) { setObsView('burstObservatory'); } else if (obsView === 'burstObservatory' && !hasBurstObservatory && hasObservatory) { setObsView('observatory'); } }, [obsView, hasObservatory, hasBurstObservatory]); const obsText = useMemo(() => { const src = obsView === 'observatory' ? templateSettings?.observatory : templateSettings?.burstObservatory; return src ? JSON.stringify(src, null, 2) : ''; }, [obsView, templateSettings?.observatory, templateSettings?.burstObservatory]); function onObsTextChange(next: string) { let parsed; try { parsed = JSON.parse(next); } catch { return; } mutate((tt) => { if (obsView === 'observatory') tt.observatory = parsed; else tt.burstObservatory = parsed; }); } return ( <> {modalContextHolder} {rows.length === 0 ? ( ) : ( <> r.key} pagination={false} size="small" scroll={{ x: 400 }} /> {showObsEditor && ( <> setObsView(e.target.value)} optionType="button" buttonStyle="solid" size="small" > {hasObservatory && Observatory} {hasBurstObservatory && Burst Observatory} )} )} setModalOpen(false)} onConfirm={onConfirm} /> ); }