From 0208396802fb4cf5fcc5c41ef72ef7cc5e202582 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 23:36:01 +0200 Subject: [PATCH] feat(frontend): migrate DNS + Routing to Zod, align with xray docs Adds first-class Zod schemas for the xray-core DNS block and routing sub-objects (Balancer, Rule) matching the documented shape at https://xtls.github.io/config/dns.html and https://xtls.github.io/config/routing.html, then wires the DnsServerModal and BalancerFormModal up to those schemas. schemas/dns.ts (new): - DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem) - DnsHostsSchema record(string -> string | string[]) - DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess to migrate legacy `expectIPs` -> `expectedIPs` alias) - DnsServerEntrySchema = string | DnsServerObject (xray accepts both) - DnsObjectSchema with all documented fields and defaults schemas/routing.ts (new): - RuleProtocolSchema enum (http/tls/quic/bittorrent) - RuleWebhookSchema (url/deduplication/headers) - RuleObjectSchema covering every documented field (domain/ip/port/ sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/ inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/ webhook) with type=literal('field').default('field') - BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad) - BalancerCostObjectSchema {regexp,match,value} - BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs) - BalancerStrategySchema + BalancerObjectSchema schemas/xray.ts: - routing.rules: was loose 3-field object, now z.array(RuleObjectSchema) - routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema) - dns: was 2-field loose, now full DnsObjectSchema - BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum) instead of z.string(); fallbackTag defaults to ''; settings? added for leastLoad DnsServerModal (full Pattern A rewrite): - useState/DnsForm interface -> Form.useForm() - manual domain/expectedIP/unexpectedIP list -> Form.List - antdRule on address/port/timeoutMs for inline validation - preserves legacy collapse-to-bare-string behavior on submit BalancerFormModal: - Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/ Baselines/Costs) wired to BalancerStrategySettingsSchema - Strategy options derived from schema enum - Cost rows with regexp/literal switch + match + value - required prop on Tag and Selector for red asterisk visual BalancersTab: - BalancerRecord interface -> type alias to BalancerObject - onConfirm now propagates strategy.settings to wire when leastLoad - Removes useMemo wrapping `columns` array. The memo had deps [t, isMobile] (with an eslint-disable) so the column render functions kept their original closure over `openEdit`. Once a balancer was created and the user clicked the edit button, the stale openEdit fired with empty `rows`, so rows[idx] was undefined and the modal opened blank. Columns are cheap to rebuild each render, so dropping the memo is the right fix. DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types. translations (en-US, fa-IR): add the previously-missing pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired keys so antdRule surfaces a real message instead of the raw i18n key. --- frontend/src/pages/xray/BalancerFormModal.tsx | 254 ++++++++++---- frontend/src/pages/xray/BalancersTab.tsx | 170 ++++----- frontend/src/pages/xray/DnsServerModal.tsx | 332 +++++++++--------- frontend/src/pages/xray/DnsTab.tsx | 18 +- frontend/src/pages/xray/RoutingTab.tsx | 6 +- frontend/src/schemas/dns.ts | 64 ++++ frontend/src/schemas/routing.ts | 77 ++++ frontend/src/schemas/xray.ts | 25 +- web/translation/en-US.json | 2 + web/translation/fa-IR.json | 2 + 10 files changed, 616 insertions(+), 334 deletions(-) create mode 100644 frontend/src/schemas/dns.ts create mode 100644 frontend/src/schemas/routing.ts 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%' }} + /> + + +