diff --git a/frontend/src/pages/xray/BalancerFormModal.tsx b/frontend/src/pages/xray/BalancerFormModal.tsx index de3d8d8d..d8d47f98 100644 --- a/frontend/src/pages/xray/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/BalancerFormModal.tsx @@ -1,8 +1,18 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Form, Input, Modal, Select } from 'antd'; +import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; -import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray'; +import InputAddon from '@/components/InputAddon'; +import { + BalancerFormSchema, + type BalancerFormValues, +} from '@/schemas/xray'; +import { + BalancerStrategyTypeSchema, + type BalancerStrategySettings, + type BalancerStrategyType, +} from '@/schemas/routing'; export type BalancerFormValue = BalancerFormValues; @@ -15,12 +25,38 @@ interface BalancerFormModalProps { onConfirm: (value: BalancerFormValue) => void; } -const STRATEGIES = [ - { value: 'random', label: 'Random' }, - { value: 'roundRobin', label: 'Round robin' }, - { value: 'leastLoad', label: 'Least load' }, - { value: 'leastPing', label: 'Least ping' }, -]; +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, +})); + +interface FormState { + tag: string; + strategy: BalancerStrategyType; + selector: string[]; + fallbackTag: string; + settings?: BalancerStrategySettings; +} + +function initialState(balancer: BalancerFormValue | null): FormState { + 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, @@ -31,110 +67,200 @@ export default function BalancerFormModal({ onConfirm, }: BalancerFormModalProps) { const { t } = useTranslation(); - const [tag, setTag] = useState(() => balancer?.tag || ''); - const [strategy, setStrategy] = useState(() => balancer?.strategy || 'random'); - const [selector, setSelector] = useState(() => [...(balancer?.selector || [])]); - const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || ''); - + const [state, setState] = useState(() => initialState(balancer)); const isEdit = balancer != null; - useEffect(() => { - if (!open) return; - if (balancer) { - setTag(balancer.tag || ''); - setStrategy(balancer.strategy || 'random'); - setSelector([...(balancer.selector || [])]); - setFallbackTag(balancer.fallbackTag || ''); - } else { - setTag(''); - setStrategy('random'); - setSelector([]); - setFallbackTag(''); - } - }, [open, balancer]); + const update = (key: K, value: FormState[K]) => + setState((prev) => ({ ...prev, [key]: value })); const parsed = useMemo( - () => BalancerFormSchema.safeParse({ tag, strategy, selector, fallbackTag }), - [tag, strategy, selector, fallbackTag], + () => BalancerFormSchema.safeParse(state), + [state], ); - const duplicateTag = !!tag.trim() && otherTags.includes(tag.trim()); - const issuesByField = useMemo(() => { + const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim()); + const issues = useMemo(() => { const map: Record = {}; if (!parsed.success) { for (const issue of parsed.error.issues) { const key = String(issue.path[0] ?? ''); - if (!map[key]) map[key] = issue.message; + if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message }); } } return map; - }, [parsed]); - const isValid = parsed.success && !duplicateTag; - - const tagValidateStatus: 'error' | 'warning' | 'success' = issuesByField.tag - ? 'error' - : duplicateTag - ? 'warning' - : 'success'; - const tagHelp = issuesByField.tag - ? 'Tag is required' - : duplicateTag - ? 'Tag already used by another balancer' - : ''; - - const selectorValidateStatus: 'error' | 'success' = issuesByField.selector ? 'error' : 'success'; - const selectorHelp = issuesByField.selector ? 'Pick at least one outbound' : ''; + }, [parsed, t]); function submit() { if (!parsed.success || duplicateTag) return; - onConfirm(parsed.data); + const values = { ...parsed.data }; + if (values.strategy !== 'leastLoad') delete values.settings; + onConfirm(values); } - const title = isEdit - ? `${t('edit')} ${t('pages.xray.Balancers')}` - : `+ ${t('pages.xray.Balancers')}`; - const okText = isEdit ? t('pages.clients.submitEdit') : t('create'); + const settings = state.settings; + const updateSetting = ( + key: K, + value: BalancerStrategySettings[K], + ) => { + setState((prev) => ({ + ...prev, + settings: { ...(prev.settings ?? {}), [key]: value }, + })); + }; + const updateBaselines = (next: string[]) => updateSetting('baselines', next); + const updateCosts = (next: NonNullable) => updateSetting('costs', next); + + const baselines = settings?.baselines ?? []; + const costs = settings?.costs ?? []; const fallbackOptions = useMemo( () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })), [outboundTags, t], ); + const title = isEdit + ? `${t('edit')} ${t('pages.xray.Balancers')}` + : `+ ${t('pages.xray.Balancers')}`; + const okText = isEdit ? t('pages.clients.submitEdit') : t('create'); + return (
- - setTag(e.target.value)} placeholder="unique balancer tag" /> + + update('tag', e.target.value)} + placeholder="unique balancer tag" + /> - update('strategy', v)} + options={STRATEGIES} + /> + updateSetting('maxRTT', e.target.value || undefined)} + placeholder="e.g. 1s" + /> + + + updateSetting('tolerance', typeof v === 'number' ? v : undefined)} + min={0} + max={1} + step={0.01} + placeholder="0.01 = 1%" + style={{ width: '100%' }} + /> + + +