5 Komitmen 0e0e41197f ... 7ae3ea66d1

Pembuat SHA1 Pesan Tanggal
  MHSanaei 7ae3ea66d1 feat(ui): improve client form modal UX 3 hari lalu
  MHSanaei 253063b785 feat: filter inbounds and clients by node (#4997) 3 hari lalu
  MHSanaei d04cb10971 feat(wireguard): per-peer comments for identifying devices (#5168) 3 hari lalu
  MHSanaei d1a13844b2 feat(api): include consumed traffic in the client-get response (#4973) 3 hari lalu
  MHSanaei bade1fcef6 feat(ui): allow custom fragment packets ranges, not just presets (#5075) 3 hari lalu
33 mengubah file dengan 390 tambahan dan 135 penghapusan
  1. 6 0
      frontend/public/openapi.json
  2. 1 0
      frontend/src/generated/examples.ts
  3. 5 0
      frontend/src/generated/schemas.ts
  4. 1 0
      frontend/src/generated/types.ts
  5. 1 0
      frontend/src/generated/zod.ts
  6. 18 3
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  7. 11 4
      frontend/src/lib/xray/inbound-link.ts
  8. 0 4
      frontend/src/pages/clients/BulkAddToGroupModal.tsx
  9. 0 4
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  10. 45 40
      frontend/src/pages/clients/ClientFormModal.tsx
  11. 26 2
      frontend/src/pages/clients/ClientsPage.tsx
  12. 30 0
      frontend/src/pages/clients/FilterDrawer.tsx
  13. 5 0
      frontend/src/pages/clients/filters.ts
  14. 9 0
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  15. 41 8
      frontend/src/pages/inbounds/list/InboundList.tsx
  16. 15 3
      frontend/src/pages/xray/outbounds/protocols/freedom.tsx
  17. 2 0
      frontend/src/schemas/client.ts
  18. 4 0
      frontend/src/schemas/protocols/inbound/wireguard.ts
  19. 8 1
      internal/web/controller/client.go
  20. 6 1
      internal/web/service/inbound.go
  21. 12 5
      internal/web/translation/ar-EG.json
  22. 12 5
      internal/web/translation/en-US.json
  23. 12 5
      internal/web/translation/es-ES.json
  24. 12 5
      internal/web/translation/fa-IR.json
  25. 12 5
      internal/web/translation/id-ID.json
  26. 12 5
      internal/web/translation/ja-JP.json
  27. 12 5
      internal/web/translation/pt-BR.json
  28. 12 5
      internal/web/translation/ru-RU.json
  29. 12 5
      internal/web/translation/tr-TR.json
  30. 12 5
      internal/web/translation/uk-UA.json
  31. 12 5
      internal/web/translation/vi-VN.json
  32. 12 5
      internal/web/translation/zh-CN.json
  33. 12 5
      internal/web/translation/zh-TW.json

+ 6 - 0
frontend/public/openapi.json

@@ -1459,6 +1459,11 @@
             "example": 1,
             "type": "integer"
           },
+          "nodeId": {
+            "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
+            "nullable": true,
+            "type": "integer"
+          },
           "port": {
             "example": 443,
             "type": "integer"
@@ -2244,6 +2249,7 @@
                   "obj": [
                     {
                       "id": 1,
+                      "nodeId": null,
                       "port": 443,
                       "protocol": "vless",
                       "remark": "VLESS-443",

+ 1 - 0
frontend/src/generated/examples.ts

@@ -315,6 +315,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "InboundOption": {
     "id": 1,
+    "nodeId": null,
     "port": 443,
     "protocol": "vless",
     "remark": "VLESS-443",

+ 5 - 0
frontend/src/generated/schemas.ts

@@ -1433,6 +1433,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 1,
         "type": "integer"
       },
+      "nodeId": {
+        "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
+        "nullable": true,
+        "type": "integer"
+      },
       "port": {
         "example": 443,
         "type": "integer"

+ 1 - 0
frontend/src/generated/types.ts

@@ -319,6 +319,7 @@ export interface InboundFallback {
 
 export interface InboundOption {
   id: number;
+  nodeId?: number | null;
   port: number;
   protocol: string;
   remark: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -343,6 +343,7 @@ export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 
 export const InboundOptionSchema = z.object({
   id: z.number().int(),
+  nodeId: z.number().int().nullable().optional(),
   port: z.number().int(),
   protocol: z.string(),
   remark: z.string(),

+ 18 - 3
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -1,4 +1,4 @@
-import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import type { FormInstance } from 'antd/es/form';
 import type { NamePath } from 'antd/es/form/interface';
@@ -205,13 +205,18 @@ function TcpMaskItem({
           if (type === 'fragment') {
             return (
               <>
-                <Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}>
-                  <Select
+                <Form.Item
+                  label="Packets"
+                  name={[fieldName, 'settings', 'packets']}
+                  rules={[{ validator: validateFragmentPackets }]}
+                >
+                  <AutoComplete
                     options={[
                       { value: 'tlshello', label: 'tlshello' },
                       { value: '1-3', label: '1-3' },
                       { value: '1-5', label: '1-5' },
                     ]}
+                    placeholder="tlshello or n-m, e.g. 1-3"
                   />
                 </Form.Item>
                 <Form.Item
@@ -264,6 +269,16 @@ function TcpMaskItem({
   );
 }
 
+// xray's fragment `packets` accepts "tlshello" or an arbitrary packet-number
+// range like "1-3" (#5075 — presets only covered the common cases).
+function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void> {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  if (str.length === 0 || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
+    return Promise.resolve();
+  }
+  return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
+}
+
 // Walks a deep object path safely. Used inside shouldUpdate which gets
 // the whole form values blob; we need to compare a deep field across
 // prev/curr without crashing on missing intermediates.

+ 11 - 4
frontend/src/lib/xray/inbound-link.ts

@@ -1079,11 +1079,11 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
   const sep = remarkModel.charAt(0);
   return inbound.settings.peers
-    .map((_p, i) => genWireguardLink({
+    .map((p, i) => genWireguardLink({
       settings: inbound.settings as WireguardInboundSettings,
       address: addr,
       port: inbound.port,
-      remark: `${remark}${sep}${i + 1}`,
+      remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
       peerIndex: i,
     }))
     .join('\r\n');
@@ -1095,16 +1095,23 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
   const sep = remarkModel.charAt(0);
   return inbound.settings.peers
-    .map((_p, i) => genWireguardConfig({
+    .map((p, i) => genWireguardConfig({
       settings: inbound.settings as WireguardInboundSettings,
       address: addr,
       port: inbound.port,
-      remark: `${remark}${sep}${i + 1}`,
+      remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
       peerIndex: i,
     }))
     .join('\r\n');
 }
 
+// Peer comments (#5168) are panel-side annotations; when present they ride
+// along in the share remark so the device is identifiable in client apps.
+function wgPeerCommentSuffix(peer: unknown): string {
+  const comment = (peer as { comment?: unknown })?.comment;
+  return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
+}
+
 export function isPostQuantumLink(link: string): boolean {
   if (/[?&]pqv=/.test(link)) return true;
   if (link.includes('mlkem768') || link.includes('mldsa65')) return true;

+ 0 - 4
frontend/src/pages/clients/BulkAddToGroupModal.tsx

@@ -66,11 +66,7 @@ export default function BulkAddToGroupModal({
               placeholder={t('pages.clients.groupName')}
               options={groups.map((g) => ({ value: g }))}
               onChange={(v) => setValue(v ?? '')}
-              filterOption={(input, option) =>
-                String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
-              }
               allowClear
-              style={{ width: '100%' }}
               autoFocus
             />
           </Form.Item>

+ 0 - 4
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -288,11 +288,7 @@ export default function ClientBulkAddModal({
               placeholder={t('pages.clients.groupPlaceholder')}
               options={groups.map((g) => ({ value: g }))}
               onChange={(v) => update('group', v ?? '')}
-              filterOption={(input, option) =>
-                String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
-              }
               allowClear
-              style={{ width: '100%' }}
             />
           </Form.Item>
 

+ 45 - 40
frontend/src/pages/clients/ClientFormModal.tsx

@@ -14,6 +14,7 @@ import {
   Switch,
   Tabs,
   Tag,
+  Tooltip,
   message,
 } from 'antd';
 import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
@@ -428,7 +429,7 @@ export default function ClientFormModal({
             items={[
               {
                 key: 'basic',
-                label: t('pages.clients.tabBasic'),
+                label: t('pages.clients.tabBasics'),
                 children: (
                   <>
                     <Row gutter={16}>
@@ -441,20 +442,31 @@ export default function ClientFormModal({
                               style={{ flex: 1 }}
                               onChange={(e) => update('email', e.target.value)}
                             />
-                            <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
+                            {!isEdit && (
+                              <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
+                            )}
                           </Space.Compact>
                         </Form.Item>
                       </Col>
-                      <Col xs={24} md={8}>
-                        <Form.Item label={t('pages.clients.totalGB')}>
+                      <Col xs={24} md={6}>
+                        <Form.Item label={t('pages.clients.totalGB')} tooltip={t('pages.clients.totalGBDesc')}>
                           <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
                             onChange={(v) => update('totalGB', Number(v) || 0)} />
                         </Form.Item>
                       </Col>
-                      <Col xs={24} md={4}>
-                        <Form.Item label={t('pages.clients.limitIp')}>
-                          <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
-                            onChange={(v) => update('limitIp', Number(v) || 0)} />
+                      <Col xs={24} md={6}>
+                        <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <InputNumber value={form.limitIp} min={0} style={{ flex: 1 }}
+                              onChange={(v) => update('limitIp', Number(v) || 0)} />
+                            {isEdit && (
+                              <Tooltip title={t('pages.clients.ipLog')}>
+                                <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                                  {clientIps.length > 0 ? clientIps.length : ''}
+                                </Button>
+                              </Tooltip>
+                            )}
+                          </Space.Compact>
                         </Form.Item>
                       </Col>
                     </Row>
@@ -489,7 +501,7 @@ export default function ClientFormModal({
                       </Col>
                       <Col xs={12} md={6}>
                         <Form.Item
-                          label={t('pages.clients.renew')}
+                          label={t('pages.clients.renewDays')}
                           tooltip={t('pages.clients.renewDesc')}
                         >
                           <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
@@ -499,16 +511,7 @@ export default function ClientFormModal({
                     </Row>
 
                     <Row gutter={16}>
-                      {tgBotEnable && (
-                        <Col xs={24} md={12}>
-                          <Form.Item label={t('pages.clients.telegramId')}>
-                            <InputNumber value={form.tgId} min={0} controls={false}
-                              placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
-                              onChange={(v) => update('tgId', Number(v) || 0)} />
-                          </Form.Item>
-                        </Col>
-                      )}
-                      <Col xs={24} md={tgBotEnable ? 12 : 24}>
+                      <Col xs={24} md={12}>
                         <Form.Item label={t('pages.clients.comment')}>
                           <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
                         </Form.Item>
@@ -520,16 +523,34 @@ export default function ClientFormModal({
                             placeholder={t('pages.clients.groupPlaceholder')}
                             options={groups.map((g) => ({ value: g }))}
                             onChange={(v) => update('group', v ?? '')}
-                            filterOption={(input, option) =>
-                              String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
-                            }
                             allowClear
-                            style={{ width: '100%' }}
                           />
                         </Form.Item>
                       </Col>
                     </Row>
 
+                    {(tgBotEnable || showReverseTag) && (
+                      <Row gutter={16}>
+                        {tgBotEnable && (
+                          <Col xs={24} md={12}>
+                            <Form.Item label={t('pages.clients.telegramId')}>
+                              <InputNumber value={form.tgId} min={0} controls={false}
+                                placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
+                                onChange={(v) => update('tgId', Number(v) || 0)} />
+                            </Form.Item>
+                          </Col>
+                        )}
+                        {showReverseTag && (
+                          <Col xs={24} md={12}>
+                            <Form.Item label={t('pages.clients.reverseTag')}>
+                              <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
+                                onChange={(e) => update('reverseTag', e.target.value)} />
+                            </Form.Item>
+                          </Col>
+                        )}
+                      </Row>
+                    )}
+
                     <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
                       <SelectAllClearButtons
                         options={inboundOptions}
@@ -555,20 +576,12 @@ export default function ClientFormModal({
                       <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
                       <span style={{ marginLeft: 8 }}>{t('enable')}</span>
                     </Form.Item>
-
-                    {isEdit && (
-                      <Form.Item label={t('pages.clients.ipLog')}>
-                        <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                          {clientIps.length > 0 ? clientIps.length : ''}
-                        </Button>
-                      </Form.Item>
-                    )}
                   </>
                 ),
               },
               {
                 key: 'config',
-                label: t('pages.clients.tabConfig'),
+                label: t('pages.clients.tabCredentials'),
                 children: (
                   <>
                     <Row gutter={16}>
@@ -635,14 +648,6 @@ export default function ClientFormModal({
                           </Form.Item>
                         </Col>
                       )}
-                      {showReverseTag && (
-                        <Col xs={24} md={12}>
-                          <Form.Item label={t('pages.clients.reverseTag')}>
-                            <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                              onChange={(e) => update('reverseTag', e.target.value)} />
-                          </Form.Item>
-                        </Col>
-                      )}
                     </Row>
                   </>
                 ),

+ 26 - 2
frontend/src/pages/clients/ClientsPage.tsx

@@ -51,6 +51,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
+import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
@@ -148,6 +149,7 @@ function readFilterState(): PersistedFilterState {
         buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
         protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
         inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
+        nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [],
         groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
       },
       sort: typeof raw.sort === 'string' ? raw.sort : '',
@@ -209,6 +211,10 @@ export default function ClientsPage() {
     client_stats: applyClientStatsEvent,
   });
 
+  // Node list for the Nodes filter; the section only renders when the panel
+  // actually manages nodes (#4997).
+  const { nodes } = useNodesQuery();
+
   const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
   const [formOpen, setFormOpen] = useState(false);
   const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
@@ -255,6 +261,23 @@ export default function ClientsPage() {
     setCurrentPage(1);
   }, [debouncedSearch, filters, sortColumn, sortOrder]);
 
+  // The node filter maps onto inbound ids client-side (#4997): the paging API
+  // already accepts an inbound CSV, so nodes never have to reach the backend.
+  // Sentinel 0 = "local panel" (inbounds without a nodeId).
+  const effectiveInboundCsv = useMemo(() => {
+    if (!filters.nodeIds.length) return filters.inboundIds.join(',');
+    const nodeSet = new Set(filters.nodeIds);
+    const nodeInboundIds = inbounds
+      .filter((ib) => nodeSet.has(ib.nodeId ?? 0))
+      .map((ib) => ib.id);
+    const pool = filters.inboundIds.length
+      ? nodeInboundIds.filter((id) => filters.inboundIds.includes(id))
+      : nodeInboundIds;
+    // Nothing matches the selected nodes: send an impossible id so the filter
+    // yields an honest empty result instead of being silently ignored.
+    return pool.length ? pool.join(',') : '-1';
+  }, [filters.nodeIds, filters.inboundIds, inbounds]);
+
   useEffect(() => {
     setQuery({
       page: currentPage,
@@ -262,7 +285,7 @@ export default function ClientsPage() {
       search: debouncedSearch,
       filter: filters.buckets.join(','),
       protocol: filters.protocols.join(','),
-      inbound: filters.inboundIds.join(','),
+      inbound: effectiveInboundCsv,
       expiryFrom: filters.expiryFrom,
       expiryTo: filters.expiryTo,
       usageFrom: gbToBytes(filters.usageFromGB),
@@ -274,7 +297,7 @@ export default function ClientsPage() {
       sort: sortColumn || undefined,
       order: sortOrder || undefined,
     });
-  }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
+  }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
 
   const activeCount = activeFilterCount(filters);
 
@@ -1333,6 +1356,7 @@ export default function ClientsPage() {
             inbounds={inbounds}
             protocols={protocolOptions}
             groups={groupOptions}
+            nodes={nodes}
           />
         </LazyMount>
       </Layout>

+ 30 - 0
frontend/src/pages/clients/FilterDrawer.tsx

@@ -18,6 +18,7 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import type { InboundOption } from '@/hooks/useClients';
+import type { NodeRecord } from '@/schemas/node';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { emptyFilters, type ClientFilters } from './filters';
 
@@ -29,6 +30,7 @@ interface FilterDrawerProps {
   inbounds: InboundOption[];
   protocols: string[];
   groups: string[];
+  nodes: NodeRecord[];
 }
 
 const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
@@ -41,6 +43,7 @@ export default function FilterDrawer({
   inbounds,
   protocols,
   groups,
+  nodes,
 }: FilterDrawerProps) {
   const { t } = useTranslation();
 
@@ -66,6 +69,16 @@ export default function FilterDrawer({
     [groups],
   );
 
+  // 0 is the "local panel" sentinel (inbounds without a nodeId) — see
+  // ClientFilters.nodeIds (#4997).
+  const nodeOptions = useMemo(
+    () => [
+      { value: 0, label: t('pages.clients.filters.localPanel') },
+      ...nodes.map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
+    ],
+    [nodes, t],
+  );
+
   const dateRange: [Dayjs | null, Dayjs | null] = [
     filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
     filters.expiryTo ? dayjs(filters.expiryTo) : null,
@@ -132,6 +145,23 @@ export default function FilterDrawer({
           />
         </Form.Item>
 
+        {nodes.length > 0 && (
+          <Form.Item label={t('pages.clients.filters.nodes')}>
+            <Select
+              mode="multiple"
+              value={filters.nodeIds}
+              onChange={(v) => patch('nodeIds', v as number[])}
+              options={nodeOptions}
+              placeholder={t('pages.clients.filters.nodes')}
+              maxTagCount="responsive"
+              allowClear
+              showSearch
+              optionFilterProp="label"
+              listHeight={220}
+            />
+          </Form.Item>
+        )}
+
         <Form.Item label={t('pages.clients.group')}>
           <Select
             mode="multiple"

+ 5 - 0
frontend/src/pages/clients/filters.ts

@@ -2,6 +2,9 @@ export interface ClientFilters {
   buckets: string[];
   protocols: string[];
   inboundIds: number[];
+  // Node ids to filter by; 0 is the "local panel" sentinel (inbounds with
+  // no nodeId). Mapped onto inbound ids client-side — see ClientsPage.
+  nodeIds: number[];
   groups: string[];
   expiryFrom?: number;
   expiryTo?: number;
@@ -17,6 +20,7 @@ export function emptyFilters(): ClientFilters {
     buckets: [],
     protocols: [],
     inboundIds: [],
+    nodeIds: [],
     groups: [],
     autoRenew: '',
     hasTgId: '',
@@ -29,6 +33,7 @@ export function activeFilterCount(f: ClientFilters): number {
   if (f.buckets.length) n++;
   if (f.protocols.length) n++;
   if (f.inboundIds.length) n++;
+  if (f.nodeIds.length) n++;
   if (f.groups.length) n++;
   if (f.expiryFrom || f.expiryTo) n++;
   if (f.usageFromGB || f.usageToGB) n++;

+ 9 - 0
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -102,6 +102,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
                 <Divider titlePlacement="center">
                   <Space>
                     <span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
+                    <Form.Item noStyle shouldUpdate>
+                      {() => {
+                        const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
+                        return comment ? <span style={{ opacity: 0.65 }}>— {comment}</span> : null;
+                      }}
+                    </Form.Item>
                     {fields.length > 1 && (
                       <Button
                         size="small"
@@ -112,6 +118,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
                     )}
                   </Space>
                 </Divider>
+                <Form.Item name={[field.name, 'comment']} label={t('comment')}>
+                  <Input placeholder="e.g. Alice's laptop" />
+                </Form.Item>
                 <Form.Item label={t('pages.xray.wireguard.secretKey')}>
                   <Space.Compact block>
                     <Form.Item name={[field.name, 'privateKey']} noStyle>

+ 41 - 8
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -5,6 +5,7 @@ import {
   Card,
   Checkbox,
   Dropdown,
+  Select,
   Space,
   Switch,
   Table,
@@ -50,6 +51,29 @@ export default function InboundList({
   const { t } = useTranslation();
   const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
   const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
+  // Node filter (#4997): 'all' shows everything, 0 is the local-panel
+  // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
+  const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
+
+  const showNodeFilter = useMemo(
+    () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
+    [nodesById, dbInbounds],
+  );
+
+  const nodeFilterOptions = useMemo(
+    () => [
+      { value: 'all' as const, label: t('pages.clients.filters.nodes') },
+      { value: 0, label: t('pages.clients.filters.localPanel') },
+      ...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
+    ],
+    [nodesById, t],
+  );
+
+  const visibleInbounds = useMemo(() => {
+    if (nodeFilter === 'all') return dbInbounds;
+    if (nodeFilter === 0) return dbInbounds.filter((ib) => ib.nodeId == null);
+    return dbInbounds.filter((ib) => ib.nodeId === nodeFilter);
+  }, [dbInbounds, nodeFilter]);
 
   const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
     const previous = dbInbound.enable;
@@ -78,11 +102,11 @@ export default function InboundList({
   }, []);
 
   const selectAll = useCallback((checked: boolean) => {
-    setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []);
-  }, [dbInbounds]);
+    setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
+  }, [visibleInbounds]);
 
-  const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length;
-  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length;
+  const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
+  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
 
   const handleBulkDelete = useCallback(async () => {
     const ok = await onBulkDelete(selectedRowKeys);
@@ -131,6 +155,15 @@ export default function InboundList({
               {!isMobile && t('pages.inbounds.generalActions')}
             </Button>
           </Dropdown>
+          {showNodeFilter && (
+            <Select
+              value={nodeFilter}
+              onChange={(v) => setNodeFilter(v)}
+              options={nodeFilterOptions}
+              popupMatchSelectWidth={false}
+              style={{ minWidth: isMobile ? 90 : 140 }}
+            />
+          )}
           {selectedRowKeys.length > 0 && (
             <>
               <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
@@ -147,7 +180,7 @@ export default function InboundList({
       <Space orientation="vertical" style={{ width: '100%' }}>
         {isMobile ? (
           <div className="inbound-cards">
-            {dbInbounds.length === 0 ? (
+            {visibleInbounds.length === 0 ? (
               <div className="card-empty">
                 <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
                 <div>{t('noData')}</div>
@@ -166,7 +199,7 @@ export default function InboundList({
                   <span className="bulk-count">{selectedRowKeys.length}</span>
                 )}
               </div>
-              {dbInbounds.map((record) => (
+              {visibleInbounds.map((record) => (
                 <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
                   <div className="card-head">
                     <Checkbox
@@ -204,13 +237,13 @@ export default function InboundList({
         ) : (
           <Table
             columns={columns}
-            dataSource={dbInbounds}
+            dataSource={visibleInbounds}
             rowKey={(r) => r.id}
             rowSelection={{
               selectedRowKeys,
               onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
             }}
-            pagination={paginationFor(dbInbounds)}
+            pagination={paginationFor(visibleInbounds)}
             scroll={{ x: 1000 }}
             style={{ marginTop: 10 }}
             size="small"

+ 15 - 3
frontend/src/pages/xray/outbounds/protocols/freedom.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
 import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
 
 import { OutboundDomainStrategies } from '@/schemas/primitives';
@@ -67,12 +67,24 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
                   <Form.Item
                     label={t('pages.settings.subFormats.packets')}
                     name={['settings', 'fragment', 'packets']}
+                    rules={[{
+                      validator: (_rule, value) => {
+                        const str = String(value ?? '').trim();
+                        // xray accepts "tlshello" or any packet-number range (#5075)
+                        if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
+                          return Promise.resolve();
+                        }
+                        return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
+                      },
+                    }]}
                   >
-                    <Select
+                    <AutoComplete
                       options={[
-                        { value: '1-3', label: '1-3' },
                         { value: 'tlshello', label: 'tlshello' },
+                        { value: '1-3', label: '1-3' },
+                        { value: '1-5', label: '1-5' },
                       ]}
+                      placeholder="tlshello or n-m, e.g. 1-3"
                     />
                   </Form.Item>
                   <Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>

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

@@ -44,6 +44,8 @@ export const InboundOptionSchema = z.object({
   port: z.number().optional(),
   tlsFlowCapable: z.boolean().optional(),
   ssMethod: z.string().optional(),
+  // Hosting node id; absent/null for this panel's own inbounds (#4997).
+  nodeId: z.number().nullable().optional(),
 }).loose();
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);

+ 4 - 0
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -26,6 +26,10 @@ export const WireguardInboundPeerSchema = z.object({
   preSharedKey: z.string().optional(),
   allowedIPs: z.array(z.string()).default([]),
   keepAlive: optionalClearedInt(z.number().int().min(0)),
+  // Panel-only annotation (#5168): which client/device this peer belongs to.
+  // Rides along in the settings JSON like privateKey does; xray-core ignores
+  // unknown peer fields.
+  comment: z.string().optional(),
 });
 export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
 

+ 8 - 1
internal/web/controller/client.go

@@ -118,7 +118,14 @@ func (a *ClientController) get(c *gin.Context) {
 		return
 	}
 	rec.Flow = flow
-	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
+	// Consumed bytes (up+down, including cross-node global overlay) so API
+	// consumers can pair usage with the client's totalGB quota (#4973).
+	// Best-effort: a traffic lookup failure must not break the client fetch.
+	var usedTraffic int64
+	if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
+		usedTraffic = t.Up + t.Down
+	}
+	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
 }
 
 func (a *ClientController) create(c *gin.Context) {

+ 6 - 1
internal/web/service/inbound.go

@@ -295,6 +295,9 @@ type InboundOption struct {
 	Port           int    `json:"port" example:"443"`
 	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
 	SsMethod       string `json:"ssMethod"`
+	// Hosting node; nil for this panel's own inbounds. Lets the clients
+	// page map a node filter onto inbound IDs (#4997).
+	NodeId *int `json:"nodeId,omitempty"`
 }
 
 func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
@@ -307,9 +310,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		Port           int    `gorm:"column:port"`
 		StreamSettings string `gorm:"column:stream_settings"`
 		Settings       string `gorm:"column:settings"`
+		NodeId         *int   `gorm:"column:node_id"`
 	}
 	err := db.Table("inbounds").
-		Select("id, remark, tag, protocol, port, stream_settings, settings").
+		Select("id, remark, tag, protocol, port, stream_settings, settings, node_id").
 		Where("user_id = ?", userId).
 		Order("id ASC").
 		Scan(&rows).Error
@@ -326,6 +330,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Port:           r.Port,
 			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:       inboundShadowsocksMethod(r.Protocol, r.Settings),
+			NodeId:         r.NodeId,
 		})
 	}
 	return out, nil

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "أساسي",
-      "tabConfig": "التكوين",
+      "tabBasics": "أساسي",
+      "tabCredentials": "بيانات الاعتماد",
       "add": "إضافة عميل",
       "edit": "تعديل العميل",
       "submitAdd": "إضافة عميل",
@@ -666,13 +666,18 @@
       "prefix": "بادئة",
       "postfix": "لاحقة",
       "delayedStart": "البدء بعد أول استخدام",
-      "expireDays": "المدة",
+      "expireDays": "المدة (أيام)",
       "days": "يوم",
       "renew": "تجديد تلقائي",
       "renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)",
+      "renewDays": "تجديد تلقائي (أيام)",
       "searchPlaceholder": "ابحث بالبريد، التعليق، sub ID، UUID، كلمة المرور، auth…",
       "filterTitle": "تصفية العملاء",
       "clearAllFilters": "مسح الكل",
+      "filters": {
+        "nodes": "النودز",
+        "localPanel": "محلي (هذه اللوحة)"
+      },
       "showingCount": "عرض {shown} من {total}",
       "sortOldest": "الأقدم أولاً",
       "sortNewest": "الأحدث أولاً",
@@ -687,10 +692,12 @@
       "hasNot": "لا يملك",
       "title": "العملاء",
       "actions": "الإجراءات",
-      "totalGB": "مجموع المرسل/المستقبل (جيجابايت)",
+      "totalGB": "حد البيانات (جيجابايت)",
+      "totalGBDesc": "حصة البيانات لهذا العميل. 0 = غير محدود.",
       "expiryTime": "انتهاء الصلاحية",
       "addClients": "إضافة عملاء",
       "limitIp": "حد عناوين IP",
+      "limitIpDesc": "الحد الأقصى لعناوين IP المتزامنة. 0 = غير محدود.",
       "password": "كلمة المرور",
       "subId": "معرّف الاشتراك",
       "online": "متصل",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "اختار الإدخال"
     }
   }
-}
+}

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

@@ -638,8 +638,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Basic",
-      "tabConfig": "Config",
+      "tabBasics": "Basics",
+      "tabCredentials": "Credentials",
       "add": "Add Client",
       "edit": "Edit Client",
       "submitAdd": "Add Client",
@@ -667,13 +667,18 @@
       "prefix": "Prefix",
       "postfix": "Postfix",
       "delayedStart": "Start After First Use",
-      "expireDays": "Duration",
+      "expireDays": "Duration (days)",
       "days": "Day(s)",
       "renew": "Auto Renew",
       "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
+      "renewDays": "Auto Renew (days)",
       "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
       "filterTitle": "Filter clients",
       "clearAllFilters": "Clear all",
+      "filters": {
+        "nodes": "Nodes",
+        "localPanel": "Local (this panel)"
+      },
       "showingCount": "Showing {shown} of {total}",
       "sortOldest": "Oldest first",
       "sortNewest": "Newest first",
@@ -688,10 +693,12 @@
       "hasNot": "Doesn't have",
       "title": "Clients",
       "actions": "Actions",
-      "totalGB": "Total Sent/Received (GB)",
+      "totalGB": "Traffic Limit (GB)",
+      "totalGBDesc": "Data quota for this client. 0 = unlimited.",
       "expiryTime": "Expiry",
       "addClients": "Add Clients",
       "limitIp": "IP Limit",
+      "limitIpDesc": "Maximum simultaneous IPs. 0 = unlimited.",
       "password": "Password",
       "subId": "Subscription ID",
       "online": "Online",
@@ -1730,4 +1737,4 @@
       "chooseInbound": "Choose an Inbound"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Básico",
-      "tabConfig": "Configuración",
+      "tabBasics": "Básico",
+      "tabCredentials": "Credenciales",
       "add": "Añadir cliente",
       "edit": "Editar cliente",
       "submitAdd": "Añadir cliente",
@@ -666,13 +666,18 @@
       "prefix": "Prefijo",
       "postfix": "Sufijo",
       "delayedStart": "Iniciar tras el primer uso",
-      "expireDays": "Duración",
+      "expireDays": "Duración (días)",
       "days": "Día(s)",
       "renew": "Renovación automática",
       "renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)",
+      "renewDays": "Renovación automática (días)",
       "searchPlaceholder": "Buscar email, comentario, sub ID, UUID, contraseña, auth…",
       "filterTitle": "Filtrar clientes",
       "clearAllFilters": "Limpiar todo",
+      "filters": {
+        "nodes": "Nodos",
+        "localPanel": "Local (este panel)"
+      },
       "showingCount": "Mostrando {shown} de {total}",
       "sortOldest": "Más antiguos",
       "sortNewest": "Más recientes",
@@ -687,10 +692,12 @@
       "hasNot": "No tiene",
       "title": "Clientes",
       "actions": "Acciones",
-      "totalGB": "Total enviado/recibido (GB)",
+      "totalGB": "Límite de tráfico (GB)",
+      "totalGBDesc": "Cuota de datos para este cliente. 0 = ilimitado.",
       "expiryTime": "Expiración",
       "addClients": "Añadir clientes",
       "limitIp": "Límite de IP",
+      "limitIpDesc": "Máximo de IP simultáneas. 0 = ilimitado.",
       "password": "Contraseña",
       "subId": "ID de suscripción",
       "online": "En línea",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Elige un Inbound"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "پایه",
-      "tabConfig": "پیکربندی",
+      "tabBasics": "پایه",
+      "tabCredentials": "اطلاعات اتصال",
       "add": "افزودن کلاینت",
       "edit": "ویرایش کلاینت",
       "submitAdd": "افزودن کلاینت",
@@ -666,13 +666,18 @@
       "prefix": "پیشوند",
       "postfix": "پسوند",
       "delayedStart": "شروع پس از اولین استفاده",
-      "expireDays": "مدت",
+      "expireDays": "مدت اعتبار (روز)",
       "days": "روز",
       "renew": "تمدید خودکار",
       "renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)",
+      "renewDays": "تمدید خودکار (روز)",
       "searchPlaceholder": "جستجوی ایمیل، توضیح، Sub ID، UUID، رمز، احراز...",
       "filterTitle": "فیلتر کاربران",
       "clearAllFilters": "پاک کردن همه",
+      "filters": {
+        "nodes": "نودها",
+        "localPanel": "محلی (همین پنل)"
+      },
       "showingCount": "نمایش {shown} از {total}",
       "sortOldest": "قدیمی‌ترین",
       "sortNewest": "جدیدترین",
@@ -687,10 +692,12 @@
       "hasNot": "ندارد",
       "title": "کلاینت‌ها",
       "actions": "عملیات",
-      "totalGB": "مجموع ارسال/دریافت (گیگابایت)",
+      "totalGB": "سقف حجم (گیگابایت)",
+      "totalGBDesc": "سهمیه‌ی حجم مصرفی کلاینت. ۰ = نامحدود",
       "expiryTime": "انقضا",
       "addClients": "افزودن کلاینت‌ها",
       "limitIp": "محدودیت IP",
+      "limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
       "password": "رمز عبور",
       "subId": "شناسه اشتراک",
       "online": "آنلاین",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "یک ورودی انتخاب کنید"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Dasar",
-      "tabConfig": "Konfigurasi",
+      "tabBasics": "Dasar",
+      "tabCredentials": "Kredensial",
       "add": "Tambah klien",
       "edit": "Ubah klien",
       "submitAdd": "Tambah klien",
@@ -666,13 +666,18 @@
       "prefix": "Awalan",
       "postfix": "Akhiran",
       "delayedStart": "Mulai setelah penggunaan pertama",
-      "expireDays": "Durasi",
+      "expireDays": "Durasi (hari)",
       "days": "Hari",
       "renew": "Perpanjangan otomatis",
       "renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)",
+      "renewDays": "Perpanjangan otomatis (hari)",
       "searchPlaceholder": "Cari email, komentar, sub ID, UUID, kata sandi, auth…",
       "filterTitle": "Filter klien",
       "clearAllFilters": "Hapus semua",
+      "filters": {
+        "nodes": "Node",
+        "localPanel": "Lokal (panel ini)"
+      },
       "showingCount": "Menampilkan {shown} dari {total}",
       "sortOldest": "Terlama dulu",
       "sortNewest": "Terbaru dulu",
@@ -687,10 +692,12 @@
       "hasNot": "Tidak memiliki",
       "title": "Klien",
       "actions": "Aksi",
-      "totalGB": "Total Kirim/Terima (GB)",
+      "totalGB": "Batas Trafik (GB)",
+      "totalGBDesc": "Kuota data untuk klien ini. 0 = tidak terbatas.",
       "expiryTime": "Kedaluwarsa",
       "addClients": "Tambah klien",
       "limitIp": "Batas IP",
+      "limitIpDesc": "Jumlah maksimum IP bersamaan. 0 = tidak terbatas.",
       "password": "Kata sandi",
       "subId": "ID Langganan",
       "online": "Online",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Pilih Inbound"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "基本",
-      "tabConfig": "設定",
+      "tabBasics": "基本",
+      "tabCredentials": "認証情報",
       "add": "クライアントを追加",
       "edit": "クライアントを編集",
       "submitAdd": "クライアントを追加",
@@ -666,13 +666,18 @@
       "prefix": "プレフィックス",
       "postfix": "サフィックス",
       "delayedStart": "初回使用から開始",
-      "expireDays": "期間",
+      "expireDays": "期間 (日)",
       "days": "日",
       "renew": "自動更新",
       "renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)",
+      "renewDays": "自動更新 (日)",
       "searchPlaceholder": "メール、コメント、sub ID、UUID、パスワード、auth を検索…",
       "filterTitle": "クライアントをフィルタ",
       "clearAllFilters": "すべてクリア",
+      "filters": {
+        "nodes": "ノード",
+        "localPanel": "ローカル(このパネル)"
+      },
       "showingCount": "{total} 件中 {shown} 件を表示",
       "sortOldest": "古い順",
       "sortNewest": "新しい順",
@@ -687,10 +692,12 @@
       "hasNot": "なし",
       "title": "クライアント",
       "actions": "操作",
-      "totalGB": "送受信合計 (GB)",
+      "totalGB": "トラフィック上限 (GB)",
+      "totalGBDesc": "このクライアントのデータ割当量。0 = 無制限。",
       "expiryTime": "有効期限",
       "addClients": "クライアントを追加",
       "limitIp": "IP 制限",
+      "limitIpDesc": "同時接続 IP の最大数。0 = 無制限。",
       "password": "パスワード",
       "subId": "サブスクリプション ID",
       "online": "オンライン",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "インバウンドを選択"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Básico",
-      "tabConfig": "Configuração",
+      "tabBasics": "Básico",
+      "tabCredentials": "Credenciais",
       "add": "Adicionar cliente",
       "edit": "Editar cliente",
       "submitAdd": "Adicionar cliente",
@@ -666,13 +666,18 @@
       "prefix": "Prefixo",
       "postfix": "Sufixo",
       "delayedStart": "Iniciar após o primeiro uso",
-      "expireDays": "Duração",
+      "expireDays": "Duração (dias)",
       "days": "Dia(s)",
       "renew": "Renovação automática",
       "renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)",
+      "renewDays": "Renovação automática (dias)",
       "searchPlaceholder": "Buscar email, comentário, sub ID, UUID, senha, auth…",
       "filterTitle": "Filtrar clientes",
       "clearAllFilters": "Limpar tudo",
+      "filters": {
+        "nodes": "Nós",
+        "localPanel": "Local (este painel)"
+      },
       "showingCount": "Mostrando {shown} de {total}",
       "sortOldest": "Mais antigos primeiro",
       "sortNewest": "Mais novos primeiro",
@@ -687,10 +692,12 @@
       "hasNot": "Não tem",
       "title": "Clientes",
       "actions": "Ações",
-      "totalGB": "Total enviado/recebido (GB)",
+      "totalGB": "Limite de tráfego (GB)",
+      "totalGBDesc": "Cota de dados para este cliente. 0 = ilimitado.",
       "expiryTime": "Expiração",
       "addClients": "Adicionar clientes",
       "limitIp": "Limite de IP",
+      "limitIpDesc": "Máximo de IPs simultâneos. 0 = ilimitado.",
       "password": "Senha",
       "subId": "ID da assinatura",
       "online": "Online",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Escolha um Inbound"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Основные",
-      "tabConfig": "Конфигурация",
+      "tabBasics": "Основные",
+      "tabCredentials": "Учетные данные",
       "add": "Добавить клиента",
       "edit": "Изменить клиента",
       "submitAdd": "Добавить клиента",
@@ -666,13 +666,18 @@
       "prefix": "Префикс",
       "postfix": "Постфикс",
       "delayedStart": "Старт после первого использования",
-      "expireDays": "Длительность",
+      "expireDays": "Длительность (дней)",
       "days": "Дни",
       "renew": "Автопродление",
       "renewDesc": "Автоматическое продление после окончания. (0 = отключено) (единица: день)",
+      "renewDays": "Автопродление (дней)",
       "searchPlaceholder": "Поиск email, комментария, sub ID, UUID, пароля, auth…",
       "filterTitle": "Фильтр клиентов",
       "clearAllFilters": "Очистить все",
+      "filters": {
+        "nodes": "Узлы",
+        "localPanel": "Локально (эта панель)"
+      },
       "showingCount": "Показано {shown} из {total}",
       "sortOldest": "Сначала старые",
       "sortNewest": "Сначала новые",
@@ -687,10 +692,12 @@
       "hasNot": "Нет",
       "title": "Клиенты",
       "actions": "Действия",
-      "totalGB": "Всего отправлено/получено (ГБ)",
+      "totalGB": "Лимит трафика (ГБ)",
+      "totalGBDesc": "Квота трафика для этого клиента. 0 = без ограничений.",
       "expiryTime": "Срок действия",
       "addClients": "Добавить клиентов",
       "limitIp": "Лимит IP",
+      "limitIpDesc": "Максимум одновременных IP-адресов. 0 = без ограничений.",
       "password": "Пароль",
       "subId": "ID подписки",
       "online": "В сети",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Выберите входящее подключение"
     }
   }
-}
+}

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

@@ -638,8 +638,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Temel",
-      "tabConfig": "Yapılandırma",
+      "tabBasics": "Temel",
+      "tabCredentials": "Kimlik Bilgileri",
       "add": "Kullanıcı Ekle",
       "edit": "Kullanıcıyı Düzenle",
       "submitAdd": "Kullanıcı Ekle",
@@ -667,13 +667,18 @@
       "prefix": "Önek",
       "postfix": "Sonek",
       "delayedStart": "İlk Kullanımdan Sonra Başla",
-      "expireDays": "Süre",
+      "expireDays": "Süre (gün)",
       "days": "Gün(ler)",
       "renew": "Otomatik Yenileme",
       "renewDesc": "Süre dolduktan sonra otomatik yeniler. (0 = devre dışı) (birim: gün)",
+      "renewDays": "Otomatik Yenileme (gün)",
       "searchPlaceholder": "E-posta, yorum, sub ID, UUID, parola, auth ara…",
       "filterTitle": "Kullanıcıları Filtrele",
       "clearAllFilters": "Tümünü Temizle",
+      "filters": {
+        "nodes": "Düğümler",
+        "localPanel": "Yerel (bu panel)"
+      },
       "showingCount": "{total} içinden {shown} gösteriliyor",
       "sortOldest": "Önce En Eski",
       "sortNewest": "Önce En Yeni",
@@ -688,10 +693,12 @@
       "hasNot": "Yok",
       "title": "Kullanıcılar",
       "actions": "İşlemler",
-      "totalGB": "Toplam Gönderilen/Alınan (GB)",
+      "totalGB": "Trafik Limiti (GB)",
+      "totalGBDesc": "Bu kullanıcı için veri kotası. 0 = sınırsız.",
       "expiryTime": "Son Kullanma",
       "addClients": "Kullanıcı Ekle",
       "limitIp": "IP Limiti",
+      "limitIpDesc": "Eş zamanlı en fazla IP sayısı. 0 = sınırsız.",
       "password": "Şifre",
       "subId": "Abonelik ID'si",
       "online": "Çevrimiçi",
@@ -1728,4 +1735,4 @@
       "chooseInbound": "Bir Gelen Bağlantı Seçin"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Основні",
-      "tabConfig": "Конфігурація",
+      "tabBasics": "Основні",
+      "tabCredentials": "Облікові дані",
       "add": "Додати клієнта",
       "edit": "Редагувати клієнта",
       "submitAdd": "Додати клієнта",
@@ -666,13 +666,18 @@
       "prefix": "Префікс",
       "postfix": "Постфікс",
       "delayedStart": "Запуск після першого використання",
-      "expireDays": "Тривалість",
+      "expireDays": "Тривалість (днів)",
       "days": "Дні",
       "renew": "Авто-продовження",
       "renewDesc": "Автоматичне продовження після закінчення. (0 = вимкнено) (одиниця: день)",
+      "renewDays": "Авто-продовження (днів)",
       "searchPlaceholder": "Пошук email, коментаря, sub ID, UUID, паролю, auth…",
       "filterTitle": "Фільтр клієнтів",
       "clearAllFilters": "Очистити все",
+      "filters": {
+        "nodes": "Вузли",
+        "localPanel": "Локально (ця панель)"
+      },
       "showingCount": "Показано {shown} з {total}",
       "sortOldest": "Спочатку старі",
       "sortNewest": "Спочатку нові",
@@ -687,10 +692,12 @@
       "hasNot": "Не має",
       "title": "Клієнти",
       "actions": "Дії",
-      "totalGB": "Усього надіслано/отримано (ГБ)",
+      "totalGB": "Ліміт трафіку (ГБ)",
+      "totalGBDesc": "Квота трафіку для цього клієнта. 0 = без обмежень.",
       "expiryTime": "Термін дії",
       "addClients": "Додати клієнтів",
       "limitIp": "Ліміт IP",
+      "limitIpDesc": "Максимум одночасних IP-адрес. 0 = без обмежень.",
       "password": "Пароль",
       "subId": "ID підписки",
       "online": "У мережі",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Виберіть Вхідний"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "Cơ bản",
-      "tabConfig": "Cấu hình",
+      "tabBasics": "Cơ bản",
+      "tabCredentials": "Thông tin xác thực",
       "add": "Thêm khách hàng",
       "edit": "Chỉnh sửa khách hàng",
       "submitAdd": "Thêm khách hàng",
@@ -666,13 +666,18 @@
       "prefix": "Tiền tố",
       "postfix": "Hậu tố",
       "delayedStart": "Bắt đầu sau lần dùng đầu",
-      "expireDays": "Thời hạn",
+      "expireDays": "Thời hạn (ngày)",
       "days": "Ngày",
       "renew": "Tự động gia hạn",
       "renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt) (đơn vị: ngày)",
+      "renewDays": "Tự động gia hạn (ngày)",
       "searchPlaceholder": "Tìm email, ghi chú, sub ID, UUID, mật khẩu, auth…",
       "filterTitle": "Lọc client",
       "clearAllFilters": "Xóa tất cả",
+      "filters": {
+        "nodes": "Nút",
+        "localPanel": "Cục bộ (bảng này)"
+      },
       "showingCount": "Hiển thị {shown} trên {total}",
       "sortOldest": "Cũ nhất trước",
       "sortNewest": "Mới nhất trước",
@@ -687,10 +692,12 @@
       "hasNot": "Không có",
       "title": "Khách hàng",
       "actions": "Hành động",
-      "totalGB": "Tổng gửi/nhận (GB)",
+      "totalGB": "Giới hạn lưu lượng (GB)",
+      "totalGBDesc": "Hạn mức dữ liệu cho khách hàng này. 0 = không giới hạn.",
       "expiryTime": "Hết hạn",
       "addClients": "Thêm khách hàng",
       "limitIp": "Giới hạn IP",
+      "limitIpDesc": "Số IP đồng thời tối đa. 0 = không giới hạn.",
       "password": "Mật khẩu",
       "subId": "ID đăng ký",
       "online": "Trực tuyến",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "Chọn một Inbound"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "基本",
-      "tabConfig": "配置",
+      "tabBasics": "基本",
+      "tabCredentials": "凭据",
       "add": "添加客户端",
       "edit": "编辑客户端",
       "submitAdd": "添加客户端",
@@ -666,13 +666,18 @@
       "prefix": "前缀",
       "postfix": "后缀",
       "delayedStart": "首次使用后开始",
-      "expireDays": "时长",
+      "expireDays": "时长 (天)",
       "days": "天",
       "renew": "自动续期",
       "renewDesc": "到期后自动续期。(0 = 禁用) (单位: 天)",
+      "renewDays": "自动续期 (天)",
       "searchPlaceholder": "搜索邮箱、备注、sub ID、UUID、密码、auth…",
       "filterTitle": "筛选客户端",
       "clearAllFilters": "清除全部",
+      "filters": {
+        "nodes": "节点",
+        "localPanel": "本机(此面板)"
+      },
       "showingCount": "显示 {shown} / {total}",
       "sortOldest": "最旧优先",
       "sortNewest": "最新优先",
@@ -687,10 +692,12 @@
       "hasNot": "不拥有",
       "title": "客户端",
       "actions": "操作",
-      "totalGB": "总上传/下载 (GB)",
+      "totalGB": "流量上限 (GB)",
+      "totalGBDesc": "该客户端的流量配额。0 = 不限制。",
       "expiryTime": "过期时间",
       "addClients": "添加客户端",
       "limitIp": "IP 限制",
+      "limitIpDesc": "最大同时连接 IP 数。0 = 不限制。",
       "password": "密码",
       "subId": "订阅 ID",
       "online": "在线",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "选择一个入站"
     }
   }
-}
+}

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

@@ -637,8 +637,8 @@
       }
     },
     "clients": {
-      "tabBasic": "基本",
-      "tabConfig": "配置",
+      "tabBasics": "基本",
+      "tabCredentials": "認證資訊",
       "add": "新增客戶端",
       "edit": "編輯客戶端",
       "submitAdd": "新增客戶端",
@@ -666,13 +666,18 @@
       "prefix": "前綴",
       "postfix": "後綴",
       "delayedStart": "首次使用後開始",
-      "expireDays": "時長",
+      "expireDays": "時長 (天)",
       "days": "天",
       "renew": "自動續期",
       "renewDesc": "到期後自動續期。(0 = 停用) (單位: 天)",
+      "renewDays": "自動續期 (天)",
       "searchPlaceholder": "搜尋電子郵件、備註、sub ID、UUID、密碼、auth…",
       "filterTitle": "篩選客戶端",
       "clearAllFilters": "清除全部",
+      "filters": {
+        "nodes": "節點",
+        "localPanel": "本機(此面板)"
+      },
       "showingCount": "顯示 {shown} / {total}",
       "sortOldest": "最舊優先",
       "sortNewest": "最新優先",
@@ -687,10 +692,12 @@
       "hasNot": "不擁有",
       "title": "客戶端",
       "actions": "操作",
-      "totalGB": "總上傳/下載 (GB)",
+      "totalGB": "流量上限 (GB)",
+      "totalGBDesc": "該客戶端的流量配額。0 = 不限制。",
       "expiryTime": "到期時間",
       "addClients": "新增客戶端",
       "limitIp": "IP 限制",
+      "limitIpDesc": "最大同時連線 IP 數。0 = 不限制。",
       "password": "密碼",
       "subId": "訂閱 ID",
       "online": "上線",
@@ -1729,4 +1736,4 @@
       "chooseInbound": "選擇一個入站"
     }
   }
-}
+}