4 次代码提交 8c30ddbfd9 ... eee26e4788

作者 SHA1 备注 提交日期
  MHSanaei eee26e4788 fix(outbounds): lock hysteria to its QUIC transport + TLS, add version/masquerade 13 小时之前
  MHSanaei 987a6dd1e5 feat(clients/inbounds): IP log popups, clearer titles, tag-based inbound labels 13 小时之前
  MHSanaei 12afb862ff fix(outbounds): parse wireguard:// links and fix ss:// query-string port 15 小时之前
  MHSanaei cb7af04cd3 fix(xray): test UDP outbounds via xray probe (#4657) + Vision testseed & Flow form fixes 15 小时之前
共有 36 个文件被更改,包括 617 次插入398 次删除
  1. 120 0
      frontend/src/components/HysteriaMasqueradeForm.tsx
  2. 36 25
      frontend/src/hooks/useXraySetting.ts
  3. 72 5
      frontend/src/lib/xray/outbound-link-parser.ts
  4. 1 1
      frontend/src/models/setting.ts
  5. 1 1
      frontend/src/pages/clients/BulkAddToGroupModal.tsx
  6. 1 1
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  7. 1 1
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  8. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  9. 55 19
      frontend/src/pages/clients/ClientFormModal.tsx
  10. 92 11
      frontend/src/pages/clients/ClientInfoModal.tsx
  11. 1 1
      frontend/src/pages/clients/ClientQrModal.tsx
  12. 4 5
      frontend/src/pages/clients/ClientsPage.tsx
  13. 1 3
      frontend/src/pages/clients/FilterDrawer.tsx
  14. 2 2
      frontend/src/pages/inbounds/AttachClientsModal.tsx
  15. 1 1
      frontend/src/pages/inbounds/DetachClientsModal.tsx
  16. 14 121
      frontend/src/pages/inbounds/InboundFormModal.tsx
  17. 2 2
      frontend/src/pages/inbounds/InboundInfoModal.tsx
  18. 2 2
      frontend/src/pages/inbounds/InboundList.tsx
  19. 56 59
      frontend/src/pages/xray/OutboundFormModal.tsx
  20. 2 1
      frontend/src/pages/xray/OutboundsTab.tsx
  21. 1 0
      frontend/src/schemas/client.ts
  22. 60 0
      frontend/src/test/outbound-link-parser.test.ts
  23. 26 71
      web/service/outbound.go
  24. 5 5
      web/translation/ar-EG.json
  25. 5 5
      web/translation/en-US.json
  26. 5 5
      web/translation/es-ES.json
  27. 5 5
      web/translation/fa-IR.json
  28. 5 5
      web/translation/id-ID.json
  29. 5 5
      web/translation/ja-JP.json
  30. 5 5
      web/translation/pt-BR.json
  31. 5 5
      web/translation/ru-RU.json
  32. 5 5
      web/translation/tr-TR.json
  33. 5 5
      web/translation/uk-UA.json
  34. 5 5
      web/translation/vi-VN.json
  35. 5 5
      web/translation/zh-CN.json
  36. 5 5
      web/translation/zh-TW.json

+ 120 - 0
frontend/src/components/HysteriaMasqueradeForm.tsx

@@ -0,0 +1,120 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import type { FormInstance } from 'antd';
+
+import HeaderMapEditor from '@/components/HeaderMapEditor';
+
+const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+
+interface HysteriaMasqueradeFormProps {
+  form: FormInstance;
+}
+
+export default function HysteriaMasqueradeForm({ form }: HysteriaMasqueradeFormProps) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <Form.Item label={t('pages.inbounds.form.masquerade')}>
+        <Form.Item shouldUpdate noStyle>
+          {() => {
+            const m = form.getFieldValue(MASQ_PATH);
+            return (
+              <Switch
+                checked={!!m}
+                onChange={(checked) =>
+                  form.setFieldValue(
+                    MASQ_PATH,
+                    checked
+                      ? {
+                        type: '', dir: '', url: '',
+                        rewriteHost: false, insecure: false,
+                        content: '', headers: {}, statusCode: 0,
+                      }
+                      : undefined,
+                  )
+                }
+              />
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+      <Form.Item shouldUpdate noStyle>
+        {() => {
+          const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
+          if (!m) return null;
+          return (
+            <>
+              <Form.Item
+                label={t('pages.inbounds.form.type')}
+                name={[...MASQ_PATH, 'type']}
+              >
+                <Select
+                  options={[
+                    { value: '', label: 'default (404 page)' },
+                    { value: 'proxy', label: 'proxy (reverse proxy)' },
+                    { value: 'file', label: 'file (serve directory)' },
+                    { value: 'string', label: 'string (fixed body)' },
+                  ]}
+                />
+              </Form.Item>
+              {m.type === 'proxy' && (
+                <>
+                  <Form.Item
+                    label={t('pages.inbounds.form.upstreamUrl')}
+                    name={[...MASQ_PATH, 'url']}
+                  >
+                    <Input placeholder="https://www.example.com" />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.rewriteHost')}
+                    name={[...MASQ_PATH, 'rewriteHost']}
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.skipTlsVerify')}
+                    name={[...MASQ_PATH, 'insecure']}
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                </>
+              )}
+              {m.type === 'file' && (
+                <Form.Item
+                  label={t('pages.inbounds.form.directory')}
+                  name={[...MASQ_PATH, 'dir']}
+                >
+                  <Input placeholder="/var/www/html" />
+                </Form.Item>
+              )}
+              {m.type === 'string' && (
+                <>
+                  <Form.Item
+                    label={t('pages.inbounds.form.statusCode')}
+                    name={[...MASQ_PATH, 'statusCode']}
+                  >
+                    <InputNumber min={0} max={599} style={{ width: '100%' }} />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.body')}
+                    name={[...MASQ_PATH, 'content']}
+                  >
+                    <Input.TextArea autoSize={{ minRows: 3 }} />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.headers')}
+                    name={[...MASQ_PATH, 'headers']}
+                  >
+                    <HeaderMapEditor mode="v1" />
+                  </Form.Item>
+                </>
+              )}
+            </>
+          );
+        }}
+      </Form.Item>
+    </>
+  );
+}

+ 36 - 25
frontend/src/hooks/useXraySetting.ts

@@ -17,6 +17,13 @@ import {
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 
+export function isUdpOutbound(outbound: unknown): boolean {
+  const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
+  const p = o?.protocol;
+  const n = o?.streamSettings?.network;
+  return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
+}
+
 export type { OutboundTrafficRow, OutboundTestResult };
 
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
@@ -243,15 +250,16 @@ export function useXraySetting(): UseXraySettingResult {
   const testOutbound = useCallback(
     async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
       if (!outbound) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
       setOutboundTestStates((prev) => ({
         ...prev,
-        [index]: { testing: true, result: null, mode },
+        [index]: { testing: true, result: null, mode: effMode },
       }));
       try {
         const raw = await HttpUtil.post('/panel/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
-          mode,
+          mode: effMode,
         });
         const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
         if (msg?.success && msg.obj) {
@@ -265,7 +273,7 @@ export function useXraySetting(): UseXraySettingResult {
           ...prev,
           [index]: {
             testing: false,
-            result: { success: false, error: msg?.msg || 'Unknown error', mode },
+            result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
           },
         }));
       } catch (e) {
@@ -273,7 +281,7 @@ export function useXraySetting(): UseXraySettingResult {
           ...prev,
           [index]: {
             testing: false,
-            result: { success: false, error: String(e), mode },
+            result: { success: false, error: String(e), mode: effMode },
           },
         }));
       }
@@ -287,28 +295,31 @@ export function useXraySetting(): UseXraySettingResult {
     if (list.length === 0 || testingAll) return;
     setTestingAll(true);
     try {
-      const concurrency = mode === 'tcp' ? 8 : 1;
-      const queue = list
-        .map((ob, i) => ({ index: i, outbound: ob }))
-        .filter(({ outbound }) => {
-          const tag = outbound?.tag;
-          const proto = outbound?.protocol;
-          if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
-          if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
-          return true;
-        });
-      async function worker() {
-        while (queue.length > 0) {
-          const item = queue.shift();
-          if (!item) break;
-          await testOutbound(item.index, item.outbound, mode);
+      const tcpQueue: { index: number; outbound: unknown }[] = [];
+      const httpQueue: { index: number; outbound: unknown }[] = [];
+      list.forEach((ob, i) => {
+        const tag = ob?.tag;
+        const proto = ob?.protocol;
+        if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
+        if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
+        if (mode === 'http' || isUdpOutbound(ob)) {
+          httpQueue.push({ index: i, outbound: ob });
+        } else {
+          tcpQueue.push({ index: i, outbound: ob });
         }
-      }
-      const workers = Array.from(
-        { length: Math.min(concurrency, queue.length) },
-        () => worker(),
-      );
-      await Promise.all(workers);
+      });
+      const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
+        const worker = async () => {
+          while (queue.length > 0) {
+            const item = queue.shift();
+            if (!item) break;
+            await testOutbound(item.index, item.outbound, mode);
+          }
+        };
+        const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
+        await Promise.all(workers);
+      };
+      await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
     } finally {
       setTestingAll(false);
     }

+ 72 - 5
frontend/src/lib/xray/outbound-link-parser.ts

@@ -356,18 +356,20 @@ export function parseShadowsocksLink(link: string): Raw | null {
   if (hashIndex >= 0) {
     try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
   }
-  const atIndex = linkNoHash.indexOf('@');
+  const queryIndex = linkNoHash.indexOf('?');
+  const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
+  const atIndex = core.indexOf('@');
   if (atIndex >= 0) {
-    try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
-    catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
-    const hostPort = linkNoHash.slice(atIndex + 1);
+    try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); }
+    catch { userInfo = core.slice('ss://'.length, atIndex); }
+    const hostPort = core.slice(atIndex + 1);
     const colon = hostPort.lastIndexOf(':');
     if (colon < 0) return null;
     host = hostPort.slice(0, colon);
     port = Number(hostPort.slice(colon + 1)) || 443;
   } else {
     let decoded: string;
-    try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
+    try { decoded = Base64.decode(core.slice('ss://'.length)); }
     catch { return null; }
     const at = decoded.indexOf('@');
     if (at < 0) return null;
@@ -424,6 +426,70 @@ export function parseHysteria2Link(link: string): Raw | null {
   };
 }
 
+function firstParam(params: URLSearchParams, ...keys: string[]): string | null {
+  for (const k of keys) {
+    const v = params.get(k);
+    if (v !== null && v !== '') return v;
+  }
+  return null;
+}
+
+export function parseWireguardLink(link: string): Raw | null {
+  const url = parseUrlLink(link, 'wireguard') ?? parseUrlLink(link, 'wg');
+  if (!url) return null;
+  let secretKey: string;
+  try {
+    secretKey = decodeURIComponent(url.username);
+  } catch {
+    secretKey = url.username;
+  }
+  const params = url.searchParams;
+  const host = url.hostname;
+  const port = url.port;
+  const endpoint = host ? (port ? `${host}:${port}` : host) : '';
+
+  const addressRaw = firstParam(params, 'address', 'ip') ?? '';
+  const address = addressRaw.split(',').map((s) => s.trim()).filter(Boolean);
+
+  const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
+  const allowedIPs = allowedRaw
+    ? allowedRaw.split(',').map((s) => s.trim()).filter(Boolean)
+    : ['0.0.0.0/0', '::/0'];
+
+  const peer: Raw = {
+    publicKey: firstParam(params, 'publickey', 'publicKey', 'public_key', 'peerPublicKey') ?? '',
+    endpoint,
+    allowedIPs,
+  };
+  const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
+  if (psk) peer.preSharedKey = psk;
+  const keepAliveRaw = firstParam(params, 'keepalive', 'persistentkeepalive', 'persistent_keepalive');
+  if (keepAliveRaw !== null) {
+    const k = Number(keepAliveRaw);
+    if (Number.isFinite(k)) peer.keepAlive = k;
+  }
+
+  const settings: Raw = { secretKey, address, peers: [peer] };
+  const mtuRaw = firstParam(params, 'mtu');
+  if (mtuRaw !== null) {
+    const m = Number(mtuRaw);
+    if (Number.isFinite(m)) settings.mtu = m;
+  }
+  const reservedRaw = firstParam(params, 'reserved');
+  if (reservedRaw) {
+    const reserved = reservedRaw.split(',')
+      .map((s) => Number(s.trim()))
+      .filter((n) => Number.isFinite(n));
+    if (reserved.length > 0) settings.reserved = reserved;
+  }
+
+  return {
+    protocol: 'wireguard',
+    tag: decodeRemark(url),
+    settings,
+  };
+}
+
 // Dispatcher — first non-null parser wins. Returns null when no parser
 // recognizes the link's protocol scheme.
 export function parseOutboundLink(link: string): Raw | null {
@@ -435,5 +501,6 @@ export function parseOutboundLink(link: string): Raw | null {
     ?? parseTrojanLink(trimmed)
     ?? parseShadowsocksLink(trimmed)
     ?? parseHysteria2Link(trimmed)
+    ?? parseWireguardLink(trimmed)
   );
 }

+ 1 - 1
frontend/src/models/setting.ts

@@ -40,7 +40,7 @@ export class AllSetting {
   subPort = 2096;
   subPath = '/sub/';
   subJsonPath = '/json/';
-  subClashEnable = true;
+  subClashEnable = false;
   subClashPath = '/clash/';
   subDomain = '';
   externalTrafficInformEnable = false;

+ 1 - 1
frontend/src/pages/clients/BulkAddToGroupModal.tsx

@@ -63,7 +63,7 @@ export default function BulkAddToGroupModal({
           >
             <AutoComplete
               value={value}
-              placeholder={t('pages.clients.addToGroupPlaceholder')}
+              placeholder={t('pages.clients.groupName')}
               options={groups.map((g) => ({ value: g }))}
               onChange={(v) => setValue(v ?? '')}
               filterOption={(input, option) =>

+ 1 - 1
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -36,7 +36,7 @@ export default function BulkAttachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: `${ib.remark ?? ''} (${ib.protocol ?? ''}@${ib.port ?? ''})`,
+        label: ib.tag,
       }));
   }, [inbounds]);
 

