Prechádzať zdrojové kódy

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<DnsServerForm>()
- 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.
MHSanaei 23 hodín pred
rodič
commit
0208396802

+ 190 - 64
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<string, string> = {
+  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<string[]>(() => [...(balancer?.selector || [])]);
-  const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || '');
-
+  const [state, setState] = useState<FormState>(() => 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 = <K extends keyof FormState>(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<string, string> = {};
     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 = <K extends keyof BalancerStrategySettings>(
+    key: K,
+    value: BalancerStrategySettings[K],
+  ) => {
+    setState((prev) => ({
+      ...prev,
+      settings: { ...(prev.settings ?? {}), [key]: value },
+    }));
+  };
+  const updateBaselines = (next: string[]) => updateSetting('baselines', next);
+  const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => 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 (
     <Modal
       open={open}
       title={title}
       okText={okText}
       cancelText={t('close')}
-      okButtonProps={{ disabled: !isValid }}
+      okButtonProps={{ disabled: !parsed.success || duplicateTag }}
       mask={{ closable: false }}
-      destroyOnHidden
       onOk={submit}
       onCancel={onClose}
     >
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
-          <Input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="unique balancer tag" />
+        <Form.Item
+          label="Tag"
+          required
+          validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
+          help={issues.tag || (duplicateTag ? 'Tag already used by another balancer' : '')}
+          hasFeedback
+        >
+          <Input
+            value={state.tag}
+            onChange={(e) => update('tag', e.target.value)}
+            placeholder="unique balancer tag"
+          />
         </Form.Item>
         <Form.Item label="Strategy">
-          <Select value={strategy} onChange={setStrategy} options={STRATEGIES} />
+          <Select
+            value={state.strategy}
+            onChange={(v) => update('strategy', v)}
+            options={STRATEGIES}
+          />
         </Form.Item>
         <Form.Item
           label="Selector"
-          validateStatus={selectorValidateStatus}
-          help={selectorHelp}
+          required
+          validateStatus={issues.selector ? 'error' : ''}
+          help={issues.selector || ''}
           hasFeedback
         >
           <Select
             mode="tags"
-            value={selector}
-            onChange={setSelector}
+            value={state.selector}
+            onChange={(v) => update('selector', v)}
             tokenSeparators={[',']}
             options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
           />
         </Form.Item>
         <Form.Item label="Fallback">
-          <Select value={fallbackTag} onChange={setFallbackTag} allowClear options={fallbackOptions} />
+          <Select
+            value={state.fallbackTag}
+            onChange={(v) => update('fallbackTag', v ?? '')}
+            allowClear
+            options={fallbackOptions}
+          />
         </Form.Item>
+
+        {state.strategy === 'leastLoad' && (
+          <>
+            <Form.Item label="Expected">
+              <InputNumber
+                value={settings?.expected}
+                onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
+                min={0}
+                placeholder="optimal node count"
+                style={{ width: '100%' }}
+              />
+            </Form.Item>
+            <Form.Item label="Max RTT">
+              <Input
+                value={settings?.maxRTT ?? ''}
+                onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
+                placeholder="e.g. 1s"
+              />
+            </Form.Item>
+            <Form.Item label="Tolerance">
+              <InputNumber
+                value={settings?.tolerance}
+                onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
+                min={0}
+                max={1}
+                step={0.01}
+                placeholder="0.01 = 1%"
+                style={{ width: '100%' }}
+              />
+            </Form.Item>
+            <Form.Item label="Baselines">
+              <Button
+                size="small"
+                type="primary"
+                icon={<PlusOutlined />}
+                onClick={() => updateBaselines([...baselines, ''])}
+              />
+              {baselines.map((b, idx) => (
+                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                  <Input
+                    value={b}
+                    placeholder="e.g. 1s"
+                    onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
+                  />
+                  <InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+            <Form.Item label="Costs">
+              <Button
+                size="small"
+                type="primary"
+                icon={<PlusOutlined />}
+                onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
+              />
+              {costs.map((c, idx) => (
+                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                  <Switch
+                    checked={c.regexp}
+                    checkedChildren="re"
+                    unCheckedChildren="lit"
+                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
+                  />
+                  <Input
+                    value={c.match}
+                    placeholder="tag pattern"
+                    onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
+                  />
+                  <InputNumber
+                    value={c.value}
+                    placeholder="weight"
+                    style={{ width: 100 }}
+                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
+                  />
+                  <InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          </>
+        )}
       </Form>
     </Modal>
   );

+ 86 - 84
frontend/src/pages/xray/BalancersTab.tsx

@@ -8,6 +8,11 @@ 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;
@@ -16,19 +21,15 @@ interface BalancersTabProps {
   isMobile: boolean;
 }
 
-interface BalancerRecord {
-  tag: string;
-  selector?: string[];
-  fallbackTag?: string;
-  strategy?: { type?: string };
-}
+type BalancerRecord = BalancerObject;
 
 interface BalancerRow {
   key: number;
   tag: string;
-  strategy: string;
+  strategy: BalancerStrategyType;
   selector: string[];
   fallbackTag: string;
+  settings?: BalancerStrategySettings;
 }
 
 const STRATEGY_LABELS: Record<string, string> = {
@@ -102,9 +103,10 @@ export default function BalancersTab({
     return list.map((b, idx) => ({
       key: idx,
       tag: b.tag || '',
-      strategy: b.strategy?.type || 'random',
+      strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
       selector: b.selector || [],
       fallbackTag: b.fallbackTag || '',
+      settings: b.strategy?.settings,
     }));
   }, [templateSettings?.routing?.balancers]);
 
@@ -159,6 +161,9 @@ export default function BalancersTab({
       };
       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);
@@ -192,84 +197,80 @@ export default function BalancersTab({
     });
   }
 
-  const columns: ColumnsType<BalancerRow> = useMemo(
-    () => [
-      {
-        title: '#',
-        key: 'action',
-        align: 'center',
-        width: 100,
-        render: (_v, _record, index) => (
-          <div className="action-cell">
-            <span className="row-index">{index + 1}</span>
-            <div className={!isMobile ? 'action-buttons' : ''}>
-              {!isMobile && (
-                <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
-              )}
-              <Dropdown
-                trigger={['click']}
-                menu={{
-                  items: [
-                    ...(isMobile
-                      ? [
-                          {
-                            key: 'edit',
-                            label: (
-                              <>
-                                <EditOutlined /> {t('edit')}
-                              </>
-                            ),
-                            onClick: () => openEdit(index),
-                          },
-                        ]
-                      : []),
-                    {
-                      key: 'del',
-                      danger: true,
-                      label: (
-                        <>
-                          <DeleteOutlined /> {t('delete')}
-                        </>
-                      ),
-                      onClick: () => confirmDelete(index),
-                    },
-                  ],
-                }}
-              >
-                <Button shape="circle" size="small" icon={<MoreOutlined />} />
-              </Dropdown>
-            </div>
+  const columns: ColumnsType<BalancerRow> = [
+    {
+      title: '#',
+      key: 'action',
+      align: 'center',
+      width: 100,
+      render: (_v, _record, index) => (
+        <div className="action-cell">
+          <span className="row-index">{index + 1}</span>
+          <div className={!isMobile ? 'action-buttons' : ''}>
+            {!isMobile && (
+              <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
+            )}
+            <Dropdown
+              trigger={['click']}
+              menu={{
+                items: [
+                  ...(isMobile
+                    ? [
+                        {
+                          key: 'edit',
+                          label: (
+                            <>
+                              <EditOutlined /> {t('edit')}
+                            </>
+                          ),
+                          onClick: () => openEdit(index),
+                        },
+                      ]
+                    : []),
+                  {
+                    key: 'del',
+                    danger: true,
+                    label: (
+                      <>
+                        <DeleteOutlined /> {t('delete')}
+                      </>
+                    ),
+                    onClick: () => confirmDelete(index),
+                  },
+                ],
+              }}
+            >
+              <Button shape="circle" size="small" icon={<MoreOutlined />} />
+            </Dropdown>
           </div>
-        ),
-      },
-      { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
-      {
-        title: 'Strategy',
-        key: 'strategy',
-        align: 'center',
-        width: 140,
-        render: (_v, record) => (
-          <Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
-            {STRATEGY_LABELS[record.strategy] || record.strategy}
+        </div>
+      ),
+    },
+    { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
+    {
+      title: 'Strategy',
+      key: 'strategy',
+      align: 'center',
+      width: 140,
+      render: (_v, record) => (
+        <Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
+          {STRATEGY_LABELS[record.strategy] || record.strategy}
+        </Tag>
+      ),
+    },
+    {
+      title: 'Selector',
+      key: 'selector',
+      align: 'center',
+      render: (_v, record) =>
+        (record.selector || []).map((sel) => (
+          <Tag key={sel} className="info-large-tag">
+            {sel}
           </Tag>
-        ),
-      },
-      {
-        title: 'Selector',
-        key: 'selector',
-        align: 'center',
-        render: (_v, record) =>
-          (record.selector || []).map((sel) => (
-            <Tag key={sel} className="info-large-tag">
-              {sel}
-            </Tag>
-          )),
-      },
-      { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
-    ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, isMobile],
-  );
+        )),
+    },
+    { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
+  ];
 
   const hasObservatory = !!templateSettings?.observatory;
   const hasBurstObservatory = !!templateSettings?.burstObservatory;
@@ -354,6 +355,7 @@ export default function BalancersTab({
       </Space>
 
       <BalancerFormModal
+        key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
         open={modalOpen}
         balancer={editingBalancer}
         outboundTags={outboundTags}

+ 175 - 157
frontend/src/pages/xray/DnsServerModal.tsx

@@ -1,29 +1,23 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
-import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
 import InputAddon from '@/components/InputAddon';
+import {
+  DnsQueryStrategySchema,
+  DnsServerObjectInnerSchema,
+  DnsServerObjectSchema,
+  type DnsServerObject,
+} from '@/schemas/dns';
+import { antdRule } from '@/utils/zodForm';
 
 export type DnsServerValue =
   | string
-  | {
-      address: string;
-      port?: number;
-      domains?: string[];
-      expectedIPs?: string[];
+  | (DnsServerObject & {
       expectIPs?: string[];
-      unexpectedIPs?: string[];
-      queryStrategy?: string;
-      skipFallback?: boolean;
-      disableCache?: boolean;
-      finalQuery?: boolean;
-      tag?: string;
-      clientIP?: string;
-      serveStale?: boolean;
-      serveExpiredTTL?: number;
-      timeoutMs?: number;
       [key: string]: unknown;
-    };
+    });
 
 interface DnsServerModalProps {
   open: boolean;
@@ -33,9 +27,9 @@ interface DnsServerModalProps {
   onConfirm: (value: DnsServerValue) => void;
 }
 
-const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+const STRATEGIES = DnsQueryStrategySchema.options;
 
-interface DnsForm {
+type DnsServerForm = {
   address: string;
   port: number;
   domains: string[];
@@ -50,9 +44,9 @@ interface DnsForm {
   serveStale: boolean;
   serveExpiredTTL: number;
   timeoutMs: number;
-}
+};
 
-function defaultServer(): DnsForm {
+function defaultFormValues(): DnsServerForm {
   return {
     address: 'localhost',
     port: 53,
@@ -71,6 +65,68 @@ function defaultServer(): DnsForm {
   };
 }
 
+function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
+  if (server == null) return defaultFormValues();
+  if (typeof server === 'string') return { ...defaultFormValues(), address: server };
+  const parsed = DnsServerObjectSchema.safeParse(server);
+  const data = parsed.success ? parsed.data : null;
+  return {
+    ...defaultFormValues(),
+    ...(data ?? {}),
+    address: (data?.address ?? server.address) || 'localhost',
+    domains: data?.domains ?? server.domains ?? [],
+    expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
+    unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
+    queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
+    skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
+    disableCache: data?.disableCache ?? server.disableCache ?? false,
+    finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
+    tag: data?.tag ?? server.tag ?? '',
+    clientIP: data?.clientIP ?? server.clientIP ?? '',
+    serveStale: data?.serveStale ?? server.serveStale ?? false,
+    serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
+    timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
+  };
+}
+
+function valuesToWire(values: DnsServerForm): DnsServerValue {
+  const isPlain
+    = values.domains.length === 0
+    && values.expectedIPs.length === 0
+    && values.unexpectedIPs.length === 0
+    && values.port === 53
+    && values.queryStrategy === 'UseIP'
+    && values.skipFallback === false
+    && values.disableCache === false
+    && values.finalQuery === false
+    && !values.tag
+    && !values.clientIP
+    && values.serveStale === false
+    && values.serveExpiredTTL === 0
+    && values.timeoutMs === 4000;
+  if (isPlain) return values.address;
+
+  const out: Record<string, unknown> = {
+    address: values.address,
+    port: values.port,
+    domains: values.domains.filter(Boolean),
+    expectedIPs: values.expectedIPs.filter(Boolean),
+    unexpectedIPs: values.unexpectedIPs.filter(Boolean),
+    queryStrategy: values.queryStrategy,
+    skipFallback: values.skipFallback,
+    disableCache: values.disableCache,
+    finalQuery: values.finalQuery,
+    serveStale: values.serveStale,
+    serveExpiredTTL: values.serveExpiredTTL,
+    timeoutMs: values.timeoutMs,
+  };
+  if (values.tag) out.tag = values.tag;
+  if (values.clientIP) out.clientIP = values.clientIP;
+  return out as DnsServerValue;
+}
+
+const shape = DnsServerObjectInnerSchema.shape;
+
 export default function DnsServerModal({
   open,
   server,
@@ -79,74 +135,16 @@ export default function DnsServerModal({
   onConfirm,
 }: DnsServerModalProps) {
   const { t } = useTranslation();
-  const [form, setForm] = useState<DnsForm>(defaultServer());
+  const [form] = Form.useForm<DnsServerForm>();
 
   useEffect(() => {
     if (!open) return;
-    if (server == null) {
-      setForm(defaultServer());
-      return;
-    }
-    if (typeof server === 'string') {
-      setForm({ ...defaultServer(), address: server });
-      return;
-    }
-    setForm({
-      ...defaultServer(),
-      ...server,
-      domains: [...(server.domains || [])],
-      expectedIPs: [...(server.expectedIPs || server.expectIPs || [])],
-      unexpectedIPs: [...(server.unexpectedIPs || [])],
-    });
-  }, [open, server]);
-
-  const update = <K extends keyof DnsForm>(key: K, value: DnsForm[K]) =>
-    setForm((prev) => ({ ...prev, [key]: value }));
-
-  function updateList(key: 'domains' | 'expectedIPs' | 'unexpectedIPs', mutator: (next: string[]) => void) {
-    setForm((prev) => {
-      const next = [...prev[key]];
-      mutator(next);
-      return { ...prev, [key]: next };
-    });
-  }
+    form.setFieldsValue(valuesFromServer(server));
+  }, [open, server, form]);
 
-  function submit() {
-    const isPlain =
-      form.domains.length === 0 &&
-      form.expectedIPs.length === 0 &&
-      form.unexpectedIPs.length === 0 &&
-      form.port === 53 &&
-      form.queryStrategy === 'UseIP' &&
-      form.skipFallback === false &&
-      form.disableCache === false &&
-      form.finalQuery === false &&
-      !form.tag &&
-      !form.clientIP &&
-      form.serveStale === false &&
-      form.serveExpiredTTL === 0 &&
-      form.timeoutMs === 4000;
-    if (isPlain) {
-      onConfirm(form.address);
-      return;
-    }
-    const out: Record<string, unknown> = {
-      address: form.address,
-      port: form.port,
-      domains: form.domains.filter(Boolean),
-      expectedIPs: form.expectedIPs.filter(Boolean),
-      unexpectedIPs: form.unexpectedIPs.filter(Boolean),
-      queryStrategy: form.queryStrategy,
-      skipFallback: form.skipFallback,
-      disableCache: form.disableCache,
-      finalQuery: form.finalQuery,
-      serveStale: form.serveStale,
-      serveExpiredTTL: form.serveExpiredTTL,
-      timeoutMs: form.timeoutMs,
-    };
-    if (form.tag) out.tag = form.tag;
-    if (form.clientIP) out.clientIP = form.clientIP;
-    onConfirm(out as DnsServerValue);
+  async function submit() {
+    const values = await form.validateFields();
+    onConfirm(valuesToWire(values));
   }
 
   const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -161,99 +159,119 @@ export default function DnsServerModal({
       onOk={submit}
       onCancel={onClose}
     >
-      <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item label={t('pages.inbounds.address')}>
-          <Input value={form.address} onChange={(e) => update('address', e.target.value)} />
+      <Form
+        form={form}
+        colon={false}
+        labelCol={{ md: { span: 8 } }}
+        wrapperCol={{ md: { span: 14 } }}
+        initialValues={defaultFormValues()}
+      >
+        <Form.Item
+          label={t('pages.inbounds.address')}
+          name="address"
+          rules={[antdRule(shape.address, t)]}
+        >
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.inbounds.port')}>
-          <InputNumber value={form.port} min={1} max={65535} onChange={(v) => update('port', Number(v) || 53)} />
+        <Form.Item
+          label={t('pages.inbounds.port')}
+          name="port"
+          rules={[antdRule(shape.port, t)]}
+        >
+          <InputNumber min={1} max={65535} />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.tag')}>
-          <Input value={form.tag} onChange={(e) => update('tag', e.target.value)} />
+        <Form.Item label={t('pages.xray.dns.tag')} name="tag">
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.clientIp')}>
-          <Input value={form.clientIP} onChange={(e) => update('clientIP', e.target.value)} />
+        <Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
+          <Input />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.strategy')}>
+        <Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
           <Select
-            value={form.queryStrategy}
-            onChange={(v) => update('queryStrategy', v)}
             style={{ width: '100%' }}
             options={STRATEGIES.map((s) => ({ value: s, label: s }))}
           />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.timeoutMs')}>
-          <InputNumber value={form.timeoutMs} min={0} step={500} onChange={(v) => update('timeoutMs', Number(v) || 0)} />
+        <Form.Item
+          label={t('pages.xray.dns.timeoutMs')}
+          name="timeoutMs"
+          rules={[antdRule(shape.timeoutMs, t)]}
+        >
+          <InputNumber min={0} step={500} />
         </Form.Item>
 
         <Divider style={{ margin: '5px 0' }} />
 
-        <Form.Item label={t('pages.xray.dns.domains')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
-          {form.domains.map((value, idx) => (
-            <Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="domains">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.domains')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
-        <Form.Item label={t('pages.xray.dns.expectIPs')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
-          {form.expectedIPs.map((value, idx) => (
-            <Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="expectedIPs">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.expectIPs')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
-        <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
-          <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
-          {form.unexpectedIPs.map((value, idx) => (
-            <Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
-              <Input
-                value={value}
-                onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
-              />
-              <InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
-                <MinusOutlined />
-              </InputAddon>
-            </Space.Compact>
-          ))}
-        </Form.Item>
+        <Form.List name="unexpectedIPs">
+          {(fields, { add, remove }) => (
+            <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
+              <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
+              {fields.map((field) => (
+                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
+                  <Form.Item name={field.name} noStyle>
+                    <Input />
+                  </Form.Item>
+                  <InputAddon onClick={() => remove(field.name)}>
+                    <MinusOutlined />
+                  </InputAddon>
+                </Space.Compact>
+              ))}
+            </Form.Item>
+          )}
+        </Form.List>
 
         <Divider style={{ margin: '5px 0' }} />
 
-        <Form.Item label={t('pages.xray.dns.skipFallback')}>
-          <Switch checked={form.skipFallback} onChange={(v) => update('skipFallback', v)} />
+        <Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.finalQuery')}>
-          <Switch checked={form.finalQuery} onChange={(v) => update('finalQuery', v)} />
+        <Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.disableCache')}>
-          <Switch checked={form.disableCache} onChange={(v) => update('disableCache', v)} />
+        <Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveStale')}>
-          <Switch checked={form.serveStale} onChange={(v) => update('serveStale', v)} />
+        <Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
+          <Switch />
         </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveExpiredTTL')}>
-          <InputNumber
-            value={form.serveExpiredTTL}
-            min={0}
-            step={60}
-            onChange={(v) => update('serveExpiredTTL', Number(v) || 0)}
-          />
+        <Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
+          <InputNumber min={0} step={60} />
         </Form.Item>
       </Form>
     </Modal>

+ 3 - 15
frontend/src/pages/xray/DnsTab.tsx

@@ -9,6 +9,7 @@ import DnsServerModal from './DnsServerModal';
 import type { DnsServerValue } from './DnsServerModal';
 import DnsPresetsModal from './DnsPresetsModal';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import { DnsQueryStrategySchema, type DnsObject } from '@/schemas/dns';
 import './DnsTab.css';
 
 interface DnsTabProps {
@@ -16,23 +17,10 @@ interface DnsTabProps {
   setTemplateSettings: SetTemplate;
 }
 
-const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+const STRATEGIES = DnsQueryStrategySchema.options;
 const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
 
-interface DnsConfig {
-  tag?: string;
-  clientIp?: string;
-  queryStrategy?: string;
-  disableCache?: boolean;
-  disableFallback?: boolean;
-  disableFallbackIfMatch?: boolean;
-  enableParallelQuery?: boolean;
-  useSystemHosts?: boolean;
-  serveStale?: boolean;
-  serveExpiredTTL?: number;
-  hosts?: Record<string, string | string[]>;
-  servers?: DnsServerValue[];
-}
+type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
 
 interface HostRow {
   domain: string;

+ 4 - 2
frontend/src/pages/xray/RoutingTab.tsx

@@ -17,6 +17,7 @@ import type { ColumnsType } from 'antd/es/table';
 import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import type { RuleObject } from '@/schemas/routing';
 import './RoutingTab.css';
 
 interface RoutingTabProps {
@@ -182,8 +183,9 @@ export default function RoutingTab({
     mutate((tt) => {
       if (!tt.routing) tt.routing = { rules: [] };
       if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
-      if (editingIndex == null) tt.routing.rules.push(rule);
-      else tt.routing.rules[editingIndex] = rule;
+      const typed = rule as unknown as RuleObject;
+      if (editingIndex == null) tt.routing.rules.push(typed);
+      else tt.routing.rules[editingIndex] = typed;
     });
     setRuleModalOpen(false);
   }

+ 64 - 0
frontend/src/schemas/dns.ts

@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+import { PortSchema } from '@/schemas/primitives';
+
+export const DnsQueryStrategySchema = z.enum([
+  'UseIP',
+  'UseIPv4',
+  'UseIPv6',
+  'UseSystem',
+]);
+export type DnsQueryStrategy = z.infer<typeof DnsQueryStrategySchema>;
+
+const DnsHostValueSchema = z.union([z.string(), z.array(z.string())]);
+export const DnsHostsSchema = z.record(z.string(), DnsHostValueSchema);
+export type DnsHosts = z.infer<typeof DnsHostsSchema>;
+
+export const DnsServerObjectInnerSchema = z.object({
+  address: z.string(),
+  port: PortSchema.default(53),
+  domains: z.array(z.string()).optional(),
+  expectedIPs: z.array(z.string()).optional(),
+  unexpectedIPs: z.array(z.string()).optional(),
+  skipFallback: z.boolean().optional(),
+  finalQuery: z.boolean().optional(),
+  tag: z.string().optional(),
+  clientIP: z.string().optional(),
+  queryStrategy: DnsQueryStrategySchema.optional(),
+  disableCache: z.boolean().optional(),
+  timeoutMs: z.number().int().min(0).default(4000),
+  serveStale: z.boolean().optional(),
+  serveExpiredTTL: z.number().int().min(0).optional(),
+});
+
+export const DnsServerObjectSchema = z.preprocess(
+  (val) => {
+    if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
+    const v = val as Record<string, unknown>;
+    if (v.expectIPs && !v.expectedIPs) {
+      return { ...v, expectedIPs: v.expectIPs };
+    }
+    return val;
+  },
+  DnsServerObjectInnerSchema,
+);
+export type DnsServerObject = z.infer<typeof DnsServerObjectSchema>;
+
+export const DnsServerEntrySchema = z.union([z.string(), DnsServerObjectSchema]);
+export type DnsServerEntry = z.infer<typeof DnsServerEntrySchema>;
+
+export const DnsObjectSchema = z.object({
+  tag: z.string().optional(),
+  hosts: DnsHostsSchema.optional(),
+  servers: z.array(DnsServerEntrySchema).optional(),
+  clientIp: z.string().optional(),
+  queryStrategy: DnsQueryStrategySchema.default('UseIP'),
+  disableCache: z.boolean().default(false),
+  disableFallback: z.boolean().default(false),
+  disableFallbackIfMatch: z.boolean().default(false),
+  enableParallelQuery: z.boolean().default(false),
+  useSystemHosts: z.boolean().default(false),
+  serveStale: z.boolean().default(false),
+  serveExpiredTTL: z.number().int().min(0).default(0),
+});
+export type DnsObject = z.infer<typeof DnsObjectSchema>;

+ 77 - 0
frontend/src/schemas/routing.ts

@@ -0,0 +1,77 @@
+import { z } from 'zod';
+
+export const RuleProtocolSchema = z.enum(['http', 'tls', 'quic', 'bittorrent']);
+export type RuleProtocol = z.infer<typeof RuleProtocolSchema>;
+
+const PortValueSchema = z.union([
+  z.number().int().min(0).max(65535),
+  z.string(),
+]);
+
+export const RuleWebhookSchema = z.object({
+  url: z.string(),
+  deduplication: z.number().int().min(0).optional(),
+  headers: z.record(z.string(), z.string()).optional(),
+});
+export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
+
+export const RuleObjectSchema = z.object({
+  type: z.literal('field').default('field'),
+  domain: z.array(z.string()).optional(),
+  ip: z.array(z.string()).optional(),
+  port: PortValueSchema.optional(),
+  sourcePort: PortValueSchema.optional(),
+  localPort: PortValueSchema.optional(),
+  network: z.string().optional(),
+  sourceIP: z.array(z.string()).optional(),
+  localIP: z.array(z.string()).optional(),
+  user: z.array(z.string()).optional(),
+  vlessRoute: PortValueSchema.optional(),
+  inboundTag: z.array(z.string()).optional(),
+  protocol: z.array(z.string()).optional(),
+  attrs: z.record(z.string(), z.string()).optional(),
+  process: z.array(z.string()).optional(),
+  outboundTag: z.string().optional(),
+  balancerTag: z.string().optional(),
+  ruleTag: z.string().optional(),
+  webhook: RuleWebhookSchema.optional(),
+});
+export type RuleObject = z.infer<typeof RuleObjectSchema>;
+
+export const BalancerStrategyTypeSchema = z.enum([
+  'random',
+  'roundRobin',
+  'leastPing',
+  'leastLoad',
+]);
+export type BalancerStrategyType = z.infer<typeof BalancerStrategyTypeSchema>;
+
+export const BalancerCostObjectSchema = z.object({
+  regexp: z.boolean().default(false),
+  match: z.string(),
+  value: z.number(),
+});
+export type BalancerCostObject = z.infer<typeof BalancerCostObjectSchema>;
+
+export const BalancerStrategySettingsSchema = z.object({
+  expected: z.number().int().min(0).optional(),
+  maxRTT: z.string().optional(),
+  tolerance: z.number().min(0).max(1).optional(),
+  baselines: z.array(z.string()).optional(),
+  costs: z.array(BalancerCostObjectSchema).optional(),
+});
+export type BalancerStrategySettings = z.infer<typeof BalancerStrategySettingsSchema>;
+
+export const BalancerStrategySchema = z.object({
+  type: BalancerStrategyTypeSchema.default('random'),
+  settings: BalancerStrategySettingsSchema.optional(),
+});
+export type BalancerStrategy = z.infer<typeof BalancerStrategySchema>;
+
+export const BalancerObjectSchema = z.object({
+  tag: z.string().trim().min(1),
+  selector: z.array(z.string()).min(1),
+  fallbackTag: z.string().optional(),
+  strategy: BalancerStrategySchema.optional(),
+});
+export type BalancerObject = z.infer<typeof BalancerObjectSchema>;

+ 13 - 12
frontend/src/schemas/xray.ts

@@ -1,4 +1,11 @@
 import { z } from 'zod';
+import { DnsObjectSchema } from './dns';
+import {
+  BalancerObjectSchema,
+  BalancerStrategySettingsSchema,
+  BalancerStrategyTypeSchema,
+  RuleObjectSchema,
+} from './routing';
 
 export const XraySettingsValueSchema = z.object({
   inbounds: z.array(z.unknown()).optional(),
@@ -13,18 +20,11 @@ export const XraySettingsValueSchema = z.object({
     )
     .optional(),
   routing: z.object({
-    rules: z.array(z.object({
-      type: z.string().optional(),
-      outboundTag: z.string().optional(),
-      balancerTag: z.string().optional(),
-    }).loose()).optional(),
-    balancers: z.array(z.unknown()).optional(),
+    rules: z.array(RuleObjectSchema).optional(),
+    balancers: z.array(BalancerObjectSchema).optional(),
     domainStrategy: z.string().optional(),
   }).loose().optional(),
-  dns: z.object({
-    tag: z.string().optional(),
-    servers: z.array(z.unknown()).optional(),
-  }).loose().optional(),
+  dns: DnsObjectSchema.optional(),
   log: z.record(z.string(), z.unknown()).optional(),
   policy: z.object({
     system: z.record(z.string(), z.boolean()).optional(),
@@ -109,9 +109,10 @@ export const RuleFormSchema = z.object({
 
 export const BalancerFormSchema = z.object({
   tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
-  strategy: z.string(),
+  strategy: BalancerStrategyTypeSchema.default('random'),
   selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
-  fallbackTag: z.string(),
+  fallbackTag: z.string().default(''),
+  settings: BalancerStrategySettingsSchema.optional(),
 });
 
 export const OutboundTagSchema = z

+ 2 - 0
web/translation/en-US.json

@@ -791,6 +791,8 @@
       "InboundsDesc": "Accepting the specific clients.",
       "Outbounds": "Outbounds",
       "Balancers": "Balancers",
+      "balancerTagRequired": "Tag is required",
+      "balancerSelectorRequired": "Pick at least one outbound",
       "OutboundsDesc": "Set the outgoing traffic pathway.",
       "Routings": "Routing Rules",
       "RoutingsDesc": "The priority of each rule is important!",

+ 2 - 0
web/translation/fa-IR.json

@@ -791,6 +791,8 @@
       "InboundsDesc": "پذیرش کلاینت خاص",
       "Outbounds": "خروجی‌ها",
       "Balancers": "بالانسرها",
+      "balancerTagRequired": "تگ الزامی است",
+      "balancerSelectorRequired": "حداقل یک outbound انتخاب کنید",
       "OutboundsDesc": "مسیر ترافیک خروجی را تنظیم کنید",
       "Routings": "قوانین مسیریابی",
       "RoutingsDesc": "اولویت هر قانون مهم است",