import { useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import type { Path } from 'react-hook-form'; import { InputAddon } from '@/components/ui'; import { FormField } from '@/components/form/rhf'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; import { BalancerFormSchema, type BalancerFormValues, } from '@/schemas/xray'; import { BalancerStrategyTypeSchema, type BalancerStrategyType, type BalancerObject, } from '@/schemas/routing'; import { isBalancerLoopbackTag } from './balancer-loopback'; export type BalancerFormValue = BalancerFormValues; interface BalancerFormModalProps { open: boolean; balancer: BalancerFormValue | null; outboundTags: string[]; balancerTags: string[]; balancers: BalancerObject[]; templateSettings: XraySettingsValue | null; otherTags: string[]; onClose: () => void; onConfirm: (value: BalancerFormValue) => void; } const STRATEGY_LABELS: Record = { random: 'Random', roundRobin: 'Round robin', leastLoad: 'Least load', leastPing: 'Least ping', }; const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({ value, label: STRATEGY_LABELS[value] ?? value, })); function initialState(balancer: BalancerFormValue | null): BalancerFormValues { if (!balancer) { return { tag: '', strategy: 'random', selector: [], fallbackTag: '' }; } return { tag: balancer.tag ?? '', strategy: (balancer.strategy ?? 'random') as BalancerStrategyType, selector: [...(balancer.selector ?? [])], fallbackTag: balancer.fallbackTag ?? '', settings: balancer.settings, }; } export default function BalancerFormModal({ open, balancer, outboundTags, balancerTags, balancers, templateSettings, otherTags, onClose, onConfirm, }: BalancerFormModalProps) { const { t } = useTranslation(); const methods = useForm({ defaultValues: initialState(balancer) }); const [submitAttempted, setSubmitAttempted] = useState(false); const isEdit = balancer != null; useEffect(() => { if (open) { methods.reset(initialState(balancer)); setSubmitAttempted(false); } }, [open, balancer, methods]); const strategy = useWatch({ control: methods.control, name: 'strategy' }); const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? []; const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? []; const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? ''; const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? ''; const currentTag = tagValue.trim(); const availableBalancerTags = useMemo( () => balancerTags.filter((tg) => tg !== currentTag), [balancerTags, currentTag], ); const cycleInfo = useMemo(() => { const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; const resolveLoopback = (tag: string): string | null => { for (const r of rules) { if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) { return r.balancerTag; } } return null; }; const fallbackOf: Record = {}; for (const b of balancers) { if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue; const target = isBalancerLoopbackTag(b.fallbackTag) ? resolveLoopback(b.fallbackTag) : b.fallbackTag; if (target) fallbackOf[b.tag] = target; } const result: Record = {}; for (const tg of availableBalancerTags) { const visited = new Set(); let cursor = tg; const path = [tg]; while (cursor && !visited.has(cursor)) { if (cursor === currentTag) { result[tg] = path; break; } visited.add(cursor); cursor = fallbackOf[cursor] || ''; if (cursor) path.push(cursor); } } return result; }, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]); const wouldCreateCycle = !!cycleInfo[fallbackTag]; const fallbackOptions = useMemo(() => { const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = [ { value: '', label: `(${t('none')})` }, ]; for (const tg of outboundTags) { options.push({ value: tg, label: tg }); } for (const tg of availableBalancerTags) { const cycle = cycleInfo[tg]; options.push({ value: tg, disabled: !!cycle, title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined, label: ( {t('pages.xray.rules.balancer')} {tg} ), }); } return options; }, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]); const isFallbackBalancer = useMemo( () => balancerTags.includes(fallbackTag), [balancerTags, fallbackTag], ); function submit() { const values = methods.getValues(); const parsed = BalancerFormSchema.safeParse(values); const trimmedTag = (values.tag ?? '').trim(); const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag); methods.clearErrors(); if (!parsed.success) { const seen = new Set(); for (const issue of parsed.error.issues) { const key = String(issue.path[0] ?? ''); if (key && !seen.has(key)) { seen.add(key); methods.setError(key as Path, { message: issue.message }); } } } if (!parsed.success || duplicateTag || wouldCreateCycle) { setSubmitAttempted(true); return; } const result: BalancerFormValues = { ...parsed.data }; if (result.strategy !== 'leastLoad') delete result.settings; onConfirm(result); } const title = isEdit ? `${t('edit')} ${t('pages.xray.Balancers')}` : `+ ${t('pages.xray.Balancers')}`; const okText = isEdit ? t('pages.clients.submitEdit') : t('create'); return (
{ const trimmed = (field.value ?? '').trim(); const duplicate = !!trimmed && otherTags.includes(trimmed); const errorMessage = fieldState.error?.message ? t(fieldState.error.message, { defaultValue: fieldState.error.message }) : ''; const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate; return ( field.onChange(e.target.value)} onBlur={field.onBlur} ref={field.ref} placeholder={t('pages.xray.balancer.tagPlaceholder')} /> ); }} /> ({ value: tg, label: tg }))} /> v ?? '' }} > (typeof v === 'number' ? v : undefined) }} >