+ 1 - 1
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -36,7 +36,7 @@ export default function BulkDetachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: `${ib.remark ?? ''} (${ib.protocol ?? ''}@${ib.port ?? ''})`,
+        label: ib.tag,
       }));
   }, [inbounds]);
 

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -100,7 +100,7 @@ export default function ClientBulkAddModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+        label: ib.tag ?? '',
         value: ib.id,
       })),
     [inbounds],

+ 55 - 19
frontend/src/pages/clients/ClientFormModal.tsx

@@ -15,7 +15,7 @@ import {
   Tag,
   message,
 } from 'antd';
-import { ReloadOutlined } from '@ant-design/icons';
+import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
@@ -148,6 +148,7 @@ export default function ClientFormModal({
   const [clientIps, setClientIps] = useState<string[]>([]);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
+  const [ipsModalOpen, setIpsModalOpen] = useState(false);
 
   function update<K extends keyof FormState>(key: K, value: FormState[K]) {
     setForm((prev) => ({ ...prev, [key]: value }));
@@ -155,6 +156,7 @@ export default function ClientFormModal({
 
   useEffect(() => {
     if (!open) return;
+    setIpsModalOpen(false);
 
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
@@ -259,9 +261,9 @@ export default function ClientFormModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+        label: ib.tag ?? '',
         value: ib.id,
-        title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
+        title: ib.tag ?? '',
       })),
     [inbounds],
   );
@@ -279,6 +281,11 @@ export default function ClientFormModal({
     }
   }
 
+  function openIpsModal() {
+    setIpsModalOpen(true);
+    if (clientIps.length === 0) void loadIps();
+  }
+
   async function clearIps() {
     if (!isEdit || !client?.email) return;
     setIpsClearing(true);
@@ -376,7 +383,7 @@ export default function ClientFormModal({
       {messageContextHolder}
       <Modal
         open={open}
-        title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
+        title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
         destroyOnHidden
         okText={isEdit ? t('save') : t('create')}
         cancelText={t('cancel')}
@@ -584,25 +591,54 @@ export default function ClientFormModal({
 
           {isEdit && ipLimitEnable && (
             <Form.Item label={t('pages.clients.ipLog')}>
-              <Space style={{ marginBottom: 8 }}>
-                <Button size="small" loading={ipsLoading} onClick={loadIps}>{t('refresh')}</Button>
-                <Button size="small" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
-                  {t('pages.clients.clearAll')}
-                </Button>
-              </Space>
-              {clientIps.length > 0 ? (
-                <div>
-                  {clientIps.map((ip, idx) => (
-                    <Tag key={idx} color="blue" style={{ marginBottom: 4 }}>{ip}</Tag>
-                  ))}
-                </div>
-              ) : (
-                <Tag>{t('tgbot.noIpRecord')}</Tag>
-              )}
+              <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                {clientIps.length > 0 ? clientIps.length : ''}
+              </Button>
             </Form.Item>
           )}
         </Form>
       </Modal>
+
+      <Modal
+        open={ipsModalOpen}
+        title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
+        width={440}
+        onCancel={() => setIpsModalOpen(false)}
+        footer={[
+          <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
+            {t('refresh')}
+          </Button>,
+          <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
+            {t('pages.clients.clearAll')}
+          </Button>,
+          <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
+            {t('close')}
+          </Button>,
+        ]}
+      >
+        {clientIps.length > 0 ? (
+          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
+            {clientIps.map((ip, idx) => (
+              <Tag
+                key={idx}
+                color="blue"
+                style={{
+                  display: 'block',
+                  width: 'fit-content',
+                  maxWidth: '100%',
+                  marginBottom: 6,
+                  padding: '2px 8px',
+                  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+                }}
+              >
+                {ip}
+              </Tag>
+            ))}
+          </div>
+        ) : (
+          <Tag>{t('tgbot.noIpRecord')}</Tag>
+        )}
+      </Modal>
     </>
   );
 }

+ 92 - 11
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
-import { CopyOutlined, QrcodeOutlined } from '@ant-design/icons';
+import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { useDatepicker } from '@/hooks/useDatepicker';
@@ -145,10 +145,16 @@ export default function ClientInfoModal({
   const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
   const [messageApi, messageContextHolder] = message.useMessage();
   const [links, setLinks] = useState<string[]>([]);
+  const [clientIps, setClientIps] = useState<string[]>([]);
+  const [ipsLoading, setIpsLoading] = useState(false);
+  const [ipsClearing, setIpsClearing] = useState(false);
+  const [ipsModalOpen, setIpsModalOpen] = useState(false);
 
   useEffect(() => {
     if (!open) {
       setLinks([]);
+      setClientIps([]);
+      setIpsModalOpen(false);
       return;
     }
     if (!client?.subId) return;
@@ -197,12 +203,41 @@ export default function ClientInfoModal({
     if (ok) messageApi.success(t('copied'));
   }
 
+  async function loadIps() {
+    if (!client?.email) return;
+    setIpsLoading(true);
+    try {
+      const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
+      if (!msg?.success) { setClientIps([]); return; }
+      const arr = Array.isArray(msg.obj) ? msg.obj : [];
+      setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
+    } finally {
+      setIpsLoading(false);
+    }
+  }
+
+  async function clearIps() {
+    if (!client?.email) return;
+    setIpsClearing(true);
+    try {
+      const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`) as ApiMsg;
+      if (msg?.success) setClientIps([]);
+    } finally {
+      setIpsClearing(false);
+    }
+  }
+
+  function openIpsModal() {
+    setIpsModalOpen(true);
+    if (clientIps.length === 0) void loadIps();
+  }
+
   return (
     <>
       {messageContextHolder}
       <Modal
         open={open}
-        title={client ? client.email : t('info')}
+        title={client ? `${t('pages.clients.clientInfo')} — ${client.email}` : t('pages.clients.clientInfo')}
         footer={null}
         width={640}
         onCancel={() => onOpenChange(false)}
@@ -313,6 +348,14 @@ export default function ClientInfoModal({
                   <td>{t('pages.clients.ipLimit')}</td>
                   <td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
                 </tr>
+                <tr>
+                  <td>{t('pages.inbounds.IPLimitlog')}</td>
+                  <td>
+                    <Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                      {clientIps.length > 0 ? clientIps.length : ''}
+                    </Button>
+                  </td>
+                </tr>
                 <tr>
                   <td>{t('pages.inbounds.createdAt')}</td>
                   <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
@@ -335,30 +378,27 @@ export default function ClientInfoModal({
                       if (ids.length === 0) return <span className="hint">—</span>;
                       const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
                       const overflow = ids.slice(INBOUND_CHIP_LIMIT);
-                      const inboundChip = (id: number, compact: boolean) => {
+                      const inboundChip = (id: number) => {
                         const ib = inboundsById[id];
                         const proto = (ib?.protocol || '').toLowerCase();
                         const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-                        const fullLabel = ib
-                          ? `${ib.remark || `#${id}`} (${ib.protocol}:${ib.port})`
-                          : `#${id}`;
-                        const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+                        const label = ib?.tag ?? '';
                         return (
-                          <Tooltip key={id} title={fullLabel}>
-                            <Tag color={color}>{compact ? compactLabel : fullLabel}</Tag>
+                          <Tooltip key={id} title={label}>
+                            <Tag color={color}>{label}</Tag>
                           </Tooltip>
                         );
                       };
                       return (
                         <div className="chips">
-                          {visible.map((id) => inboundChip(id, true))}
+                          {visible.map((id) => inboundChip(id))}
                           {overflow.length > 0 && (
                             <Popover
                               trigger="click"
                               placement="bottomRight"
                               content={
                                 <div className="chips chips-stack">
-                                  {overflow.map((id) => inboundChip(id, false))}
+                                  {overflow.map((id) => inboundChip(id))}
                                 </div>
                               }
                             >
@@ -510,6 +550,47 @@ export default function ClientInfoModal({
           </>
         )}
       </Modal>
+
+      <Modal
+        open={ipsModalOpen}
+        title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
+        width={440}
+        onCancel={() => setIpsModalOpen(false)}
+        footer={[
+          <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
+            {t('refresh')}
+          </Button>,
+          <Button key="clear" danger loading={ipsClearing} disabled={clientIps.length === 0} onClick={clearIps}>
+            {t('pages.clients.clearAll')}
+          </Button>,
+          <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
+            {t('close')}
+          </Button>,
+        ]}
+      >
+        {clientIps.length > 0 ? (
+          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
+            {clientIps.map((ip, idx) => (
+              <Tag
+                key={idx}
+                color="blue"
+                style={{
+                  display: 'block',
+                  width: 'fit-content',
+                  maxWidth: '100%',
+                  marginBottom: 6,
+                  padding: '2px 8px',
+                  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+                }}
+              >
+                {ip}
+              </Tag>
+            ))}
+          </div>
+        ) : (
+          <Tag>{t('tgbot.noIpRecord')}</Tag>
+        )}
+      </Modal>
     </>
   );
 }

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -117,7 +117,7 @@ export default function ClientQrModal({
   return (
     <Modal
       open={open}
-      title={client ? client.email : t('qrCode')}
+      title={client ? `${t('qrCode')} — ${client.email}` : t('qrCode')}
       footer={null}
       width={520}
       centered

+ 4 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -299,8 +299,7 @@ export default function ClientsPage() {
 
   function inboundLabel(id: number) {
     const ib = inboundsById[id];
-    if (!ib) return `#${id}`;
-    return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
+    return ib?.tag ?? '';
   }
 
   const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@@ -589,7 +588,7 @@ export default function ClientsPage() {
           <Tooltip title={t('pages.clients.qrCode')}>
             <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
           </Tooltip>
-          <Tooltip title={t('pages.clients.moreInformation')}>
+          <Tooltip title={t('pages.clients.clientInfo')}>
             <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
           </Tooltip>
           <Tooltip title={t('pages.inbounds.resetTraffic')}>
@@ -678,7 +677,7 @@ export default function ClientsPage() {
           const ib = inboundsById[id];
           const proto = (ib?.protocol || '').toLowerCase();
           const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
+          const compactLabel = ib?.tag ?? '';
           return (
             <Tooltip key={id} title={inboundLabel(id)}>
               <Tag color={color} style={{ margin: 2 }}>
@@ -1118,7 +1117,7 @@ export default function ClientsPage() {
                                     {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
                                     {bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
                                     <div className="card-actions" onClick={(e) => e.stopPropagation()}>
-                                      <Tooltip title={t('pages.clients.moreInformation')}>
+                                      <Tooltip title={t('pages.clients.clientInfo')}>
                                         <InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
                                       </Tooltip>
                                       <Switch

+ 1 - 3
frontend/src/pages/clients/FilterDrawer.tsx

@@ -50,9 +50,7 @@ export default function FilterDrawer({
   const inboundOptions = useMemo(
     () => inbounds.map((ib) => ({
       value: ib.id,
-      label: ib.remark
-        ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
-        : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
+      label: ib.tag ?? '',
     })),
     [inbounds],
   );

+ 2 - 2
frontend/src/pages/inbounds/AttachClientsModal.tsx

@@ -69,7 +69,7 @@ export default function AttachClientsModal({
     if (!source) return [];
     return (dbInbounds || [])
       .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
-      .map((ib) => ({ value: ib.id, label: `${ib.remark} (${ib.protocol}@${ib.port})` }));
+      .map((ib) => ({ value: ib.id, label: ib.tag ?? '' }));
   }, [dbInbounds, source]);
 
   const filteredRows = useMemo(() => {
@@ -150,7 +150,7 @@ export default function AttachClientsModal({
       }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })}
+      title={t('pages.inbounds.attachClientsTitle', { remark: source?.tag ?? '' })}
       width={680}
     >
       {messageContextHolder}

+ 1 - 1
frontend/src/pages/inbounds/DetachClientsModal.tsx

@@ -139,7 +139,7 @@ export default function DetachClientsModal({
       }}
       okText={t('pages.inbounds.detachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.detachClientsTitle', { remark: source?.remark ?? '' })}
+      title={t('pages.inbounds.detachClientsTitle', { remark: source?.tag ?? '' })}
       width={680}
     >
       {messageContextHolder}

+ 14 - 121
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -83,6 +83,7 @@ import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
 import DateTimePicker from '@/components/DateTimePicker';
 import FinalMaskForm from '@/components/FinalMaskForm';
 import HeaderMapEditor from '@/components/HeaderMapEditor';
+import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm';
 import InputAddon from '@/components/InputAddon';
 import JsonEditor from '@/components/JsonEditor';
 import './InboundFormModal.css';
@@ -931,14 +932,11 @@ export default function InboundFormModal({
             disabled={mode === 'edit'}
             placeholder={t('pages.inbounds.localPanel')}
             allowClear
-            options={[
-              { value: null, label: t('pages.inbounds.localPanel') },
-              ...selectableNodes.map((n) => ({
-                value: n.id,
-                label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
-                disabled: n.status === 'offline',
-              })),
-            ]}
+            options={selectableNodes.map((n) => ({
+              value: n.id,
+              label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
+              disabled: n.status === 'offline',
+            }))}
           />
         </Form.Item>
       )}
@@ -1498,16 +1496,15 @@ export default function InboundFormModal({
           {network === 'tcp' && (security === 'tls' || security === 'reality') && (
             <Form.Item
               label={t('pages.inbounds.form.visionTestseed')}
-              name={['settings', 'testseed']}
-              initialValue={[900, 500, 900, 256]}
-              normalize={(v: unknown) =>
-                Array.isArray(v)
-                  ? v.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0)
-                  : []
-              }
               extra="Applies only to clients using the xtls-rprx-vision flow; ignored otherwise."
             >
-              <Select mode="tags" tokenSeparators={[',', ' ']} placeholder="four positive integers" />
+              <Space.Compact block>
+                {[900, 500, 900, 256].map((def, i) => (
+                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
+                    <InputNumber min={1} style={{ width: '25%' }} />
+                  </Form.Item>
+                ))}
+              </Space.Compact>
             </Form.Item>
           )}
         </>
@@ -1610,111 +1607,7 @@ export default function InboundFormModal({
             <InputNumber min={1} style={{ width: '100%' }} />
           </Form.Item>
 
-          <Form.Item label={t('pages.inbounds.form.masquerade')}>
-            <Form.Item shouldUpdate noStyle>
-              {() => {
-                const m = form.getFieldValue([
-                  'streamSettings', 'hysteriaSettings', 'masquerade',
-                ]);
-                return (
-                  <Switch
-                    checked={!!m}
-                    onChange={(checked) =>
-                      form.setFieldValue(
-                        ['streamSettings', 'hysteriaSettings', 'masquerade'],
-                        checked
-                          ? {
-                            type: '', dir: '', url: '',
-                            rewriteHost: false, insecure: false,
-                            content: '', headers: {}, statusCode: 0,
-                          }
-                          : undefined,
-                      )
-                    }
-                  />
-                );
-              }}
-            </Form.Item>
-          </Form.Item>
-          <Form.Item shouldUpdate noStyle>
-            {() => {
-              const m = form.getFieldValue([
-                'streamSettings', 'hysteriaSettings', 'masquerade',
-              ]) as { type?: string } | undefined;
-              if (!m) return null;
-              return (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.type')}
-                    name={['streamSettings', 'hysteriaSettings', 'masquerade', 'type']}
-                  >
-                    <Select
-                      options={[
-                        { value: '', label: 'default (404 page)' },
-                        { value: 'proxy', label: 'proxy (reverse proxy)' },
-                        { value: 'file', label: 'file (serve directory)' },
-                        { value: 'string', label: 'string (fixed body)' },
-                      ]}
-                    />
-                  </Form.Item>
-                  {m.type === 'proxy' && (
-                    <>
-                      <Form.Item
-                        label={t('pages.inbounds.form.upstreamUrl')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'url']}
-                      >
-                        <Input placeholder="https://www.example.com" />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.rewriteHost')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'rewriteHost']}
-                        valuePropName="checked"
-                      >
-                        <Switch />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.skipTlsVerify')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'insecure']}
-                        valuePropName="checked"
-                      >
-                        <Switch />
-                      </Form.Item>
-                    </>
-                  )}
-                  {m.type === 'file' && (
-                    <Form.Item
-                      label={t('pages.inbounds.form.directory')}
-                      name={['streamSettings', 'hysteriaSettings', 'masquerade', 'dir']}
-                    >
-                      <Input placeholder="/var/www/html" />
-                    </Form.Item>
-                  )}
-                  {m.type === 'string' && (
-                    <>
-                      <Form.Item
-                        label={t('pages.inbounds.form.statusCode')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'statusCode']}
-                      >
-                        <InputNumber min={0} max={599} style={{ width: '100%' }} />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.body')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'content']}
-                      >
-                        <Input.TextArea autoSize={{ minRows: 3 }} />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.headers')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'headers']}
-                      >
-                        <HeaderMapEditor mode="v1" />
-                      </Form.Item>
-                    </>
-                  )}
-                </>
-              );
-            }}
-          </Form.Item>
+          <HysteriaMasqueradeForm form={form} />
         </>
       )}
 

+ 2 - 2
frontend/src/pages/inbounds/InboundInfoModal.tsx

@@ -480,7 +480,7 @@ export default function InboundInfoModal({
 
   if (!dbInbound || !inbound) {
     return (
-      <Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} />
+      <Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundInfo')} footer={null} width={640} />
     );
   }
 
@@ -1074,7 +1074,7 @@ export default function InboundInfoModal({
   tabItems.push({ key: 'inbound', label: t('pages.xray.rules.inbound'), children: inboundTab });
 
   return (
-    <Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} destroyOnHidden>
+    <Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundInfo')} footer={null} width={640} destroyOnHidden>
       <Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
     </Modal>
   );

+ 2 - 2
frontend/src/pages/inbounds/InboundList.tsx

@@ -255,7 +255,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
       });
     }
   } else {
-    items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('info') });
+    items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
   }
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
   items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
@@ -626,7 +626,7 @@ export default function InboundList({
                     <span className="card-id">#{record.id}</span>
                     <span className="tag-name">{record.remark}</span>
                     <div className="card-actions" onClick={(e) => e.stopPropagation()}>
-                      <Tooltip title={t('info')}>
+                      <Tooltip title={t('pages.inbounds.inboundInfo')}>
                         <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
                       </Tooltip>
                       <Switch

+ 56 - 59
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -17,6 +17,7 @@ import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@an
 
 import FinalMaskForm from '@/components/FinalMaskForm';
 import HeaderMapEditor from '@/components/HeaderMapEditor';
+import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm';
 import InputAddon from '@/components/InputAddon';
 import JsonEditor from '@/components/JsonEditor';
 import { Wireguard } from '@/utils';
@@ -107,9 +108,8 @@ const NETWORK_OPTIONS: { value: string; label: string }[] = [
   { value: 'xhttp', label: 'XHTTP' },
 ];
 
-// Hysteria appends an extra `hysteria` network branch to the selector
-// — only when the parent protocol is hysteria. Wire-side this matches
-// the legacy modal's `isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS`.
+// The hysteria protocol is locked to its own QUIC transport: the selector
+// shows only this option when the parent protocol is hysteria.
 const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
 
 // Per-network bootstrap. Mirrors the legacy class constructors so the
@@ -163,6 +163,19 @@ function newStreamSlice(network: string): Record<string, unknown> {
   }
 }
 
+// Hysteria2 always rides its own QUIC transport with TLS — the panel never
+// offers another transport or 'none' security for it.
+function hysteriaStreamSlice(): Record<string, unknown> {
+  return {
+    ...newStreamSlice('hysteria'),
+    security: 'tls',
+    tlsSettings: {
+      serverName: '', alpn: ['h3'], fingerprint: '',
+      echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
+    },
+  };
+}
+
 // Protocols whose form schema carries a flat connect target — these all
 // get the shared "server" sub-block (address + port) at the top of the
 // protocol section. Wireguard has an address but no port. DNS/freedom/
@@ -191,8 +204,8 @@ export default function OutboundFormModal({
   const [linkInput, setLinkInput] = useState('');
 
   // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
-  // hysteria2://) and replace form state with the result. The current
-  // tag is preserved when the parsed link doesn't carry one.
+  // hysteria2:// / wireguard://) and replace form state with the result.
+  // The current tag is preserved when the parsed link doesn't carry one.
   function importLink() {
     const link = linkInput.trim();
     if (!link) return;
@@ -233,23 +246,13 @@ export default function OutboundFormModal({
 
   const tag = Form.useWatch('tag', form) ?? '';
   const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
-  // preserve: true — without it useWatch only reflects values whose
-  // Form.Item is currently mounted. The streamSettings selectors live
-  // INSIDE `{streamAllowed && network && (...)}`, so the moment that
-  // conditional gates them out, useWatch returns undefined, the gate
-  // keeps returning false, and the stream block never renders even
-  // though streamSettings is in the form store.
   const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
   const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
-
   const streamAllowed = canEnableStream({ protocol });
   const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
   const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
   const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
 
-  // Seed streamSettings when the user picks a protocol that supports
-  // streams but the form does not yet have a stream slice (new outbound,
-  // or wire payload arrived without streamSettings).
   useEffect(() => {
     if (!streamAllowed) return;
     if (network) return;
@@ -257,9 +260,16 @@ export default function OutboundFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [streamAllowed, network]);
 
-  // Wireguard pubKey is a UI-only field derived from secretKey on every
-  // edit. The legacy modal did the same on every keystroke. We re-derive
-  // here so paste-in secret keys immediately surface the matching pub.
+  useEffect(() => {
+    if (protocol !== 'hysteria') return;
+    if (network === 'hysteria' && security === 'tls') return;
+    const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
+    const slice = hysteriaStreamSlice();
+    if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
+    if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
+    form.setFieldValue('streamSettings', slice);
+  }, [protocol, network, security]);
+
   const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
   useEffect(() => {
     if (protocol !== 'wireguard') return;
@@ -277,21 +287,18 @@ export default function OutboundFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [protocol, wgSecretKey]);
 
-  // Switching protocol resets the settings sub-object to fresh defaults
-  // so leftover fields from the previous protocol do not bleed through.
-  // The adapter's rawOutboundToFormValues seeds whatever the new protocol
-  // expects (vless flat shape, vmess flat shape, wireguard with secretKey
-  // placeholder, etc.).
   function onValuesChange(changed: Partial<OutboundFormValues>) {
     if ('protocol' in changed && changed.protocol) {
       const next = rawOutboundToFormValues({ protocol: changed.protocol });
       form.setFieldValue('settings', next.settings);
+      if (changed.protocol === 'hysteria') {
+        form.setFieldValue('streamSettings', hysteriaStreamSlice());
+      } else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
+        form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
+      }
     }
   }
 
-  // Security change cascade: swap the security sub-key so the DU branch
-  // matches. Seed default field values when entering tls/reality so the
-  // sub-forms render without `undefined` field references.
   function onSecurityChange(next: string) {
     const stream = form.getFieldValue('streamSettings') ?? {};
     const cleaned = { ...stream } as Record<string, unknown>;
@@ -324,6 +331,10 @@ export default function OutboundFormModal({
   // wsSettings, etc.) so the DU branch matches. Preserve security if
   // the new network supports it, otherwise force back to 'none'.
   function onNetworkChange(next: string) {
+    if (next === 'hysteria') {
+      form.setFieldValue('streamSettings', hysteriaStreamSlice());
+      return;
+    }
     const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
     const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
     const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
@@ -372,13 +383,6 @@ export default function OutboundFormModal({
     return true;
   }
 
-  // Wrap every tab switch with a blur of the active element. AntD marks
-  // the outgoing panel `aria-hidden="true"` synchronously when the
-  // controlled activeKey flips; if a focused input is still inside that
-  // panel (e.g. Input.Search on the JSON tab after user hits Enter to
-  // import), Chrome logs a WAI-ARIA warning. Doing the blur right
-  // before setActiveKey ensures the panel is unfocused by the time
-  // AntD applies the attribute.
   function switchTab(key: string) {
     if (typeof document !== 'undefined') {
       (document.activeElement as HTMLElement | null)?.blur?.();
@@ -597,12 +601,6 @@ export default function OutboundFormModal({
                       </>
                     )}
 
-                    {protocol === 'hysteria' && (
-                      <Form.Item label={t('pages.inbounds.form.version')} name={['settings', 'version']}>
-                        <InputNumber min={2} max={2} disabled />
-                      </Form.Item>
-                    )}
-
                     {protocol === 'loopback' && (
                       <Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
                         <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
@@ -1155,7 +1153,7 @@ export default function OutboundFormModal({
                             onChange={onNetworkChange}
                             options={
                               protocol === 'hysteria'
-                                ? [...NETWORK_OPTIONS, HYSTERIA_NETWORK_OPTION]
+                                ? [HYSTERIA_NETWORK_OPTION]
                                 : NETWORK_OPTIONS
                             }
                           />
@@ -1721,6 +1719,12 @@ export default function OutboundFormModal({
 
                         {network === 'hysteria' && (
                           <>
+                            <Form.Item
+                              label={t('pages.inbounds.form.version')}
+                              name={['streamSettings', 'hysteriaSettings', 'version']}
+                            >
+                              <InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
+                            </Form.Item>
                             <Form.Item
                               label={t('pages.xray.outboundForm.authPassword')}
                               name={['streamSettings', 'hysteriaSettings', 'auth']}
@@ -1733,6 +1737,7 @@ export default function OutboundFormModal({
                             >
                               <InputNumber min={1} style={{ width: '100%' }} />
                             </Form.Item>
+                            <HysteriaMasqueradeForm form={form} />
                           </>
                         )}
                       </>
@@ -1743,7 +1748,7 @@ export default function OutboundFormModal({
                         <Select
                           allowClear
                           placeholder={t('none')}
-                          options={FLOW_OPTIONS}
+                          options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
                         />
                       </Form.Item>
                     )}
@@ -1762,22 +1767,14 @@ export default function OutboundFormModal({
                             <Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
                               <InputNumber min={0} style={{ width: '100%' }} />
                             </Form.Item>
-                            <Form.Item
-                              label={t('pages.inbounds.form.visionTestseed')}
-                              name={['settings', 'testseed']}
-                              normalize={(v: unknown) =>
-                                Array.isArray(v)
-                                  ? v
-                                    .map((x) => Number(x))
-                                    .filter((n) => Number.isInteger(n) && n > 0)
-                                  : []
-                              }
-                            >
-                              <Select
-                                mode="tags"
-                                tokenSeparators={[',', ' ']}
-                                placeholder="four positive integers"
-                              />
+                            <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
+                              <Space.Compact block>
+                                {[900, 500, 900, 256].map((def, i) => (
+                                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
+                                    <InputNumber min={1} style={{ width: '25%' }} />
+                                  </Form.Item>
+                                ))}
+                              </Space.Compact>
                             </Form.Item>
                           </>
                         );
@@ -1791,7 +1788,7 @@ export default function OutboundFormModal({
                           buttonStyle="solid"
                           onChange={(e) => onSecurityChange(e.target.value as string)}
                         >
-                          <Radio.Button value="none">{t('none')}</Radio.Button>
+                          {network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
                           {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
                           {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
                         </Radio.Group>
@@ -2215,7 +2212,7 @@ export default function OutboundFormModal({
                   <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
                     <Input.Search
                       value={linkInput}
-                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
+                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
                       enterButton="Import"
                       onChange={(e) => setLinkInput(e.target.value)}
                       onSearch={importLink}

+ 2 - 1
frontend/src/pages/xray/OutboundsTab.tsx

@@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
 import { SizeFormatter } from '@/utils';
 import { OutboundProtocols as Protocols } from '@/schemas/primitives';
 import OutboundFormModal from './OutboundFormModal';
+import { isUdpOutbound } from '@/hooks/useXraySetting';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 
@@ -361,7 +362,7 @@ export default function OutboundsTab({
         align: 'center',
         width: 80,
         render: (_v, record, index) => (
-          <Tooltip title={`${t('check')} (${testMode.toUpperCase()})`}>
+          <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
             <Button
               type="primary"
               shape="circle"

+ 1 - 0
frontend/src/schemas/client.ts

@@ -39,6 +39,7 @@ export const ClientRecordSchema = z.object({
 export const InboundOptionSchema = z.object({
   id: z.number(),
   remark: z.string().optional(),
+  tag: z.string().optional(),
   protocol: z.string().optional(),
   port: z.number().optional(),
   tlsFlowCapable: z.boolean().optional(),

+ 60 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -7,6 +7,7 @@ import {
   parseVlessLink,
   parseVmessLink,
   parseHysteria2Link,
+  parseWireguardLink,
 } from '@/lib/xray/outbound-link-parser';
 import { Base64 } from '@/utils';
 
@@ -204,6 +205,18 @@ describe('parseShadowsocksLink', () => {
     expect(settings.servers[0].password).toBe('supersecret');
   });
 
+  it('keeps the port when the link carries a query string (2022 two-key password)', () => {
+    const link = 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53';
+    const out = parseShadowsocksLink(link);
+    expect(out?.protocol).toBe('shadowsocks');
+    expect(out?.tag).toBe('pahf4urt53');
+    const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
+    expect(settings.servers[0].address).toBe('localhost');
+    expect(settings.servers[0].port).toBe(30757);
+    expect(settings.servers[0].method).toBe('2022-blake3-aes-256-gcm');
+    expect(settings.servers[0].password).toBe('/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI=');
+  });
+
   it('parses the legacy base64-of-whole form', () => {
     // ss://base64(method:password@host:port)#remark
     const inner = Base64.encode('aes-256-gcm:[email protected]:1080');
@@ -306,6 +319,49 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
   });
 });
 
+describe('parseWireguardLink', () => {
+  it('parses a wireguard:// link with percent-encoded secret and publickey', () => {
+    const link = 'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824'
+      + '?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D'
+      + '&address=10.0.0.2%2F32&mtu=1420#-1';
+    const out = parseWireguardLink(link);
+    expect(out?.protocol).toBe('wireguard');
+    expect(out?.tag).toBe('-1');
+    const settings = out?.settings as {
+      secretKey: string; address: string[]; mtu: number;
+      peers: Array<{ publicKey: string; endpoint: string; allowedIPs: string[] }>;
+    };
+    expect(settings.secretKey).toBe('IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U=');
+    expect(settings.address).toEqual(['10.0.0.2/32']);
+    expect(settings.mtu).toBe(1420);
+    expect(settings.peers[0].publicKey).toBe('3CnNsCy74TOlupjaii+RFp/gDMk5vvUuFD0SNZ/Gl2s=');
+    expect(settings.peers[0].endpoint).toBe('localhost:22824');
+    expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0', '::/0']);
+  });
+
+  it('parses reserved, presharedkey and keepalive aliases', () => {
+    const link = 'wireguard://[email protected]:51820'
+      + '?publickey=peerpub&address=10.0.0.2/32,fd00::2/128'
+      + '&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25'
+      + '&allowedips=0.0.0.0/0#wg-peer';
+    const out = parseWireguardLink(link);
+    const settings = out?.settings as {
+      reserved: number[];
+      peers: Array<{ preSharedKey: string; keepAlive: number; allowedIPs: string[] }>;
+      address: string[];
+    };
+    expect(settings.address).toEqual(['10.0.0.2/32', 'fd00::2/128']);
+    expect(settings.reserved).toEqual([1, 2, 3]);
+    expect(settings.peers[0].preSharedKey).toBe('psk-secret');
+    expect(settings.peers[0].keepAlive).toBe(25);
+    expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0']);
+  });
+
+  it('returns null for non-wireguard links', () => {
+    expect(parseWireguardLink('vless://x@y:1')).toBeNull();
+  });
+});
+
 describe('parseOutboundLink dispatcher', () => {
   it('dispatches vmess via base64 JSON', () => {
     const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };
@@ -317,6 +373,10 @@ describe('parseOutboundLink dispatcher', () => {
     expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless');
   });
 
+  it('dispatches wireguard via URL', () => {
+    expect(parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol).toBe('wireguard');
+  });
+
   it('returns null for an unknown scheme', () => {
     expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
   });

+ 26 - 71
web/service/outbound.go

@@ -151,6 +151,14 @@ type TestEndpointResult struct {
 // sockopt.dialerProxy chains during test).
 func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
 	if mode == "tcp" {
+		// A bare TCP dial only proves reachability for TCP-based proxies.
+		// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
+		// unauthenticated packets, so a raw dial can't tell "reachable" from
+		// "dead" — route them through the authoritative xray handshake probe.
+		var ob map[string]any
+		if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) {
+			return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
+		}
 		return s.testOutboundTCP(outboundJSON)
 	}
 	return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
@@ -178,7 +186,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		wg.Add(1)
 		go func(i int) {
 			defer wg.Done()
-			results[i] = probeEndpoint(endpoints[i], 5*time.Second)
+			results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
 		}(i)
 	}
 	wg.Wait()
@@ -195,11 +203,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 		}
 	}
 
-	mode := "tcp"
-	if endpoints[0].Network == "udp" {
-		mode = "udp"
-	}
-	out := &TestOutboundResult{Mode: mode, Endpoints: results}
+	out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
 	if bestDelay >= 0 {
 		out.Success = true
 		out.Delay = bestDelay
@@ -212,22 +216,6 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
 	return out, nil
 }
 
-// outboundEndpoint is a host:port plus the transport its proxy actually
-// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
-// TCP dial to its peer endpoint always times out — the probe must match
-// the transport of the outbound being tested.
-type outboundEndpoint struct {
-	Address string
-	Network string
-}
-
-func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
-	if ep.Network == "udp" {
-		return probeUDPEndpoint(ep.Address, timeout)
-	}
-	return probeTCPEndpoint(ep.Address, timeout)
-}
-
 func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
 	r := TestEndpointResult{Address: endpoint}
 	start := time.Now()
@@ -242,69 +230,36 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 	return r
 }
 
-// probeUDPEndpoint sends a single byte and waits briefly for a reply or
-// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
-// so a read timeout is the normal "endpoint reachable" outcome; a
-// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
-func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
-	r := TestEndpointResult{Address: endpoint}
-	start := time.Now()
-	conn, err := net.DialTimeout("udp", endpoint, timeout)
-	if err != nil {
-		r.Delay = time.Since(start).Milliseconds()
-		r.Error = err.Error()
-		return r
-	}
-	defer conn.Close()
-
-	if _, werr := conn.Write([]byte{0}); werr != nil {
-		r.Delay = time.Since(start).Milliseconds()
-		r.Error = werr.Error()
-		return r
+// outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
+// (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
+// UDP dial can't probe these — they ignore unauthenticated packets, so a
+// dial neither proves reachability nor measures latency. Such outbounds
+// must go through the real xray handshake probe instead.
+func outboundTransportIsUDP(ob map[string]any) bool {
+	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
+		return true
 	}
-
-	_ = conn.SetReadDeadline(time.Now().Add(timeout))
-	buf := make([]byte, 64)
-	_, rerr := conn.Read(buf)
-	r.Delay = time.Since(start).Milliseconds()
-	if rerr != nil {
-		if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
-			r.Success = true
-			return r
+	if stream, ok := ob["streamSettings"].(map[string]any); ok {
+		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+			return true
 		}
-		r.Error = rerr.Error()
-		return r
 	}
-	r.Success = true
-	return r
+	return false
 }
 
-func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
+func extractOutboundEndpoints(ob map[string]any) []string {
 	protocol, _ := ob["protocol"].(string)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 		return nil
 	}
 
-	// Hysteria is QUIC/UDP — detect via the outer protocol or via
-	// streamSettings.network so a trojan-with-hysteria transport gets
-	// probed over UDP too. kcp and quic are also UDP-based.
-	network := "tcp"
-	if protocol == "hysteria" || protocol == "wireguard" {
-		network = "udp"
-	}
-	if stream, ok := ob["streamSettings"].(map[string]any); ok {
-		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
-			network = "udp"
-		}
-	}
-
-	var out []outboundEndpoint
+	var out []string
 	addServer := func(addr any, port any) {
 		host, _ := addr.(string)
 		p := numAsInt(port)
 		if host != "" && p > 0 {
-			out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
+			out = append(out, fmt.Sprintf("%s:%d", host, p))
 		}
 	}
 	switch protocol {
@@ -333,7 +288,7 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
 			for _, p := range peers {
 				if pm, ok := p.(map[string]any); ok {
 					if ep, _ := pm["endpoint"].(string); ep != "" {
-						out = append(out, outboundEndpoint{Address: ep, Network: network})
+						out = append(out, ep)
 					}
 				}
 			}

+ 5 - 5
web/translation/ar-EG.json

@@ -400,7 +400,7 @@
       "telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
       "subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
       "same": "نفسه",
-      "inboundData": "بيانات الإدخال",
+      "inboundInfo": "معلومات الإدخال",
       "exportInbound": "تصدير الإدخال",
       "import": "استيراد",
       "importInbound": "استيراد إدخال",
@@ -652,12 +652,12 @@
       "comment": "ملاحظة",
       "traffic": "حركة المرور",
       "offline": "غير متصل",
-      "addTitle": "إضافة عميل",
+      "addClient": "إضافة عميل",
       "qrCode": "رمز QR",
-      "moreInformation": "مزيد من المعلومات",
+      "clientInfo": "معلومات العميل",
       "delete": "حذف",
       "reset": "إعادة ضبط حركة المرور",
-      "editTitle": "تعديل العميل",
+      "editClient": "تعديل العميل",
       "client": "العميل",
       "enabled": "مفعّل",
       "remaining": "المتبقي",
@@ -679,7 +679,7 @@
       "subLinksSelected": "روابط الاشتراك ({count})",
       "addToGroupTitle": "إضافة {count} عميل إلى مجموعة",
       "addToGroupTooltip": "اختر مجموعة موجودة أو أدخل اسماً جديداً. استخدم Ungroup لإزالة العملاء من مجموعتهم الحالية.",
-      "addToGroupPlaceholder": "اسم المجموعة",
+      "groupName": "اسم المجموعة",
       "addToGroupSuccessToast": "تمت إضافة {count} عميل إلى {group}",
       "ungroupSuccessToast": "تم مسح المجموعة من {count} عميل",
       "ungroup": "إزالة من المجموعة",

+ 5 - 5
web/translation/en-US.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
       "subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
       "same": "Same",
-      "inboundData": "Inbound's Data",
+      "inboundInfo": "Inbound Information",
       "exportInbound": "Export Inbound",
       "import": "Import",
       "importInbound": "Import an Inbound",
@@ -652,12 +652,12 @@
       "comment": "Comment",
       "traffic": "Traffic",
       "offline": "Offline",
-      "addTitle": "Add Client",
+      "addClient": "Add Client",
       "qrCode": "QR Code",
-      "moreInformation": "More Information",
+      "clientInfo": "Client Information",
       "delete": "Delete",
       "reset": "Reset Traffic",
-      "editTitle": "Edit Client",
+      "editClient": "Edit Client",
       "client": "Client",
       "enabled": "Enabled",
       "remaining": "Remaining",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Sub links ({count})",
       "addToGroupTitle": "Add {count} client(s) to a group",
       "addToGroupTooltip": "Pick an existing group or type a new name. Use the Ungroup action to remove clients from their current group.",
-      "addToGroupPlaceholder": "Group name",
+      "groupName": "Group name",
       "addToGroupSuccessToast": "Added {count} client(s) to {group}",
       "ungroupSuccessToast": "Cleared group from {count} client(s)",
       "ungroup": "Ungroup",

+ 5 - 5
web/translation/es-ES.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
       "subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
       "same": "misma",
-      "inboundData": "Datos de entrada",
+      "inboundInfo": "Información de entrada",
       "exportInbound": "Exportación entrante",
       "import": "Importar",
       "importInbound": "Importar un entrante",
@@ -652,12 +652,12 @@
       "comment": "Comentario",
       "traffic": "Tráfico",
       "offline": "Sin conexión",
-      "addTitle": "Añadir cliente",
+      "addClient": "Añadir cliente",
       "qrCode": "Código QR",
-      "moreInformation": "Más información",
+      "clientInfo": "Información del cliente",
       "delete": "Eliminar",
       "reset": "Restablecer tráfico",
-      "editTitle": "Editar cliente",
+      "editClient": "Editar cliente",
       "client": "Cliente",
       "enabled": "Habilitado",
       "remaining": "Restante",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Enlaces sub ({count})",
       "addToGroupTitle": "Añadir {count} cliente(s) a un grupo",
       "addToGroupTooltip": "Selecciona un grupo existente o escribe un nombre nuevo. Usa Ungroup para quitar clientes de su grupo actual.",
-      "addToGroupPlaceholder": "Nombre del grupo",
+      "groupName": "Nombre del grupo",
       "addToGroupSuccessToast": "Se añadieron {count} cliente(s) a {group}",
       "ungroupSuccessToast": "Grupo limpiado de {count} cliente(s)",
       "ungroup": "Desagrupar",

+ 5 - 5
web/translation/fa-IR.json

@@ -400,7 +400,7 @@
       "telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
       "subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
       "same": "همسان",
-      "inboundData": "داده‌های ورودی",
+      "inboundInfo": "اطلاعات ورودی",
       "exportInbound": "استخراج ورودی",
       "import": "افزودن",
       "importInbound": "افزودن یک ورودی",
@@ -652,12 +652,12 @@
       "comment": "توضیحات",
       "traffic": "ترافیک",
       "offline": "آفلاین",
-      "addTitle": "افزودن کلاینت",
+      "addClient": "افزودن کلاینت",
       "qrCode": "کد QR",
-      "moreInformation": "اطلاعات بیشتر",
+      "clientInfo": "اطلاعات کلاینت",
       "delete": "حذف",
       "reset": "بازنشانی ترافیک",
-      "editTitle": "ویرایش کلاینت",
+      "editClient": "ویرایش کلاینت",
       "client": "کلاینت",
       "enabled": "فعال",
       "remaining": "باقی‌مانده",
@@ -679,7 +679,7 @@
       "subLinksSelected": "لینک‌های اشتراک ({count})",
       "addToGroupTitle": "افزودن {count} کاربر به یک گروه",
       "addToGroupTooltip": "یک گروه موجود را انتخاب کنید یا نام جدیدی تایپ کنید. برای حذف کاربران از گروه فعلی، از Ungroup استفاده کنید.",
-      "addToGroupPlaceholder": "نام گروه",
+      "groupName": "نام گروه",
       "addToGroupSuccessToast": "{count} کاربر به {group} اضافه شد",
       "ungroupSuccessToast": "گروه از {count} کاربر پاک شد",
       "ungroup": "خارج از گروه",

+ 5 - 5
web/translation/id-ID.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
       "subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
       "same": "Sama",
-      "inboundData": "Data Masuk",
+      "inboundInfo": "Informasi Inbound",
       "exportInbound": "Ekspor Masuk",
       "import": "Impor",
       "importInbound": "Impor Masuk",
@@ -652,12 +652,12 @@
       "comment": "Komentar",
       "traffic": "Lalu lintas",
       "offline": "Offline",
-      "addTitle": "Tambah klien",
+      "addClient": "Tambah klien",
       "qrCode": "Kode QR",
-      "moreInformation": "Informasi lebih lanjut",
+      "clientInfo": "Informasi Klien",
       "delete": "Hapus",
       "reset": "Reset lalu lintas",
-      "editTitle": "Ubah klien",
+      "editClient": "Ubah klien",
       "client": "Klien",
       "enabled": "Aktif",
       "remaining": "Sisa",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Tautan sub ({count})",
       "addToGroupTitle": "Tambahkan {count} klien ke grup",
       "addToGroupTooltip": "Pilih grup yang ada atau ketik nama baru. Gunakan Ungroup untuk menghapus klien dari grup saat ini.",
-      "addToGroupPlaceholder": "Nama grup",
+      "groupName": "Nama grup",
       "addToGroupSuccessToast": "{count} klien ditambahkan ke {group}",
       "ungroupSuccessToast": "Grup dihapus dari {count} klien",
       "ungroup": "Lepaskan grup",

+ 5 - 5
web/translation/ja-JP.json

@@ -400,7 +400,7 @@
       "telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)",
       "subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
       "same": "同じ",
-      "inboundData": "インバウンドデータ",
+      "inboundInfo": "インバウンド情報",
       "exportInbound": "インバウンドルールをエクスポート",
       "import": "インポート",
       "importInbound": "インバウンドルールをインポート",
@@ -652,12 +652,12 @@
       "comment": "コメント",
       "traffic": "トラフィック",
       "offline": "オフライン",
-      "addTitle": "クライアントを追加",
+      "addClient": "クライアントを追加",
       "qrCode": "QR コード",
-      "moreInformation": "詳細情報",
+      "clientInfo": "クライアント情報",
       "delete": "削除",
       "reset": "トラフィックをリセット",
-      "editTitle": "クライアントを編集",
+      "editClient": "クライアントを編集",
       "client": "クライアント",
       "enabled": "有効",
       "remaining": "残量",
@@ -679,7 +679,7 @@
       "subLinksSelected": "サブリンク ({count})",
       "addToGroupTitle": "{count} クライアントをグループに追加",
       "addToGroupTooltip": "既存のグループを選ぶか新しい名前を入力してください。Ungroup で現在のグループから外せます。",
-      "addToGroupPlaceholder": "グループ名",
+      "groupName": "グループ名",
       "addToGroupSuccessToast": "{count} クライアントを {group} に追加しました",
       "ungroupSuccessToast": "{count} クライアントのグループをクリアしました",
       "ungroup": "グループ解除",

+ 5 - 5
web/translation/pt-BR.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
       "subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
       "same": "Igual",
-      "inboundData": "Dados do Inbound",
+      "inboundInfo": "Informações do Inbound",
       "exportInbound": "Exportar Inbound",
       "import": "Importar",
       "importInbound": "Importar um Inbound",
@@ -652,12 +652,12 @@
       "comment": "Comentário",
       "traffic": "Tráfego",
       "offline": "Offline",
-      "addTitle": "Adicionar cliente",
+      "addClient": "Adicionar cliente",
       "qrCode": "Código QR",
-      "moreInformation": "Mais informações",
+      "clientInfo": "Informações do cliente",
       "delete": "Excluir",
       "reset": "Redefinir tráfego",
-      "editTitle": "Editar cliente",
+      "editClient": "Editar cliente",
       "client": "Cliente",
       "enabled": "Habilitado",
       "remaining": "Restante",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Links sub ({count})",
       "addToGroupTitle": "Adicionar {count} cliente(s) a um grupo",
       "addToGroupTooltip": "Escolha um grupo existente ou digite um novo nome. Use Ungroup para remover clientes do grupo atual.",
-      "addToGroupPlaceholder": "Nome do grupo",
+      "groupName": "Nome do grupo",
       "addToGroupSuccessToast": "{count} cliente(s) adicionado(s) a {group}",
       "ungroupSuccessToast": "Grupo limpo de {count} cliente(s)",
       "ungroup": "Desagrupar",

+ 5 - 5
web/translation/ru-RU.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
       "subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
       "same": "Тот же",
-      "inboundData": "Данные подключений",
+      "inboundInfo": "Информация о подключении",
       "exportInbound": "Экспорт подключений",
       "import": "Импортировать",
       "importInbound": "Импорт подключений",
@@ -652,12 +652,12 @@
       "comment": "Комментарий",
       "traffic": "Трафик",
       "offline": "Не в сети",
-      "addTitle": "Добавить клиента",
+      "addClient": "Добавить клиента",
       "qrCode": "QR-код",
-      "moreInformation": "Подробнее",
+      "clientInfo": "Информация о клиенте",
       "delete": "Удалить",
       "reset": "Сбросить трафик",
-      "editTitle": "Изменить клиента",
+      "editClient": "Изменить клиента",
       "client": "Клиент",
       "enabled": "Включён",
       "remaining": "Остаток",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Sub-ссылки ({count})",
       "addToGroupTitle": "Добавить {count} клиент(ов) в группу",
       "addToGroupTooltip": "Выберите существующую группу или введите новое имя. Используйте Ungroup, чтобы удалить клиентов из их текущей группы.",
-      "addToGroupPlaceholder": "Имя группы",
+      "groupName": "Имя группы",
       "addToGroupSuccessToast": "{count} клиент(ов) добавлено в {group}",
       "ungroupSuccessToast": "Группа очищена у {count} клиент(ов)",
       "ungroup": "Разгруппировать",

+ 5 - 5
web/translation/tr-TR.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya ({'@'}userinfobot)",
       "subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz.",
       "same": "Aynı",
-      "inboundData": "Gelenin Verileri",
+      "inboundInfo": "Gelen Bilgileri",
       "exportInbound": "Geleni Dışa Aktar",
       "import": "İçe Aktar",
       "importInbound": "Bir Gelen İçe Aktar",
@@ -652,12 +652,12 @@
       "comment": "Yorum",
       "traffic": "Trafik",
       "offline": "Çevrimdışı",
-      "addTitle": "İstemci ekle",
+      "addClient": "İstemci ekle",
       "qrCode": "QR kodu",
-      "moreInformation": "Daha fazla bilgi",
+      "clientInfo": "İstemci Bilgileri",
       "delete": "Sil",
       "reset": "Trafiği sıfırla",
-      "editTitle": "İstemciyi düzenle",
+      "editClient": "İstemciyi düzenle",
       "client": "İstemci",
       "enabled": "Etkin",
       "remaining": "Kalan",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Abonelik bağlantıları ({count})",
       "addToGroupTitle": "{count} istemciyi bir gruba ekle",
       "addToGroupTooltip": "Mevcut bir grubu seçin veya yeni ad girin. İstemcileri mevcut gruplarından çıkarmak için Ungroup'u kullanın.",
-      "addToGroupPlaceholder": "Grup adı",
+      "groupName": "Grup adı",
       "addToGroupSuccessToast": "{count} istemci {group} grubuna eklendi",
       "ungroupSuccessToast": "{count} istemcinin grubu temizlendi",
       "ungroup": "Gruptan çıkar",

+ 5 - 5
web/translation/uk-UA.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
       "subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
       "same": "Те саме",
-      "inboundData": "Вхідні дані",
+      "inboundInfo": "Інформація про підключення",
       "exportInbound": "Експортувати вхідні",
       "import": "Імпорт",
       "importInbound": "Імпортувати вхідний",
@@ -652,12 +652,12 @@
       "comment": "Коментар",
       "traffic": "Трафік",
       "offline": "Не в мережі",
-      "addTitle": "Додати клієнта",
+      "addClient": "Додати клієнта",
       "qrCode": "QR-код",
-      "moreInformation": "Докладніше",
+      "clientInfo": "Інформація про клієнта",
       "delete": "Видалити",
       "reset": "Скинути трафік",
-      "editTitle": "Редагувати клієнта",
+      "editClient": "Редагувати клієнта",
       "client": "Клієнт",
       "enabled": "Увімкнено",
       "remaining": "Залишок",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Sub-посилання ({count})",
       "addToGroupTitle": "Додати {count} клієнт(ів) до групи",
       "addToGroupTooltip": "Виберіть існуючу групу або введіть нову назву. Використовуйте Ungroup, щоб вилучити клієнтів із поточної групи.",
-      "addToGroupPlaceholder": "Назва групи",
+      "groupName": "Назва групи",
       "addToGroupSuccessToast": "{count} клієнт(ів) додано до {group}",
       "ungroupSuccessToast": "Групу очищено у {count} клієнт(ів)",
       "ungroup": "Розгрупувати",

+ 5 - 5
web/translation/vi-VN.json

@@ -400,7 +400,7 @@
       "telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
       "subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
       "same": "Giống nhau",
-      "inboundData": "Dữ liệu gửi đến",
+      "inboundInfo": "Thông tin Inbound",
       "exportInbound": "Xuất nhập khẩu",
       "import": "Nhập",
       "importInbound": "Nhập inbound",
@@ -652,12 +652,12 @@
       "comment": "Ghi chú",
       "traffic": "Lưu lượng",
       "offline": "Ngoại tuyến",
-      "addTitle": "Thêm khách hàng",
+      "addClient": "Thêm khách hàng",
       "qrCode": "Mã QR",
-      "moreInformation": "Thông tin thêm",
+      "clientInfo": "Thông tin khách hàng",
       "delete": "Xóa",
       "reset": "Đặt lại lưu lượng",
-      "editTitle": "Chỉnh sửa khách hàng",
+      "editClient": "Chỉnh sửa khách hàng",
       "client": "Khách hàng",
       "enabled": "Đã bật",
       "remaining": "Còn lại",
@@ -679,7 +679,7 @@
       "subLinksSelected": "Liên kết sub ({count})",
       "addToGroupTitle": "Thêm {count} client vào một nhóm",
       "addToGroupTooltip": "Chọn nhóm có sẵn hoặc nhập tên mới. Dùng Ungroup để xóa client khỏi nhóm hiện tại.",
-      "addToGroupPlaceholder": "Tên nhóm",
+      "groupName": "Tên nhóm",
       "addToGroupSuccessToast": "Đã thêm {count} client vào {group}",
       "ungroupSuccessToast": "Đã xóa nhóm khỏi {count} client",
       "ungroup": "Bỏ nhóm",

+ 5 - 5
web/translation/zh-CN.json

@@ -400,7 +400,7 @@
       "telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
       "same": "相同",
-      "inboundData": "入站数据",
+      "inboundInfo": "入站信息",
       "exportInbound": "导出入站规则",
       "import": "导入",
       "importInbound": "导入入站规则",
@@ -652,12 +652,12 @@
       "comment": "备注",
       "traffic": "流量",
       "offline": "离线",
-      "addTitle": "添加客户端",
+      "addClient": "添加客户端",
       "qrCode": "二维码",
-      "moreInformation": "更多信息",
+      "clientInfo": "客户端信息",
       "delete": "删除",
       "reset": "重置流量",
-      "editTitle": "编辑客户端",
+      "editClient": "编辑客户端",
       "client": "客户端",
       "enabled": "已启用",
       "remaining": "剩余",
@@ -679,7 +679,7 @@
       "subLinksSelected": "订阅链接 ({count})",
       "addToGroupTitle": "将 {count} 个客户端添加到分组",
       "addToGroupTooltip": "选择现有分组或输入新名称。使用 Ungroup 操作从当前分组移除客户端。",
-      "addToGroupPlaceholder": "分组名称",
+      "groupName": "分组名称",
       "addToGroupSuccessToast": "已将 {count} 个客户端添加到 {group}",
       "ungroupSuccessToast": "已清除 {count} 个客户端的分组",
       "ungroup": "取消分组",

+ 5 - 5
web/translation/zh-TW.json

@@ -400,7 +400,7 @@
       "telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
       "subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
       "same": "相同",
-      "inboundData": "入站資料",
+      "inboundInfo": "入站資訊",
       "exportInbound": "匯出入站規則",
       "import": "匯入",
       "importInbound": "匯入入站規則",
@@ -652,12 +652,12 @@
       "comment": "備註",
       "traffic": "流量",
       "offline": "離線",
-      "addTitle": "新增客戶端",
+      "addClient": "新增客戶端",
       "qrCode": "QR 碼",
-      "moreInformation": "更多資訊",
+      "clientInfo": "客戶端資訊",
       "delete": "刪除",
       "reset": "重設流量",
-      "editTitle": "編輯客戶端",
+      "editClient": "編輯客戶端",
       "client": "客戶端",
       "enabled": "已啟用",
       "remaining": "剩餘",
@@ -679,7 +679,7 @@
       "subLinksSelected": "訂閱連結 ({count})",
       "addToGroupTitle": "將 {count} 個客戶端加入群組",
       "addToGroupTooltip": "選擇現有群組或輸入新名稱。使用 Ungroup 操作從當前群組移除客戶端。",
-      "addToGroupPlaceholder": "群組名稱",
+      "groupName": "群組名稱",
       "addToGroupSuccessToast": "已將 {count} 個客戶端加入 {group}",
       "ungroupSuccessToast": "已清除 {count} 個客戶端的群組",
       "ungroup": "取消群組",