11 Sitoutukset eee652c4a5 ... 58905d81a4

Tekijä SHA1 Viesti Päivämäärä
  MHSanaei 58905d81a4 feat(node-sync): push global client usage to nodes for display and local enforcement 21 tuntia sitten
  MHSanaei 8258a26fbf fix(node-sync): keep shared client traffic row when email still lives on other inbounds 22 tuntia sitten
  MHSanaei dc52e725b6 fix(ui): blink the online dot in mobile client cards like desktop 22 tuntia sitten
  MHSanaei aeb2217ae5 fix(ui): classify ended clients as depleted, not disabled, on inbounds page 22 tuntia sitten
  MHSanaei 9730561f20 ci(bot): update issue-bot repo map and tighten reply style 23 tuntia sitten
  Nikan Zeyaei 07e5e8498e feat(ui): add select all / clear all shortcuts for inbound multi-select (#5175) 23 tuntia sitten
  Nikan Zeyaei ffde2f7ebf feat(sub): add Copy All Configs button to subscription page (#5163) 23 tuntia sitten
  Vladimir Avtsenov 89b1137b00 feat(env): allow setting the initial URI path for the web panel (#5149) 23 tuntia sitten
  aleskxyz 8f408d2d6a feat(routing): show tag (remark) in routing rules list (#5151) 23 tuntia sitten
  nima1024m 941eba546d feat(clients): restore traffic usage progress bars on Clients page (#5150) 1 päivä sitten
  Rouzbeh† c7a76e9626 fix: enable XTLS vision flow for VLESS+XHTTP+vlessenc in UI and share links (#5157) (#5185) 1 päivä sitten
74 muutettua tiedostoa jossa 1794 lisäystä ja 223 poistoa
  1. 2 1
      .env.example
  2. 135 56
      .github/workflows/claude-issue-bot.yml
  3. 2 0
      CONTRIBUTING.md
  4. 1 0
      README.ar_EG.md
  5. 1 0
      README.es_ES.md
  6. 1 0
      README.fa_IR.md
  7. 1 0
      README.md
  8. 1 0
      README.ru_RU.md
  9. 1 0
      README.tr_TR.md
  10. 1 0
      README.zh_CN.md
  11. 1 0
      docker-compose.yml
  12. 54 1
      frontend/public/openapi.json
  13. 90 0
      frontend/src/components/clients/ClientTrafficCell.css
  14. 85 0
      frontend/src/components/clients/ClientTrafficCell.tsx
  15. 51 0
      frontend/src/components/form/SelectAllClearButtons.tsx
  16. 1 0
      frontend/src/components/form/index.ts
  17. 64 0
      frontend/src/lib/clients/traffic-display.ts
  18. 12 0
      frontend/src/lib/inbounds/label.ts
  19. 24 7
      frontend/src/lib/xray/protocol-capabilities.ts
  20. 12 1
      frontend/src/pages/api-docs/endpoints.ts
  21. 20 11
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  22. 20 11
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  23. 8 2
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  24. 9 4
      frontend/src/pages/clients/ClientFormModal.tsx
  25. 2 1
      frontend/src/pages/clients/ClientInfoModal.tsx
  26. 26 14
      frontend/src/pages/clients/ClientsPage.tsx
  27. 2 1
      frontend/src/pages/clients/FilterDrawer.tsx
  28. 3 2
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  29. 2 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  30. 4 0
      frontend/src/pages/inbounds/info/helpers.ts
  31. 22 12
      frontend/src/pages/inbounds/useInbounds.ts
  32. 19 0
      frontend/src/pages/sub/SubPage.tsx
  33. 2 2
      frontend/src/pages/xray/routing/CriterionRow.tsx
  34. 6 2
      frontend/src/pages/xray/routing/RouteTester.tsx
  35. 10 2
      frontend/src/pages/xray/routing/RuleCardList.tsx
  36. 3 8
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  37. 53 2
      frontend/src/pages/xray/routing/helpers.ts
  38. 21 9
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  39. 55 0
      frontend/src/test/client-traffic-display.test.ts
  40. 29 0
      frontend/src/test/inbound-from-db.test.ts
  41. 1 0
      internal/database/db.go
  42. 20 0
      internal/database/model/client_global_traffic.go
  43. 19 0
      internal/sub/service.go
  44. 20 0
      internal/web/controller/inbound.go
  45. 66 0
      internal/web/job/node_traffic_sync_job.go
  46. 15 0
      internal/web/runtime/remote.go
  47. 7 1
      internal/web/service/client_crud.go
  48. 16 8
      internal/web/service/client_flow_isolation_test.go
  49. 1 0
      internal/web/service/client_lookup.go
  50. 17 2
      internal/web/service/client_traffic.go
  51. 142 0
      internal/web/service/global_traffic_test.go
  52. 9 1
      internal/web/service/inbound.go
  53. 28 19
      internal/web/service/inbound_clients.go
  54. 14 2
      internal/web/service/inbound_disable.go
  55. 32 7
      internal/web/service/inbound_node.go
  56. 52 11
      internal/web/service/inbound_protocol.go
  57. 90 0
      internal/web/service/inbound_protocol_test.go
  58. 19 0
      internal/web/service/inbound_traffic.go
  59. 215 0
      internal/web/service/inbound_traffic_global.go
  60. 75 0
      internal/web/service/node_client_traffic_sum_test.go
  61. 15 8
      internal/web/service/setting.go
  62. 5 1
      internal/web/translation/ar-EG.json
  63. 5 1
      internal/web/translation/en-US.json
  64. 5 1
      internal/web/translation/es-ES.json
  65. 5 1
      internal/web/translation/fa-IR.json
  66. 5 1
      internal/web/translation/id-ID.json
  67. 5 1
      internal/web/translation/ja-JP.json
  68. 5 1
      internal/web/translation/pt-BR.json
  69. 5 1
      internal/web/translation/ru-RU.json
  70. 5 2
      internal/web/translation/tr-TR.json
  71. 5 1
      internal/web/translation/uk-UA.json
  72. 5 1
      internal/web/translation/vi-VN.json
  73. 5 1
      internal/web/translation/zh-CN.json
  74. 5 1
      internal/web/translation/zh-TW.json

+ 2 - 1
.env.example

@@ -1,4 +1,5 @@
 XUI_DEBUG=true
 XUI_DB_FOLDER=x-ui
 XUI_LOG_FOLDER=x-ui
-XUI_BIN_FOLDER=x-ui
+XUI_BIN_FOLDER=x-ui
+XUI_INIT_WEB_BASE_PATH=/

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 135 - 56
.github/workflows/claude-issue-bot.yml


+ 2 - 0
CONTRIBUTING.md

@@ -72,6 +72,7 @@ XUI_DEBUG=true
 XUI_DB_FOLDER=x-ui
 XUI_LOG_FOLDER=x-ui
 XUI_BIN_FOLDER=x-ui
+XUI_INIT_WEB_BASE_PATH=/
 ```
 
 Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
@@ -254,6 +255,7 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
 | `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
 | `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
+| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
 | `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
 | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
 

+ 1 - 0
README.ar_EG.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | مجلد ملف قاعدة بيانات SQLite | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | الحد الأقصى للاتصالات المفتوحة (تجمّع PostgreSQL) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | الحد الأقصى للاتصالات الخاملة (تجمّع PostgreSQL) | — |
+| `XUI_INIT_WEB_BASE_PATH` | مسار URI الأولي للوحة الويب | `/` |
 | `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
 | `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
 | `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |

+ 1 - 0
README.es_ES.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | Directorio del archivo de base de datos SQLite | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | Máximo de conexiones abiertas (pool de PostgreSQL) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | Máximo de conexiones inactivas (pool de PostgreSQL) | — |
+| `XUI_INIT_WEB_BASE_PATH` | La ruta URI inicial para el panel web | `/` |
 | `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
 | `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Habilitar el modo de depuración | `false` |

+ 1 - 0
README.fa_IR.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | پوشه‌ی فایل پایگاه‌داده‌ی SQLite | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | حداکثر اتصالات باز (استخر PostgreSQL) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | حداکثر اتصالات بی‌کار (استخر PostgreSQL) | — |
+| `XUI_INIT_WEB_BASE_PATH` | مسیر URI اولیه برای پنل وب | `/` |
 | `XUI_ENABLE_FAIL2BAN` | فعال‌سازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
 | `XUI_LOG_LEVEL` | سطح گزارش‌گیری (`debug`، `info`، `warning`، `error`) | `info` |
 | `XUI_DEBUG` | فعال‌سازی حالت دیباگ | `false` |

+ 1 - 0
README.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | Directory for the SQLite database file | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | Maximum open connections (PostgreSQL pool) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | Maximum idle connections (PostgreSQL pool) | — |
+| `XUI_INIT_WEB_BASE_PATH` | The initial URI path for the web panel | `/` |
 | `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
 | `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Enable debug mode | `false` |

+ 1 - 0
README.ru_RU.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | Каталог для файла базы данных SQLite | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | Максимум открытых соединений (пул PostgreSQL) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | Максимум простаивающих соединений (пул PostgreSQL) | — |
+| `XUI_INIT_WEB_BASE_PATH` | Начальный URI-путь для веб-панели | `/` |
 | `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
 | `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Включить режим отладки | `false` |

+ 1 - 0
README.tr_TR.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | SQLite veritabanı dizini | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bekleme bağlantısı (PostgreSQL havuzu) | — |
+| `XUI_INIT_WEB_BASE_PATH` | Web paneli için başlangıç URI yolu | `/` |
 | `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
 | `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |

+ 1 - 0
README.zh_CN.md

@@ -130,6 +130,7 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_DB_FOLDER` | SQLite 数据库文件所在目录 | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | 最大打开连接数(PostgreSQL 连接池) | — |
 | `XUI_DB_MAX_IDLE_CONNS` | 最大空闲连接数(PostgreSQL 连接池) | — |
+| `XUI_INIT_WEB_BASE_PATH` | Web 面板的初始 URI 路径 | `/` |
 | `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
 | `XUI_LOG_LEVEL` | 日志级别(`debug`、`info`、`warning`、`error`) | `info` |
 | `XUI_DEBUG` | 启用调试模式 | `false` |

+ 1 - 0
docker-compose.yml

@@ -18,6 +18,7 @@ services:
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
+      # XUI_INIT_WEB_BASE_PATH: "/"
       # To use PostgreSQL instead of the default SQLite, run:
       #   docker compose --profile postgres up -d
       # and uncomment the two lines below.

+ 54 - 1
frontend/public/openapi.json

@@ -2185,7 +2185,7 @@
         "tags": [
           "Inbounds"
         ],
-        "summary": "Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.",
+        "summary": "Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.",
         "operationId": "get_panel_api_inbounds_options",
         "responses": {
           "200": {
@@ -2709,6 +2709,59 @@
         }
       }
     },
+    "/panel/api/inbounds/pushClientTraffics": {
+      "post": {
+        "tags": [
+          "Inbounds"
+        ],
+        "summary": "Receive a master panel's aggregated per-client usage, keyed by the master's GUID. Stored in a side table used only for the UI display overlay and local quota enforcement — never folded into the local counters that masters poll, so delta accounting stays intact. Called panel-to-panel by the node traffic sync job.",
+        "operationId": "post_panel_api_inbounds_pushClientTraffics",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "masterGuid": "9f6c2d-…",
+                "traffics": [
+                  {
+                    "email": "alice",
+                    "up": 1048576,
+                    "down": 2097152
+                  }
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/inbounds/{id}/fallbacks": {
       "get": {
         "tags": [

+ 90 - 0
frontend/src/components/clients/ClientTrafficCell.css

@@ -0,0 +1,90 @@
+.client-traffic-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+  box-sizing: border-box;
+  padding: 2px 10px;
+  border-radius: 999px;
+  background: var(--ant-color-fill-quaternary);
+}
+
+.client-traffic-cell.is-compact {
+  gap: 6px;
+  padding: 2px 8px;
+  margin-top: 6px;
+}
+
+.client-traffic-cell-used,
+.client-traffic-cell-limit {
+  flex: 0 0 72px;
+  min-width: 72px;
+  font-size: 12px;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.client-traffic-cell.is-compact .client-traffic-cell-used {
+  flex-basis: 64px;
+  min-width: 64px;
+  font-size: 11px;
+}
+
+.client-traffic-cell-used {
+  text-align: end;
+  color: var(--ant-color-text);
+}
+
+.client-traffic-cell-limit {
+  text-align: start;
+  color: var(--ant-color-text-secondary);
+}
+
+.client-traffic-cell-bar {
+  flex: 1 1 60px;
+  min-width: 48px;
+}
+
+.client-traffic-cell-bar.ant-progress {
+  margin: 0;
+  line-height: 1;
+}
+
+.client-traffic-cell-bar .ant-progress-outer,
+.client-traffic-cell-bar .ant-progress-inner {
+  display: block;
+}
+
+.client-traffic-cell-bar .ant-progress-inner {
+  background: var(--ant-color-fill-secondary);
+}
+
+.client-traffic-cell.is-unlimited .client-traffic-cell-bar .ant-progress-inner .ant-progress-bg {
+  background-color: color-mix(in srgb, #722ed1 35%, transparent);
+  border: 1px solid color-mix(in srgb, #722ed1 55%, transparent);
+}
+
+.client-traffic-cell-infinity {
+  display: inline-flex;
+  align-items: center;
+  justify-content: flex-start;
+  color: var(--ant-color-purple);
+  font-size: 14px;
+  line-height: 1;
+}
+
+.client-traffic-popover table {
+  border-collapse: collapse;
+  width: 100%;
+  font-variant-numeric: tabular-nums;
+}
+
+.client-traffic-popover td {
+  padding: 2px 6px;
+  white-space: nowrap;
+}
+
+.client-traffic-popover td:first-child {
+  color: var(--ant-color-text-secondary);
+}

+ 85 - 0
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -0,0 +1,85 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Popover, Progress } from 'antd';
+
+import InfinityIcon from '@/components/ui/InfinityIcon';
+import { useTheme } from '@/hooks/useTheme';
+import { computeTrafficDisplay } from '@/lib/clients/traffic-display';
+import { SizeFormatter } from '@/utils';
+import './ClientTrafficCell.css';
+
+export interface ClientTrafficCellProps {
+  up?: number;
+  down?: number;
+  total?: number;
+  enabled?: boolean;
+  trafficDiff?: number;
+  compact?: boolean;
+}
+
+export default function ClientTrafficCell({
+  up = 0,
+  down = 0,
+  total = 0,
+  enabled = true,
+  trafficDiff = 0,
+  compact = false,
+}: ClientTrafficCellProps) {
+  const { t } = useTranslation();
+  const { isDark } = useTheme();
+
+  const display = useMemo(
+    () => computeTrafficDisplay({ up, down, total, enabled, trafficDiff }, isDark),
+    [up, down, total, enabled, trafficDiff, isDark],
+  );
+
+  const popover = (
+    <table className="client-traffic-popover">
+      <tbody>
+        <tr>
+          <td>↑</td>
+          <td>{SizeFormatter.sizeFormat(up)}</td>
+          <td>↓</td>
+          <td>{SizeFormatter.sizeFormat(down)}</td>
+        </tr>
+        {!display.isUnlimited && (
+          <tr>
+            <td colSpan={2}>{t('remained')}</td>
+            <td colSpan={2}>{SizeFormatter.sizeFormat(display.remaining)}</td>
+          </tr>
+        )}
+      </tbody>
+    </table>
+  );
+
+  const rootClass = [
+    'client-traffic-cell',
+    compact ? 'is-compact' : '',
+    display.isUnlimited ? 'is-unlimited' : '',
+  ].filter(Boolean).join(' ');
+
+  return (
+    <Popover content={popover} trigger={['hover', 'click']} placement="top">
+      <div className={rootClass}>
+        <span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
+        <Progress
+          className="client-traffic-cell-bar"
+          percent={display.percent}
+          showInfo={false}
+          strokeColor={display.strokeColor}
+          status={display.status}
+          size={compact ? 'small' : 'medium'}
+        />
+        <span className="client-traffic-cell-limit">
+          {display.isUnlimited ? (
+            <span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
+              <InfinityIcon />
+            </span>
+          ) : (
+            SizeFormatter.sizeFormat(total)
+          )}
+        </span>
+      </div>
+    </Popover>
+  );
+}

+ 51 - 0
frontend/src/components/form/SelectAllClearButtons.tsx

@@ -0,0 +1,51 @@
+import { useTranslation } from 'react-i18next';
+import { Button } from 'antd';
+
+interface Option {
+  value: number;
+}
+
+interface SelectAllClearButtonsProps {
+  options: Option[];
+  value: number[];
+  onChange: (value: number[]) => void;
+  /** Override the default "Select all" label (defaults to the inbound copy). */
+  selectAllLabel?: string;
+  /** Override the default "Clear all" label (defaults to the inbound copy). */
+  clearLabel?: string;
+}
+
+export default function SelectAllClearButtons({
+  options,
+  value,
+  onChange,
+  selectAllLabel,
+  clearLabel,
+}: SelectAllClearButtonsProps) {
+  const { t } = useTranslation();
+
+  const optionValues = options.map((o) => o.value);
+  // Treat as "all selected" when every option is chosen, rather than comparing
+  // lengths — this stays correct even if `value` holds ids outside `options`.
+  const allSelected = options.length > 0 && optionValues.every((v) => value.includes(v));
+
+  return (
+    <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+      <Button
+        size="small"
+        disabled={allSelected}
+        // Union with the current value so selections outside `options` are kept.
+        onClick={() => onChange(Array.from(new Set([...value, ...optionValues])))}
+      >
+        {selectAllLabel ?? t('pages.clients.selectAllInbounds')}
+      </Button>
+      <Button
+        size="small"
+        disabled={value.length === 0}
+        onClick={() => onChange([])}
+      >
+        {clearLabel ?? t('pages.clients.clearAllInbounds')}
+      </Button>
+    </div>
+  );
+}

+ 1 - 0
frontend/src/components/form/index.ts

@@ -1,3 +1,4 @@
 export { default as DateTimePicker } from './DateTimePicker';
 export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
+export { default as SelectAllClearButtons } from './SelectAllClearButtons';

+ 64 - 0
frontend/src/lib/clients/traffic-display.ts

@@ -0,0 +1,64 @@
+import { ColorUtils } from '@/utils';
+
+export interface TrafficDisplayInput {
+  up: number;
+  down: number;
+  total: number;
+  enabled: boolean;
+  trafficDiff: number;
+}
+
+export interface TrafficDisplay {
+  used: number;
+  remaining: number;
+  percent: number;
+  isUnlimited: boolean;
+  isDepleted: boolean;
+  strokeColor: string;
+  status: 'normal' | 'exception' | undefined;
+}
+
+const DISABLED_STROKE = {
+  light: '#bcbcbc',
+  dark: 'rgb(72, 84, 105)',
+} as const;
+
+const UNLIMITED_STROKE = '#722ed1';
+
+export function computeTrafficDisplay(
+  input: TrafficDisplayInput,
+  isDark: boolean,
+): TrafficDisplay {
+  const up = input.up || 0;
+  const down = input.down || 0;
+  const used = up + down;
+  const total = input.total || 0;
+  const isUnlimited = total <= 0;
+
+  let percent = 100;
+  if (!isUnlimited) {
+    percent = Math.min(100, Math.max(0, (used / total) * 100));
+  }
+
+  const isDepleted = !isUnlimited && used >= total;
+  const remaining = isUnlimited ? 0 : Math.max(0, total - used);
+
+  let strokeColor: string;
+  if (!input.enabled) {
+    strokeColor = isDark ? DISABLED_STROKE.dark : DISABLED_STROKE.light;
+  } else if (isUnlimited) {
+    strokeColor = UNLIMITED_STROKE;
+  } else {
+    strokeColor = ColorUtils.clientUsageColor({ up, down, total }, input.trafficDiff);
+  }
+
+  return {
+    used,
+    remaining,
+    percent,
+    isUnlimited,
+    isDepleted,
+    strokeColor,
+    status: isDepleted && input.enabled ? 'exception' : undefined,
+  };
+}

+ 12 - 0
frontend/src/lib/inbounds/label.ts

@@ -0,0 +1,12 @@
+/**
+ * Display label for an inbound: `tag (remark)` when a distinct remark exists,
+ * otherwise just the tag. Falls back to the remark when no tag is set, and to an
+ * empty string when neither is present.
+ */
+export function formatInboundLabel(tag?: string, remark?: string): string {
+  const tagText = (tag || '').trim();
+  const remarkText = (remark || '').trim();
+  if (!tagText) return remarkText;
+  if (!remarkText || remarkText === tagText) return tagText;
+  return `${tagText} (${remarkText})`;
+}

+ 24 - 7
frontend/src/lib/xray/protocol-capabilities.ts

@@ -16,16 +16,16 @@ const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
 
 export interface CapabilityProtocolSlice {
   protocol: string;
+  settings?: { encryption?: string; decryption?: string };
   streamSettings?: { network?: string; security?: string };
 }
 
 export interface CapabilityVlessSlice extends CapabilityProtocolSlice {
-  settings?: { clients?: { flow?: string }[] };
+  settings?: { encryption?: string; decryption?: string; clients?: { flow?: string }[] };
 }
 
-export interface CapabilityShadowsocksSlice {
-  protocol: string;
-  settings?: { method?: string };
+export interface CapabilityShadowsocksSlice extends CapabilityProtocolSlice {
+  settings?: { encryption?: string; method?: string };
 }
 
 export function canEnableTls(values: CapabilityProtocolSlice): boolean {
@@ -39,11 +39,28 @@ export function canEnableReality(values: CapabilityProtocolSlice): boolean {
   return REALITY_NETWORKS.includes(values.streamSettings?.network ?? '');
 }
 
+// VLESS encryption (vlessenc / ML-KEM) is on when encryption or decryption holds
+// a generated value (e.g. "mlkem768x25519plus.native.0rtt.<key>") rather than
+// the "none"/"" sentinel. The value is never the literal "vlessenc" (that is the
+// `xray vlessenc` subcommand). decryption is the server-side value; encryption is
+// stored for link generation — either being set means it is on.
+function hasVlessEncryption(settings: CapabilityProtocolSlice['settings']): boolean {
+  const isSet = (v?: string) => v != null && v !== '' && v !== 'none';
+  return isSet(settings?.encryption) || isSet(settings?.decryption);
+}
+
 export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
+  if (values.protocol !== 'vless') return false;
+  const network = values.streamSettings?.network;
   const security = values.streamSettings?.security;
-  if (security !== 'tls' && security !== 'reality') return false;
-  if (values.streamSettings?.network !== 'tcp') return false;
-  return values.protocol === 'vless';
+
+  // Classic XTLS Vision: raw TCP carried over TLS or REALITY.
+  if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true;
+
+  // vlessenc carries Vision over XHTTP without transport TLS.
+  if (network === 'xhttp' && hasVlessEncryption(values.settings)) return true;
+
+  return false;
 }
 
 export function canEnableStream(values: { protocol: string }): boolean {

+ 12 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -122,7 +122,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/inbounds/options',
-        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
+        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
         responseSchema: 'InboundOption',
         responseSchemaArray: true,
       },
@@ -205,6 +205,17 @@ export const sections: readonly Section[] = [
           { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/pushClientTraffics',
+        summary: 'Receive a master panel\'s aggregated per-client usage, keyed by the master\'s GUID. Stored in a side table used only for the UI display overlay and local quota enforcement — never folded into the local counters that masters poll, so delta accounting stays intact. Called panel-to-panel by the node traffic sync job.',
+        params: [
+          { name: 'masterGuid', in: 'body (json)', type: 'string', desc: 'Stable GUID of the pushing master panel.' },
+          { name: 'traffics', in: 'body (json)', type: 'object[]', desc: 'Client traffic rows; only email/up/down are read.' },
+        ],
+        body: '{\n  "masterGuid": "9f6c2d-…",\n  "traffics": [\n    { "email": "alice", "up": 1048576, "down": 2097152 }\n  ]\n}',
+        response: '{\n  "success": true\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/inbounds/:id/fallbacks',

+ 20 - 11
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
+import { SelectAllClearButtons } from '@/components/form';
 import type { InboundOption } from '@/hooks/useClients';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkAttachResult } from '@/schemas/client';
 
 const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
@@ -36,7 +38,7 @@ export default function BulkAttachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
       }));
   }, [inbounds]);
 
@@ -81,16 +83,23 @@ export default function BulkAttachInboundsModal({
         {targetOptions.length === 0 ? (
           <Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
         ) : (
-          <Select
-            mode="multiple"
-            style={{ width: '100%' }}
-            value={targetIds}
-            onChange={setTargetIds}
-            options={targetOptions}
-            placeholder={t('pages.clients.attachToInboundsTargets')}
-            optionFilterProp="label"
-            autoFocus
-          />
+          <>
+            <SelectAllClearButtons
+              options={targetOptions}
+              value={targetIds}
+              onChange={setTargetIds}
+            />
+            <Select
+              mode="multiple"
+              style={{ width: '100%' }}
+              value={targetIds}
+              onChange={setTargetIds}
+              options={targetOptions}
+              placeholder={t('pages.clients.attachToInboundsTargets')}
+              optionFilterProp="label"
+              autoFocus
+            />
+          </>
         )}
       </Modal>
     </>

+ 20 - 11
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
+import { SelectAllClearButtons } from '@/components/form';
 import type { InboundOption } from '@/hooks/useClients';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { BulkDetachResult } from '@/schemas/client';
 
 const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
@@ -36,7 +38,7 @@ export default function BulkDetachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
       }));
   }, [inbounds]);
 
@@ -81,16 +83,23 @@ export default function BulkDetachInboundsModal({
         {targetOptions.length === 0 ? (
           <Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
         ) : (
-          <Select
-            mode="multiple"
-            style={{ width: '100%' }}
-            value={targetIds}
-            onChange={setTargetIds}
-            options={targetOptions}
-            placeholder={t('pages.clients.detachFromInboundsTargets')}
-            optionFilterProp="label"
-            autoFocus
-          />
+          <>
+            <SelectAllClearButtons
+              options={targetOptions}
+              value={targetIds}
+              onChange={setTargetIds}
+            />
+            <Select
+              mode="multiple"
+              style={{ width: '100%' }}
+              value={targetIds}
+              onChange={setTargetIds}
+              options={targetOptions}
+              placeholder={t('pages.clients.detachFromInboundsTargets')}
+              optionFilterProp="label"
+              autoFocus
+            />
+          </>
         )}
       </Modal>
     </>

+ 8 - 2
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -6,8 +6,9 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
 import { RandomUtil, SizeFormatter } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
-import { DateTimePicker } from '@/components/form';
+import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { useClients, type InboundOption } from '@/hooks/useClients';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
@@ -109,7 +110,7 @@ export default function ClientBulkAddModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
       })),
     [inbounds],
@@ -212,6 +213,11 @@ export default function ClientBulkAddModal({
       >
         <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
           <Form.Item label={t('pages.clients.attachedInbounds')} required>
+            <SelectAllClearButtons
+              options={inboundOptions}
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+            />
             <Select
               mode="multiple"
               value={form.inboundIds}

+ 9 - 4
frontend/src/pages/clients/ClientFormModal.tsx

@@ -18,9 +18,9 @@ import {
 import { EyeOutlined, ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-
 import { HttpUtil, RandomUtil } from '@/utils';
-import { DateTimePicker } from '@/components/form';
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
@@ -288,9 +288,9 @@ export default function ClientFormModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.remark?.trim() || ib.tag || '',
+        label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
-        title: ib.remark?.trim() || ib.tag || '',
+        title: formatInboundLabel(ib.tag, ib.remark),
       })),
     [inbounds],
   );
@@ -600,6 +600,11 @@ export default function ClientFormModal({
           </Row>
 
           <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+            <SelectAllClearButtons
+              options={inboundOptions}
+              value={form.inboundIds}
+              onChange={(v) => update('inboundIds', v)}
+            />
             <Select
               mode="multiple"
               value={form.inboundIds}

+ 2 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -4,6 +4,7 @@ import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
 import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
@@ -316,7 +317,7 @@ export default function ClientInfoModal({
                         const ib = inboundsById[id];
                         const proto = (ib?.protocol || '').toLowerCase();
                         const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-                        const label = ib?.remark?.trim() || ib?.tag || '';
+                        const label = formatInboundLabel(ib?.tag, ib?.remark);
                         return (
                           <Tooltip key={id} title={label}>
                             <Tag color={color}>{label}</Tag>

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

@@ -47,11 +47,13 @@ import {
 } from '@ant-design/icons';
 
 import { useTheme } from '@/hooks/useTheme';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
 import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
@@ -302,7 +304,7 @@ export default function ClientsPage() {
 
   function inboundLabel(id: number) {
     const ib = inboundsById[id];
-    return ib?.remark?.trim() || ib?.tag || '';
+    return formatInboundLabel(ib?.tag, ib?.remark);
   }
 
   const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@@ -343,15 +345,6 @@ export default function ClientsPage() {
   // order, so we just hand it through.
   const sortedClients = filteredClients;
 
-  function trafficLabel(row: ClientRecord) {
-    const t0 = row.traffic;
-    if (!t0) return '-';
-    const used = (t0.up || 0) + (t0.down || 0);
-    const total = row.totalGB || 0;
-    if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
-    return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
-  }
-
   function remainingLabel(row: ClientRecord) {
     const total = row.totalGB || 0;
     if (total <= 0) return '∞';
@@ -692,7 +685,7 @@ export default function ClientsPage() {
           const ib = inboundsById[id];
           const proto = (ib?.protocol || '').toLowerCase();
           const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = ib?.remark?.trim() || ib?.tag || '';
+          const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
           return (
             <Tooltip key={id} title={inboundLabel(id)}>
               <Tag color={color} style={{ margin: 2 }}>
@@ -726,7 +719,16 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.traffic'),
       key: 'traffic',
-      render: (_v, record) => trafficLabel(record),
+      width: 240,
+      render: (_v, record) => (
+        <ClientTrafficCell
+          up={record.traffic?.up}
+          down={record.traffic?.down}
+          total={record.totalGB}
+          enabled={record.enable}
+          trafficDiff={trafficDiff}
+        />
+      ),
     },
     {
       title: t('pages.clients.remaining'),
@@ -744,7 +746,7 @@ export default function ClientsPage() {
       ),
     },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff]);
 
   const tablePagination = {
     current: currentPage,
@@ -1139,7 +1141,9 @@ export default function ClientsPage() {
                                       checked={selectedRowKeys.includes(row.email)}
                                       onChange={(e) => toggleSelect(row.email, e.target.checked)}
                                     />
-                                    <Badge status={bucketBadgeStatus(bucket)} />
+                                    {row.enable && bucket !== 'depleted' && isOnline(row.email)
+                                      ? <span className="online-dot" style={{ marginInlineEnd: 0 }} />
+                                      : <Badge status={bucketBadgeStatus(bucket)} />}
                                     <span className="tag-name">{row.email}</span>
                                     {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
                                     {bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
@@ -1186,6 +1190,14 @@ export default function ClientsPage() {
                                       </Dropdown>
                                     </div>
                                   </div>
+                                  <ClientTrafficCell
+                                    compact
+                                    up={row.traffic?.up}
+                                    down={row.traffic?.down}
+                                    total={row.totalGB}
+                                    enabled={row.enable}
+                                    trafficDiff={trafficDiff}
+                                  />
                                 </div>
                               );
                             })}

+ 2 - 1
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 { formatInboundLabel } from '@/lib/inbounds/label';
 import { emptyFilters, type ClientFilters } from './filters';
 
 interface FilterDrawerProps {
@@ -50,7 +51,7 @@ export default function FilterDrawer({
   const inboundOptions = useMemo(
     () => inbounds.map((ib) => ({
       value: ib.id,
-      label: ib.remark?.trim() || ib.tag || '',
+      label: formatInboundLabel(ib.tag, ib.remark),
     })),
     [inbounds],
   );

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

@@ -4,6 +4,7 @@ import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } f
 import type { ColumnsType } from 'antd/es/table';
 
 import { HttpUtil } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { isInboundMultiUser } from '../list';
 
@@ -69,7 +70,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?.trim() || ib.tag || '' }));
+      .map((ib) => ({ value: ib.id, label: formatInboundLabel(ib.tag, ib.remark) }));
   }, [dbInbounds, source]);
 
   const filteredRows = useMemo(() => {
@@ -150,7 +151,7 @@ export default function AttachClientsModal({
       }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark?.trim() || source?.tag || '' })}
+      title={t('pages.inbounds.attachClientsTitle', { remark: formatInboundLabel(source?.tag, source?.remark) })}
       width={680}
     >
       {messageContextHolder}

+ 2 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -4,6 +4,7 @@ import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, messa
 import type { ColumnsType } from 'antd/es/table';
 
 import { HttpUtil } from '@/utils';
+import { formatInboundLabel } from '@/lib/inbounds/label';
 import type { DBInbound } from '@/models/dbinbound';
 
 interface AttachExistingClientsModalProps {
@@ -170,7 +171,7 @@ export default function AttachExistingClientsModal({
       okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachExistingTitle', { remark: target?.remark?.trim() || target?.tag || '' })}
+      title={t('pages.inbounds.attachExistingTitle', { remark: formatInboundLabel(target?.tag, target?.remark) })}
       width={680}
     >
       {messageContextHolder}

+ 4 - 0
frontend/src/pages/inbounds/info/helpers.ts

@@ -121,6 +121,10 @@ export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
     }),
     isVlessTlsFlow: canEnableTlsFlow({
       protocol: dbInbound.protocol,
+      settings: {
+        encryption: settings.encryption as string | undefined,
+        decryption: settings.decryption as string | undefined,
+      },
       streamSettings: { network, security },
     }),
     host: readNetworkHost(stream, network),

+ 22 - 12
frontend/src/pages/inbounds/useInbounds.ts

@@ -225,25 +225,35 @@ export function useInbounds() {
       const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
 
       if (dbInbound.enable) {
+        const statsByEmail = new Map<string, { email: string; total: number; up: number; down: number; expiryTime: number }>();
+        for (const stats of clientStats) {
+          if (stats.email) statsByEmail.set(stats.email.toLowerCase(), stats);
+        }
         for (const client of clients) {
           if (client.comment && client.email) comments.set(client.email, client.comment);
-          if (client.enable) {
-            if (client.email) active.push(client.email);
-            if (client.email && inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
-          } else if (client.email) {
+          if (!client.email) continue;
+          const stats = statsByEmail.get(client.email.toLowerCase());
+          const exhausted = stats != null && stats.total > 0 && stats.up + stats.down >= stats.total;
+          const expired = stats != null && stats.expiryTime > 0 && stats.expiryTime <= now;
+          // Depleted wins over disabled (same priority as computeClientsSummary):
+          // the auto-disable job also flips client.enable off in settings when a
+          // client ends, so checking enable first would file every ended client
+          // under "Disabled".
+          if (expired || exhausted) {
+            depleted.push(client.email);
+            continue;
+          }
+          if (!client.enable) {
             deactive.push(client.email);
+            continue;
           }
-        }
-        for (const stats of clientStats) {
-          const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
-          const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
-          if (expired || exhausted) {
-            depleted.push(stats.email);
-          } else {
+          active.push(client.email);
+          if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
+          if (stats) {
             const expiringSoon =
               (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
               (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
-            if (expiringSoon) expiring.push(stats.email);
+            if (expiringSoon) expiring.push(client.email);
           }
         }
       } else {

+ 19 - 0
frontend/src/pages/sub/SubPage.tsx

@@ -111,6 +111,13 @@ export default function SubPage() {
     if (ok) messageApi.success(t('copied'));
   }, [t, messageApi]);
 
+  const copyAll = useCallback(async () => {
+    if (links.length === 0) return;
+    const allLinks = links.join('\n');
+    const ok = await ClipboardManager.copyText(allLinks);
+    if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
+  }, [t, messageApi]);
+
   const open = useCallback((url: string) => {
     if (!url) return;
     window.open(url, '_blank');
@@ -393,6 +400,18 @@ export default function SubPage() {
                   <>
                     <Divider>{t('pages.inbounds.copyLink')}</Divider>
                     <div className="links-section">
+                      <div className="sub-link-row">
+                        <span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
+                        <div className="sub-link-actions">
+                          <Button
+                            size="small"
+                            icon={<CopyOutlined />}
+                            onClick={copyAll}
+                            aria-label={t('subscription.copyAllConfigs')}
+                            title={t('subscription.copyAllConfigs')}
+                          />
+                        </div>
+                      </div>
                       {links.map((link, idx) => {
                         const parts = parseLinkParts(link, linkEmails[idx] || '');
                         const fallback = `Link ${idx + 1}`;

+ 2 - 2
frontend/src/pages/xray/routing/CriterionRow.tsx

@@ -2,8 +2,8 @@ import { Tooltip } from 'antd';
 
 import { csv } from './helpers';
 
-export default function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
-  const parts = csv(value);
+export default function CriterionRow({ label, value, values, title }: { label: string; value?: string; values?: string[]; title: string }) {
+  const parts = values ?? csv(value);
   if (parts.length === 0) return null;
   return (
     <Tooltip title={title}>

+ 6 - 2
frontend/src/pages/xray/routing/RouteTester.tsx

@@ -1,9 +1,11 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
 import { AimOutlined } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { buildRemarkByTag, formatInboundTag } from './helpers';
 
 interface RouteTesterProps {
   inboundTags: string[];
@@ -21,6 +23,8 @@ const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ lab
 
 export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   const [dest, setDest] = useState('');
   const [port, setPort] = useState<number | null>(443);
   const [network, setNetwork] = useState('tcp');
@@ -97,7 +101,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
             allowClear
             value={inboundTag}
             onChange={setInboundTag}
-            options={inboundTags.filter(Boolean).map((tag) => ({ label: tag, value: tag }))}
+            options={inboundTags.filter(Boolean).map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
           />
         </Col>
         <Col xs={12} sm={4}>

+ 10 - 2
frontend/src/pages/xray/routing/RuleCardList.tsx

@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Tag, Tooltip } from 'antd';
 import {
@@ -11,7 +12,8 @@ import {
   HolderOutlined,
 } from '@ant-design/icons';
 
-import { chipPreview, ruleCriteriaChips } from './helpers';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
 import type { RuleRow } from './types';
 
 interface RuleCardListProps {
@@ -36,6 +38,8 @@ export default function RuleCardList({
   confirmDelete,
 }: RuleCardListProps) {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   return (
     <div className="rule-list">
       {rows.length === 0 ? (
@@ -74,7 +78,11 @@ export default function RuleCardList({
               <div className="flow-side">
                 <span className="flow-label">{t('pages.xray.Inbounds')}</span>
                 {rule.inboundTag ? (
-                  <Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
+                  <Tooltip title={inboundTagsDisplayTitle(rule.inboundTag, remarkByTag)}>
+                    <Tag color="blue" className="flow-tag">
+                      {inboundTagChipPreview(rule.inboundTag, remarkByTag)}
+                    </Tag>
+                  </Tooltip>
                 ) : (
                   <span className="criterion-empty">any</span>
                 )}

+ 3 - 8
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -5,6 +5,7 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
 import { InputAddon } from '@/components/ui';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
+import { buildRemarkByTag, formatInboundTag } from './helpers';
 
 export interface RoutingRule {
   type?: string;
@@ -74,13 +75,7 @@ export default function RuleFormModal({
   const isEdit = rule != null;
 
   const { data: inboundOptions } = useInboundOptions();
-  const remarkByTag = useMemo(() => {
-    const map: Record<string, string> = {};
-    for (const ib of inboundOptions || []) {
-      if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
-    }
-    return map;
-  }, [inboundOptions]);
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
 
   useEffect(() => {
     if (!open) return;
@@ -279,7 +274,7 @@ export default function RuleFormModal({
             mode="multiple"
             value={form.inboundTag}
             onChange={(v) => update('inboundTag', v)}
-            options={inboundTags.map((tag) => ({ value: tag, label: remarkByTag[tag] || tag }))}
+            options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
           />
         </Form.Item>
 

+ 53 - 2
frontend/src/pages/xray/routing/helpers.ts

@@ -11,13 +11,64 @@ export function csv(value?: string): string[] {
   return String(value).split(',').map((s) => s.trim()).filter(Boolean);
 }
 
-export function chipPreview(value?: string): string {
-  const parts = csv(value);
+export function chipPreviewParts(parts: string[]): string {
   if (parts.length === 0) return '';
   if (parts.length === 1) return parts[0];
   return `${parts[0]} +${parts.length - 1}`;
 }
 
+export function chipPreview(value?: string): string {
+  return chipPreviewParts(csv(value));
+}
+
+/** Same lookup as RuleFormModal inbound select: remark first, else tag. */
+export function buildRemarkByTag(
+  options: Array<{ tag?: string; remark?: string }>,
+): Record<string, string> {
+  const map: Record<string, string> = {};
+  for (const ib of options) {
+    if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
+  }
+  return map;
+}
+
+/** Format a single inbound tag as `tag (remark)`, or just `tag` when no distinct remark. */
+export function formatInboundTag(
+  tag: string,
+  remarkByTag: Record<string, string> = {},
+): string {
+  const label = remarkByTag[tag]?.trim();
+  if (!label || label === tag) return tag;
+  return `${tag} (${label})`;
+}
+
+/**
+ * Formatted inbound entries — `tag (remark)` when a distinct remark exists, else
+ * `tag`. Returns an array (not a joined string) so callers never have to re-split
+ * on commas, which a remark may legitimately contain.
+ */
+export function formatInboundTagList(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string[] {
+  return csv(tags).map((tag) => formatInboundTag(tag, remarkByTag));
+}
+
+export function inboundTagsDisplayTitle(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string | undefined {
+  const list = formatInboundTagList(tags, remarkByTag);
+  return list.length > 0 ? list.join(', ') : undefined;
+}
+
+export function inboundTagChipPreview(
+  tags?: string,
+  remarkByTag: Record<string, string> = {},
+): string {
+  return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
+}
+
 export function ruleCriteriaChips(rule: RuleRow) {
   const chips: { label: string; value?: string }[] = [];
   if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });

+ 21 - 9
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -13,7 +13,9 @@ import {
 } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import CriterionRow from './CriterionRow';
+import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
 import type { RuleRow } from './types';
 
 interface RoutingColumnsParams {
@@ -40,6 +42,8 @@ export function useRoutingColumns({
   confirmDelete,
 }: RoutingColumnsParams): ColumnsType<RuleRow> {
   const { t } = useTranslation();
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
   return useMemo(
     () => [
       {
@@ -131,13 +135,22 @@ export function useRoutingColumns({
         align: 'left',
         width: 180,
         key: 'inbound',
-        render: (_v, record) => (
-          <div className="criterion-flow">
-            {record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
-            {record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
-            {!record.inboundTag && !record.user && <span className="criterion-empty">—</span>}
-          </div>
-        ),
+        render: (_v, record) => {
+          const inboundParts = formatInboundTagList(record.inboundTag, remarkByTag);
+          return (
+            <div className="criterion-flow">
+              {inboundParts.length > 0 && (
+                <CriterionRow
+                  label="Tag"
+                  values={inboundParts}
+                  title={`Inbound tag: ${inboundTagsDisplayTitle(record.inboundTag, remarkByTag) ?? inboundParts.join(', ')}`}
+                />
+              )}
+              {record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
+              {inboundParts.length === 0 && !record.user && <span className="criterion-empty">—</span>}
+            </div>
+          );
+        },
       },
       {
         title: t('pages.xray.Outbounds'),
@@ -171,7 +184,6 @@ export function useRoutingColumns({
           ),
       },
     ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, isMobile, rowsLength, showSource, showBalancer],
+    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
   );
 }

+ 55 - 0
frontend/src/test/client-traffic-display.test.ts

@@ -0,0 +1,55 @@
+import { describe, it, expect } from 'vitest';
+
+import { computeTrafficDisplay } from '@/lib/clients/traffic-display';
+
+describe('computeTrafficDisplay', () => {
+  const gb = 1024 * 1024 * 1024;
+
+  it('returns 50% for half-used limited quota', () => {
+    const d = computeTrafficDisplay(
+      { up: 0.25 * gb, down: 0.25 * gb, total: gb, enabled: true, trafficDiff: 0 },
+      false,
+    );
+    expect(d.percent).toBe(50);
+    expect(d.isUnlimited).toBe(false);
+    expect(d.remaining).toBe(0.5 * gb);
+  });
+
+  it('returns 100% bar for unlimited clients', () => {
+    const d = computeTrafficDisplay(
+      { up: 5 * gb, down: 2 * gb, total: 0, enabled: true, trafficDiff: 0 },
+      false,
+    );
+    expect(d.percent).toBe(100);
+    expect(d.isUnlimited).toBe(true);
+    expect(d.strokeColor).toBe('#722ed1');
+  });
+
+  it('marks depleted clients with exception status', () => {
+    const d = computeTrafficDisplay(
+      { up: gb, down: 0, total: gb, enabled: true, trafficDiff: 0 },
+      false,
+    );
+    expect(d.isDepleted).toBe(true);
+    expect(d.status).toBe('exception');
+    expect(d.percent).toBe(100);
+  });
+
+  it('uses gray stroke when client is disabled', () => {
+    const d = computeTrafficDisplay(
+      { up: 0.5 * gb, down: 0, total: gb, enabled: false, trafficDiff: 0 },
+      false,
+    );
+    expect(d.strokeColor).toBe('#bcbcbc');
+    expect(d.status).toBeUndefined();
+  });
+
+  it('uses warning color near traffic limit', () => {
+    const diff = 0.1 * gb;
+    const d = computeTrafficDisplay(
+      { up: 0.95 * gb, down: 0, total: gb, enabled: true, trafficDiff: diff },
+      false,
+    );
+    expect(d.strokeColor).toBe('#faad14');
+  });
+});

+ 29 - 0
frontend/src/test/inbound-from-db.test.ts

@@ -180,6 +180,35 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
       streamSettings: { network: 'tcp', security: 'tls' },
     })).toBe(false);
   });
+
+  it('canEnableTlsFlow allows vless + xhttp when vlessenc encryption is set', () => {
+    const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
+    const dec = 'mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E';
+    // XHTTP + a real (generated) encryption value → Vision flow allowed.
+    expect(canEnableTlsFlow({
+      protocol: 'vless',
+      settings: { encryption: enc },
+      streamSettings: { network: 'xhttp', security: 'none' },
+    })).toBe(true);
+    // decryption alone (server-side value) is enough on XHTTP.
+    expect(canEnableTlsFlow({
+      protocol: 'vless',
+      settings: { decryption: dec, encryption: 'none' },
+      streamSettings: { network: 'xhttp', security: 'none' },
+    })).toBe(true);
+    // No encryption → stays gated off.
+    expect(canEnableTlsFlow({
+      protocol: 'vless',
+      settings: { encryption: 'none' },
+      streamSettings: { network: 'xhttp', security: 'none' },
+    })).toBe(false);
+    // vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
+    expect(canEnableTlsFlow({
+      protocol: 'vless',
+      settings: { decryption: dec, encryption: enc },
+      streamSettings: { network: 'tcp', security: 'none' },
+    })).toBe(false);
+  });
 });
 
 describe('getInboundClients with schema-shaped inbound', () => {

+ 1 - 0
internal/database/db.go

@@ -72,6 +72,7 @@ func initModels() error {
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
+		&model.ClientGlobalTraffic{},
 		&model.OutboundSubscription{},
 	}
 	for _, mdl := range models {

+ 20 - 0
internal/database/model/client_global_traffic.go

@@ -0,0 +1,20 @@
+package model
+
+// ClientGlobalTraffic mirrors a master panel's aggregated (global) usage for a
+// client hosted on this panel. Masters push one row per (master, email) so the
+// node can display the client's true cross-panel total and enforce its quota
+// locally. The values never feed back into client_traffics — that table keeps
+// this panel's local-only counters, which is what keeps every master's
+// delta-baseline accounting over our snapshot correct.
+//
+// Rows are overwritten in place on every push (not max-merged), so a traffic
+// reset on the master propagates here within one push cycle. Readers that need
+// a single number fold the per-master rows with MAX.
+type ClientGlobalTraffic struct {
+	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	MasterGuid string `json:"masterGuid" gorm:"uniqueIndex:idx_master_email,priority:1;not null"`
+	Email      string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;not null"`
+	Up         int64  `json:"up"`
+	Down       int64  `json:"down"`
+	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+}

+ 19 - 0
internal/sub/service.go

@@ -445,6 +445,20 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 	return buildVmessLink(obj)
 }
 
+// vlessEncryptionEnabled reports whether the VLESS inbound settings enable
+// VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
+// fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
+// "none" or empty means off. The value is never the literal "vlessenc" — that is
+// the `xray vlessenc` CLI subcommand name, not a stored value.
+func vlessEncryptionEnabled(settings map[string]any) bool {
+	for _, key := range []string{"encryption", "decryption"} {
+		if v, ok := settings[key].(string); ok && v != "" && v != "none" {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	if inbound.Protocol != model.VLESS {
 		return ""
@@ -484,6 +498,11 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 		}
 	default:
 		params["security"] = "none"
+		// VLESS encryption (vlessenc / ML-KEM) carries XTLS Vision over XHTTP
+		// without transport TLS.
+		if streamNetwork == "xhttp" && len(clients[clientIndex].Flow) > 0 && vlessEncryptionEnabled(settings) {
+			params["flow"] = clients[clientIndex].Flow
+		}
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)

+ 20 - 0
internal/web/controller/inbound.go

@@ -11,6 +11,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/gin-gonic/gin"
 )
@@ -77,6 +78,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/import", a.importInbound)
 	g.POST("/:id/fallbacks", a.setFallbacks)
+	g.POST("/pushClientTraffics", a.pushClientTraffics)
 }
 
 // getInbounds retrieves the list of inbounds for the logged-in user.
@@ -339,6 +341,24 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
 }
 
+// pushClientTraffics receives a master panel's aggregated per-client usage
+// (see InboundService.AcceptGlobalTraffic for the storage semantics).
+func (a *InboundController) pushClientTraffics(c *gin.Context) {
+	var req struct {
+		MasterGuid string                `json:"masterGuid"`
+		Traffics   []*xray.ClientTraffic `json:"traffics"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, "success", nil)
+}
+
 // importInbound imports an inbound configuration from provided data.
 func (a *InboundController) importInbound(c *gin.Context) {
 	inbound := &model.Inbound{}

+ 66 - 0
internal/web/job/node_traffic_sync_job.go

@@ -2,6 +2,7 @@ package job
 
 import (
 	"context"
+	"strings"
 	"sync"
 	"time"
 
@@ -10,6 +11,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 const (
@@ -17,6 +19,7 @@ const (
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
 	nodeReconcileTimeout          = 30 * time.Second
 	nodeClientIpSyncInterval      = 10 * time.Second
+	nodeGlobalPushInterval        = 30 * time.Second
 )
 
 type NodeTrafficSyncJob struct {
@@ -28,6 +31,8 @@ type NodeTrafficSyncJob struct {
 	structural     atomicBool
 	ipSyncMu       sync.Mutex
 	lastIpSync     int64
+	globalPushMu   sync.Mutex
+	lastGlobalPush int64
 }
 
 type atomicBool struct {
@@ -115,6 +120,8 @@ func (j *NodeTrafficSyncJob) Run() {
 		j.structural.set()
 	}
 
+	j.maybePushGlobals(mgr, nodes)
+
 	lastOnline, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 		logger.Warning("node traffic sync: get last-online failed:", err)
@@ -164,6 +171,65 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 }
 
+// maybePushGlobals broadcasts this panel's aggregated per-client usage to its
+// online nodes so each node can display the client's cross-panel total and
+// enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped
+// per node to the clients that node actually hosts, and throttled — the
+// aggregates only need to reach nodes on a human timescale, not every poll.
+func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*model.Node) {
+	j.globalPushMu.Lock()
+	now := time.Now().Unix()
+	if now-j.lastGlobalPush < int64(nodeGlobalPushInterval/time.Second) {
+		j.globalPushMu.Unlock()
+		return
+	}
+	j.lastGlobalPush = now
+	j.globalPushMu.Unlock()
+
+	masterGuid, err := j.settingService.GetPanelGuid()
+	if err != nil || masterGuid == "" {
+		return
+	}
+
+	sem := make(chan struct{}, nodeTrafficSyncConcurrency)
+	var wg sync.WaitGroup
+	for _, n := range nodes {
+		if !n.Enable || n.Status != "online" {
+			continue
+		}
+		remote, err := mgr.RemoteFor(n)
+		if err != nil {
+			continue
+		}
+		traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
+		if err != nil {
+			logger.Warning("node traffic sync: load globals for", n.Name, "failed:", err)
+			continue
+		}
+		if len(traffics) == 0 {
+			continue
+		}
+		wg.Add(1)
+		sem <- struct{}{}
+		go func(n *model.Node, remote *runtime.Remote, traffics []*xray.ClientTraffic) {
+			defer wg.Done()
+			defer func() { <-sem }()
+			ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
+			defer cancel()
+			if err := remote.PushGlobalClientTraffics(ctx, masterGuid, traffics); err != nil {
+				// An old-build node without the endpoint answers 404 — not worth a
+				// warning every cycle.
+				if strings.Contains(err.Error(), "HTTP 404") {
+					logger.Debug("node traffic sync: node", n.Name, "has no global-traffic endpoint (old build)")
+				} else {
+					logger.Warning("node traffic sync: push globals to", n.Name, "failed:", err)
+				}
+			}
+		}(n, remote, traffics)
+	}
+	wg.Wait()
+}
+
 func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
 	rt, err := mgr.RemoteFor(n)
 	if err != nil {

+ 15 - 0
internal/web/runtime/remote.go

@@ -18,6 +18,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 const remoteHTTPTimeout = 10 * time.Second
@@ -452,6 +453,20 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
 	return snap, nil
 }
 
+// PushGlobalClientTraffics sends this panel's aggregated per-client usage to
+// the node, tagged with this panel's GUID so the node keeps one row per
+// pushing master. Display/enforcement input on the node only — the node never
+// folds these into the counters it reports back, so this panel's (and any
+// other master's) delta accounting over the node snapshot stays intact.
+func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string, traffics []*xray.ClientTraffic) error {
+	payload := map[string]any{
+		"masterGuid": masterGuid,
+		"traffics":   traffics,
+	}
+	_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/pushClientTraffics", payload)
+	return err
+}
+
 func wireInbound(ib *model.Inbound) url.Values {
 	v := url.Values{}
 	v.Set("total", strconv.FormatInt(ib.Total, 10))

+ 7 - 1
internal/web/service/client_crud.go

@@ -146,7 +146,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
 }
 
 func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
-	if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) {
+	if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
 		c.Flow = ""
 	}
 	return c
@@ -411,6 +411,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return needRestart, err
 		}
+		if err := clearGlobalTraffic(db, existing.Email); err != nil {
+			return needRestart, err
+		}
 		if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
 			return needRestart, err
 		}
@@ -550,6 +553,9 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 		if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return needRestart, err
 		}
+		if err := clearGlobalTraffic(db, email); err != nil {
+			return needRestart, err
+		}
 		if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
 			return needRestart, err
 		}

+ 16 - 8
internal/web/service/client_flow_isolation_test.go

@@ -10,23 +10,31 @@ import (
 
 func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
 	const vision = "xtls-rprx-vision"
+	const enc = `{"encryption":"mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y"}`
 	cases := []struct {
 		name           string
 		protocol       model.Protocol
 		streamSettings string
+		settings       string
 		wantFlow       string
 	}{
-		{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, vision},
-		{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, vision},
-		{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, ""},
-		{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, ""},
-		{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, ""},
-		{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, ""},
-		{"empty stream clears flow", model.VLESS, "", ""},
+		{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, "", vision},
+		{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, "", vision},
+		{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, "", ""},
+		{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, "", ""},
+		{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, "", ""},
+		{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, "", ""},
+		{"empty stream clears flow", model.VLESS, "", "", ""},
+		// vlessenc (ML-KEM) keeps Vision flow without transport TLS only on XHTTP.
+		// TCP without tls/reality clears it even with vlessenc set.
+		{"vless tcp vlessenc clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, enc, ""},
+		{"vless xhttp vlessenc keeps flow", model.VLESS, `{"network":"xhttp","security":"none"}`, enc, vision},
+		{"vless xhttp no encryption clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, `{"encryption":"none"}`, ""},
+		{"vless xhttp empty settings clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, "", ""},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings}
+			ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings, Settings: tc.settings}
 			got := clientWithInboundFlow(model.Client{Email: "[email protected]", Flow: vision}, ib)
 			if got.Flow != tc.wantFlow {
 				t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow)

+ 1 - 0
internal/web/service/client_lookup.go

@@ -125,6 +125,7 @@ func (s *ClientService) List() ([]ClientWithAttachments, error) {
 			}
 			stats = append(stats, batchStats...)
 		}
+		overlayGlobalTrafficValues(db, stats)
 		for i := range stats {
 			trafficByEmail[stats[i].Email] = &stats[i]
 		}

+ 17 - 2
internal/web/service/client_traffic.go

@@ -101,7 +101,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 				}
 				affected += int(res.RowsAffected)
 			}
-			return nil
+			return clearGlobalTraffic(tx, cleanEmails...)
 		})
 	})
 	if err != nil {
@@ -128,6 +128,13 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 			whereText += " = ?"
 		}
 
+		var resetEmails []string
+		if err := tx.Model(xray.ClientTraffic{}).
+			Where(whereText, id).
+			Pluck("email", &resetEmails).Error; err != nil {
+			return err
+		}
+
 		result := tx.Model(xray.ClientTraffic{}).
 			Where(whereText, id).
 			Updates(map[string]any{"enable": true, "up": 0, "down": 0})
@@ -136,6 +143,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 			return result.Error
 		}
 
+		if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
+			return err
+		}
+
 		inboundWhereText := "id "
 		if id == -1 {
 			inboundWhereText += " > ?"
@@ -155,11 +166,15 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 }
 
 func (s *ClientService) ResetAllTraffics() (bool, error) {
-	res := database.GetDB().Model(&xray.ClientTraffic{}).
+	db := database.GetDB()
+	res := db.Model(&xray.ClientTraffic{}).
 		Where("1 = 1").
 		Updates(map[string]any{"up": 0, "down": 0})
 	if res.Error != nil {
 		return false, res.Error
 	}
+	if err := db.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
+		return false, err
+	}
 	return res.RowsAffected > 0, nil
 }

+ 142 - 0
internal/web/service/global_traffic_test.go

@@ -0,0 +1,142 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func seedClientRow(t *testing.T, email string, inboundId int, up, down, total int64) {
+	t.Helper()
+	db := database.GetDB()
+	if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: true, Up: up, Down: down, Total: total}).Error; err != nil {
+		t.Fatalf("seed client_traffics %q: %v", email, err)
+	}
+}
+
+func TestAcceptGlobalTraffic_SideTableOnly(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+	seedClientRow(t, "alice", 1, 100, 100, 0)
+
+	err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{
+		{Email: "alice", Up: 900, Down: 800},
+		{Email: "ghost", Up: 5, Down: 5}, // not hosted here — must be dropped
+	})
+	if err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+
+	local := readTraffic(t, db, "alice")
+	if local.Up != 100 || local.Down != 100 {
+		t.Errorf("local counters must stay pure, got up=%d down=%d", local.Up, local.Down)
+	}
+	var globals []model.ClientGlobalTraffic
+	if err := db.Find(&globals).Error; err != nil {
+		t.Fatalf("read globals: %v", err)
+	}
+	if len(globals) != 1 || globals[0].Email != "alice" || globals[0].Up != 900 || globals[0].Down != 800 {
+		t.Errorf("unexpected globals: %+v", globals)
+	}
+}
+
+func TestAcceptGlobalTraffic_OverwriteAndMultiMaster(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+	seedClientRow(t, "alice", 1, 0, 0, 0)
+
+	must := func(guid string, up, down int64) {
+		t.Helper()
+		if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: "alice", Up: up, Down: down}}); err != nil {
+			t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
+		}
+	}
+	must("master-a", 900, 900)
+	must("master-a", 50, 50) // a master-side reset propagates by overwrite
+	must("master-b", 500, 400)
+
+	rows := []*xray.ClientTraffic{{Email: "alice", Up: 10, Down: 10}}
+	overlayGlobalTraffic(db, rows)
+	if rows[0].Up != 500 || rows[0].Down != 400 {
+		t.Errorf("overlay should fold per-master max, got up=%d down=%d", rows[0].Up, rows[0].Down)
+	}
+}
+
+func TestGlobalUsage_DisablesClient(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+	// 200 of 1000 used locally — local check alone would never trip.
+	seedClientRow(t, "cap", 1, 100, 100, 1000)
+
+	if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "cap", Up: 800, Down: 700}}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+
+	if _, count, _, err := svc.disableInvalidClients(db); err != nil {
+		t.Fatalf("disableInvalidClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("expected 1 client disabled, got %d", count)
+	}
+	if got := readTraffic(t, db, "cap"); got.Enable {
+		t.Error("client should be disabled by global usage exceeding its quota")
+	}
+}
+
+func TestGlobalRows_ClearedOnReset(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+	seedClientRow(t, "alice", 1, 50, 50, 1000)
+	if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "alice", Up: 999, Down: 999}}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+	if err := svc.ResetClientTrafficByEmail("alice"); err != nil {
+		t.Fatalf("ResetClientTrafficByEmail: %v", err)
+	}
+	var cnt int64
+	db.Model(&model.ClientGlobalTraffic{}).Count(&cnt)
+	if cnt != 0 {
+		t.Errorf("global rows should be cleared on reset, found %d", cnt)
+	}
+}
+
+// The full inbound list doubles as the traffic snapshot masters poll, so it
+// must report pure local counters; the slim list only feeds this panel's UI,
+// so it carries the cross-panel overlay.
+func TestSnapshotListNotOverlaid_SlimOverlaid(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	settings := `{"clients": [{"email": "alice", "enable": true}]}`
+	ib := &model.Inbound{UserId: 1, Tag: "in-a", Enable: true, Port: 42001, Protocol: model.VLESS, Settings: settings}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	seedClientRow(t, "alice", ib.Id, 100, 100, 0)
+	if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "alice", Up: 900, Down: 900}}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+
+	full, err := svc.GetInbounds(1)
+	if err != nil {
+		t.Fatalf("GetInbounds: %v", err)
+	}
+	if len(full) != 1 || len(full[0].ClientStats) != 1 {
+		t.Fatalf("unexpected full list shape: %d inbounds", len(full))
+	}
+	if full[0].ClientStats[0].Up != 100 {
+		t.Errorf("full list (master snapshot) must stay un-overlaid, got up=%d", full[0].ClientStats[0].Up)
+	}
+
+	slim, err := svc.GetInboundsSlim(1)
+	if err != nil {
+		t.Fatalf("GetInboundsSlim: %v", err)
+	}
+	if len(slim) != 1 || len(slim[0].ClientStats) != 1 {
+		t.Fatalf("unexpected slim list shape: %d inbounds", len(slim))
+	}
+	if slim[0].ClientStats[0].Up != 900 {
+		t.Errorf("slim list should carry the global overlay, got up=%d", slim[0].ClientStats[0].Up)
+	}
+}

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

@@ -78,6 +78,13 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
 	}
 	s.annotateFallbackParents(db, inbounds)
 	s.annotateLocalOriginGuid(inbounds)
+	// Top up stats rows owned by sibling inbounds (multi-attached clients)
+	// so the list's depleted/expiring badges see every client; the UUID/SubId
+	// enrichment stays skipped. Must run before slimming strips the settings.
+	s.backfillClientStats(db, inbounds)
+	// Slim feeds the panel UI only (masters poll the full list), so the badge
+	// math may see the cross-panel totals a master pushed.
+	s.overlayInboundsClientStats(db, inbounds)
 	for _, ib := range inbounds {
 		ib.Settings = slimSettingsClients(ib.Settings)
 	}
@@ -196,7 +203,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Tag:            r.Tag,
 			Protocol:       r.Protocol,
 			Port:           r.Port,
-			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
+			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:       inboundShadowsocksMethod(r.Protocol, r.Settings),
 		})
 	}
@@ -567,6 +574,7 @@ func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
 		return nil, err
 	}
 	s.enrichClientStats(db, []*model.Inbound{inbound})
+	s.overlayInboundsClientStats(db, []*model.Inbound{inbound})
 	return inbound, nil
 }
 

+ 28 - 19
internal/web/service/inbound_clients.go

@@ -31,6 +31,31 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
 	if len(inbounds) == 0 {
 		return
 	}
+	clientsByInbound := s.backfillClientStats(db, inbounds)
+	for i, inbound := range inbounds {
+		clients := clientsByInbound[i]
+		if len(clients) == 0 || len(inbound.ClientStats) == 0 {
+			continue
+		}
+		cMap := make(map[string]model.Client, len(clients))
+		for _, c := range clients {
+			cMap[strings.ToLower(c.Email)] = c
+		}
+		for j := range inbound.ClientStats {
+			email := strings.ToLower(inbound.ClientStats[j].Email)
+			if c, ok := cMap[email]; ok {
+				inbound.ClientStats[j].UUID = c.ID
+				inbound.ClientStats[j].SubId = c.SubID
+			}
+		}
+	}
+}
+
+// backfillClientStats tops up each inbound's preloaded ClientStats with rows
+// owned by a sibling inbound: client_traffics is keyed on email, so a client
+// attached to several inbounds has one row that only preloads on the inbound
+// it was created on. Returns the parsed clients per inbound for reuse.
+func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbound) [][]model.Client {
 	clientsByInbound := make([][]model.Client, len(inbounds))
 	seenByInbound := make([]map[string]struct{}, len(inbounds))
 	missing := make(map[string]struct{})
@@ -69,7 +94,7 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
 			extra = append(extra, page...)
 		}
 		if loadErr != nil {
-			logger.Warning("enrichClientStats:", loadErr)
+			logger.Warning("backfillClientStats:", loadErr)
 		} else {
 			byEmail := make(map[string]xray.ClientTraffic, len(extra))
 			for _, st := range extra {
@@ -92,23 +117,7 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
 			}
 		}
 	}
-	for i, inbound := range inbounds {
-		clients := clientsByInbound[i]
-		if len(clients) == 0 || len(inbound.ClientStats) == 0 {
-			continue
-		}
-		cMap := make(map[string]model.Client, len(clients))
-		for _, c := range clients {
-			cMap[strings.ToLower(c.Email)] = c
-		}
-		for j := range inbound.ClientStats {
-			email := strings.ToLower(inbound.ClientStats[j].Email)
-			if c, ok := cMap[email]; ok {
-				inbound.ClientStats[j].UUID = c.ID
-				inbound.ClientStats[j].SubId = c.SubID
-			}
-		}
-	}
+	return clientsByInbound
 }
 
 // emailUsedByOtherInbounds reports whether email lives in any inbound other
@@ -210,7 +219,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
 	case model.VLESS:
 		target.ID = s.generateRandomCredential(targetProtocol)
 		if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
-			inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings) {
+			inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) {
 			target.Flow = flow
 		}
 	case model.Trojan, model.Shadowsocks:

+ 14 - 2
internal/web/service/inbound_disable.go

@@ -48,13 +48,25 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 	return needRestart, count, err
 }
 
+// depletedClientsCond matches clients that exhausted their quota or expired.
+// Besides the local counters it also trips on the cross-panel usage a master
+// pushed into client_global_traffics — that's what lets a node cut a client
+// whose combined usage exceeds the quota even though the local share doesn't
+// (placeholders: now).
+const depletedClientsCond = `((total > 0 AND up + down >= total)
+	OR (expiry_time > 0 AND expiry_time <= ?)
+	OR (total > 0 AND EXISTS (
+		SELECT 1 FROM client_global_traffics g
+		WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
+	)))`
+
 func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
 	now := time.Now().Unix() * 1000
 	needRestart := false
 
 	var depletedRows []xray.ClientTraffic
 	err := tx.Model(xray.ClientTraffic{}).
-		Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
+		Where(depletedClientsCond+" AND enable = ?", now, true).
 		Find(&depletedRows).Error
 	if err != nil {
 		return false, 0, nil, err
@@ -130,7 +142,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 	}
 
 	result := tx.Model(xray.ClientTraffic{}).
-		Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
+		Where(depletedClientsCond+" AND enable = ?", now, true).
 		Update("enable", false)
 	err = result.Error
 	count := result.RowsAffected

+ 32 - 7
internal/web/service/inbound_node.go

@@ -411,10 +411,27 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					return false, err
 				}
 			}
-		}
-		if err := tx.Where("inbound_id = ?", c.Id).
-			Delete(&xray.ClientTraffic{}).Error; err != nil {
-			return false, err
+			// The per-email row is the shared accumulator across every inbound
+			// (and node) the email is attached to. Only drop it when this was the
+			// email's last inbound — wiping it while a sibling still feeds it
+			// loses the summed history, and the next node sync would re-seed the
+			// row with that node's counter alone.
+			sharedEmails, sErr := s.emailsUsedByOtherInbounds(goneEmails, c.Id)
+			if sErr != nil {
+				return false, sErr
+			}
+			delEmails := make([]string, 0, len(goneEmails))
+			for _, e := range goneEmails {
+				if !sharedEmails[strings.ToLower(strings.TrimSpace(e))] {
+					delEmails = append(delEmails, e)
+				}
+			}
+			for _, batch := range chunkStrings(delEmails, sqliteMaxVars) {
+				if err := tx.Where("inbound_id = ? AND email IN ?", c.Id, batch).
+					Delete(&xray.ClientTraffic{}).Error; err != nil {
+					return false, err
+				}
+			}
 		}
 		if err := s.clientService.DetachInbound(tx, c.Id); err != nil {
 			return false, err
@@ -523,9 +540,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				Delete(&model.NodeClientTraffic{}).Error; err != nil {
 				return false, err
 			}
-			if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
-				Delete(&xray.ClientTraffic{}).Error; err != nil {
-				return false, err
+			// Same shared-accumulator rule as the inbound-removal sweep above:
+			// keep the row while another inbound still references the email.
+			stillUsed, uErr := s.emailUsedByOtherInbounds(existing.Email, c.Id)
+			if uErr != nil {
+				return false, uErr
+			}
+			if !stillUsed {
+				if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
+					Delete(&xray.ClientTraffic{}).Error; err != nil {
+					return false, err
+				}
 			}
 			structuralChange = true
 		}

+ 52 - 11
internal/web/service/inbound_protocol.go

@@ -22,9 +22,14 @@ func inboundShadowsocksMethod(protocol, settings string) string {
 	return s.Method
 }
 
-// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
-// XTLS Vision is only valid for VLESS on TCP with tls or reality.
-func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
+// inboundCanEnableTlsFlow mirrors canEnableTlsFlow() from the frontend
+// (frontend/src/lib/xray/protocol-capabilities.ts). XTLS Vision is valid for
+// VLESS on TCP with tls or reality (classic), and on XHTTP when VLESS encryption
+// (vlessenc / ML-KEM) is enabled — there the post-quantum, VLESS-level
+// encryption stands in for the transport TLS that Vision relies on. settings is
+// the inbound's raw settings JSON, which carries the encryption value
+// (streamSettings does not).
+func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
 	if protocol != string(model.VLESS) {
 		return false
 	}
@@ -38,15 +43,51 @@ func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
 	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
 		return false
 	}
-	if stream.Network != "tcp" {
+	switch stream.Network {
+	case "tcp":
+		return stream.Security == "tls" || stream.Security == "reality"
+	case "xhttp":
+		return vlessEncryptionEnabled(settings)
+	default:
 		return false
 	}
-	return stream.Security == "tls" || stream.Security == "reality"
+}
+
+// vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level
+// encryption (vlessenc / ML-KEM) configured. When enabled these fields hold a
+// generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>"); "none"
+// or empty means off. The value is never the literal "vlessenc" — that is the
+// name of the `xray vlessenc` CLI subcommand, not a stored value.
+//
+// Both fields are checked: decryption is the authoritative server-side value
+// xray-core reads, while encryption is stored by the panel for link generation.
+// The ML-KEM/X25519 buttons set both, but accepting either keeps the gate
+// working for inbounds configured via the API or raw JSON.
+func vlessEncryptionEnabled(settings string) bool {
+	if settings == "" {
+		return false
+	}
+	var s struct {
+		Encryption string `json:"encryption"`
+		Decryption string `json:"decryption"`
+	}
+	if err := json.Unmarshal([]byte(settings), &s); err != nil {
+		return false
+	}
+	return vlessEncValueSet(s.Encryption) || vlessEncValueSet(s.Decryption)
+}
+
+// vlessEncValueSet reports whether a VLESS encryption/decryption field holds a
+// real (generated) value rather than the "none"/empty sentinel.
+func vlessEncValueSet(v string) bool {
+	return v != "" && v != "none"
 }
 
 // inboundCanHostFallbacks gates the settings.fallbacks injection.
 // Xray only honors fallbacks on VLESS and Trojan inbounds carried over
-// TCP transport with TLS or Reality security.
+// TCP transport with TLS or Reality security. This is intentionally stricter
+// than inboundCanEnableTlsFlow (which also accepts XHTTP+vlessenc): fallbacks
+// are a raw-TCP-only feature.
 func inboundCanHostFallbacks(ib *model.Inbound) bool {
 	if ib == nil {
 		return false
@@ -54,13 +95,13 @@ func inboundCanHostFallbacks(ib *model.Inbound) bool {
 	if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan {
 		return false
 	}
-	return inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) ||
-		(ib.Protocol == model.Trojan && trojanStreamSupportsFallbacks(ib.StreamSettings))
+	return streamSupportsFallbacks(ib.StreamSettings)
 }
 
-// trojanStreamSupportsFallbacks mirrors the Trojan side of the same gate
-// (Trojan reuses XTLS-Vision capable streams: tcp + tls or reality).
-func trojanStreamSupportsFallbacks(streamSettings string) bool {
+// streamSupportsFallbacks reports whether the stream is raw TCP carried over
+// TLS or REALITY — the only transport Xray honors inbound fallbacks on (and the
+// classic requirement for XTLS Vision before vlessenc).
+func streamSupportsFallbacks(streamSettings string) bool {
 	if streamSettings == "" {
 		return false
 	}

+ 90 - 0
internal/web/service/inbound_protocol_test.go

@@ -0,0 +1,90 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A representative vlessenc/ML-KEM encryption value as produced by `xray
+// vlessenc` — a dotted string, never the literal "vlessenc".
+const vlessEncValue = "mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y"
+
+func TestInboundCanEnableTlsFlow(t *testing.T) {
+	cases := []struct {
+		name           string
+		protocol       string
+		streamSettings string
+		settings       string
+		want           bool
+	}{
+		{"vless tcp tls", string(model.VLESS), `{"network":"tcp","security":"tls"}`, "", true},
+		{"vless tcp reality", string(model.VLESS), `{"network":"tcp","security":"reality"}`, "", true},
+		{"vless tcp none no enc", string(model.VLESS), `{"network":"tcp","security":"none"}`, "", false},
+		{"vless ws tls", string(model.VLESS), `{"network":"ws","security":"tls"}`, "", false},
+		{"vless grpc reality", string(model.VLESS), `{"network":"grpc","security":"reality"}`, "", false},
+		{"vmess tcp tls", string(model.VMESS), `{"network":"tcp","security":"tls"}`, "", false},
+		{"empty stream", string(model.VLESS), "", "", false},
+
+		// vlessenc is gated to XHTTP only. TCP without tls/reality is NOT
+		// Vision-capable even with vlessenc set — the combination only works on
+		// XHTTP in practice.
+		{"vless tcp vlessenc not capable", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, false},
+		// ws is a framed transport — vlessenc never enables Vision there.
+		{"vless ws vlessenc still off", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, false},
+
+		// XHTTP + VLESS encryption (the #5157 case).
+		{"vless xhttp vlessenc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
+		{"vless xhttp encryption none", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"none"}`, false},
+		{"vless xhttp no settings", string(model.VLESS), `{"network":"xhttp","security":"none"}`, "", false},
+		// Regression for PR #5185: the gate is "any non-none encryption", NOT an
+		// equality check against the literal "vlessenc" (which the buggy PR used
+		// and which never matches a real, generated encryption value). An x25519
+		// auth value must enable it just like the ML-KEM value above.
+		{"vless xhttp x25519 enc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"native.0rtt.121s-180s.xRMUYYjQctqYO1pSyffM-w"}`, true},
+		// Server-side configs (API/JSON) may carry only decryption; that alone
+		// must also enable the flow gate.
+		{"vless xhttp decryption only", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"decryption":"` + vlessEncValue + `","encryption":"none"}`, true},
+		// XHTTP without encryption stays off even with tls (Vision over XHTTP is
+		// gated on vlessenc, not transport security).
+		{"vless xhttp tls no encryption", string(model.VLESS), `{"network":"xhttp","security":"tls"}`, `{"encryption":"none"}`, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := inboundCanEnableTlsFlow(tc.protocol, tc.streamSettings, tc.settings)
+			if got != tc.want {
+				t.Errorf("inboundCanEnableTlsFlow(%q, %q, %q) = %v, want %v",
+					tc.protocol, tc.streamSettings, tc.settings, got, tc.want)
+			}
+		})
+	}
+}
+
+// Fallbacks must remain raw-TCP-only and must NOT follow the broadened flow gate
+// onto XHTTP+vlessenc.
+func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
+	cases := []struct {
+		name           string
+		protocol       model.Protocol
+		streamSettings string
+		settings       string
+		want           bool
+	}{
+		{"vless tcp tls", model.VLESS, `{"network":"tcp","security":"tls"}`, "", true},
+		{"trojan tcp reality", model.Trojan, `{"network":"tcp","security":"reality"}`, "", true},
+		{"vless xhttp vlessenc not fallback-capable", model.VLESS, `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, false},
+		{"vmess tcp tls not fallback-capable", model.VMESS, `{"network":"tcp","security":"tls"}`, "", false},
+		{"nil-ish empty stream", model.VLESS, "", "", false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings, Settings: tc.settings}
+			if got := inboundCanHostFallbacks(ib); got != tc.want {
+				t.Errorf("inboundCanHostFallbacks = %v, want %v", got, tc.want)
+			}
+		})
+	}
+	if inboundCanHostFallbacks(nil) {
+		t.Errorf("inboundCanHostFallbacks(nil) = true, want false")
+	}
+}

+ 19 - 0
internal/web/service/inbound_traffic.go

@@ -386,6 +386,11 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	if err != nil {
 		return false, 0, err
 	}
+	// A renewed client starts a fresh quota window: drop the cross-panel rows
+	// too, or the stale pushed totals would re-deplete it immediately.
+	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
+		return false, 0, err
+	}
 	if p != nil {
 		err1 = s.xrayApi.Init(p.GetAPIPort())
 		if err1 != nil {
@@ -436,6 +441,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
 	if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
 		return err
 	}
+	if err := clearGlobalTraffic(tx, email); err != nil {
+		return err
+	}
 	return tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error
 }
 
@@ -447,6 +455,9 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
 		if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
 			return err
 		}
+		if err := tx.Where("email IN ?", batch).Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
+			return err
+		}
 		if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
 			return err
 		}
@@ -457,6 +468,9 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
 func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 	return submitTrafficWrite(func() error {
 		db := database.GetDB()
+		if err := clearGlobalTraffic(db, clientEmail); err != nil {
+			return err
+		}
 		return db.Model(xray.ClientTraffic{}).
 			Where("email = ?", clientEmail).
 			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error
@@ -550,6 +564,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	if err != nil {
 		return false, err
 	}
+	if err := clearGlobalTraffic(db, clientEmail); err != nil {
+		return false, err
+	}
 
 	now := time.Now().UnixMilli()
 	_ = db.Model(model.Inbound{}).
@@ -848,6 +865,7 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
 	if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
 		return nil, err
 	}
+	overlayGlobalTraffic(db, traffics)
 	return traffics, nil
 }
 
@@ -880,6 +898,7 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
 	if len(traffics) == 0 {
 		return nil, nil
 	}
+	overlayGlobalTraffic(db, traffics)
 	t := traffics[0]
 
 	if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {

+ 215 - 0
internal/web/service/inbound_traffic_global.go

@@ -0,0 +1,215 @@
+package service
+
+import (
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+)
+
+// AcceptGlobalTraffic ingests a master panel's aggregated per-client usage
+// into client_global_traffics, keyed by (masterGuid, email). The numbers are
+// display/enforcement inputs only — client_traffics keeps this panel's
+// local-only counters, so the pushing master's (and any other master's)
+// delta accounting over our snapshot stays correct.
+//
+// Rows are overwritten, not max-merged: a reset on the master propagates here
+// on its next push. Emails this panel doesn't host are dropped.
+func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error {
+	masterGuid = strings.TrimSpace(masterGuid)
+	if masterGuid == "" {
+		return nil
+	}
+	emails := make([]string, 0, len(traffics))
+	byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
+	for _, t := range traffics {
+		if t == nil || t.Email == "" {
+			continue
+		}
+		if _, dup := byEmail[t.Email]; !dup {
+			emails = append(emails, t.Email)
+		}
+		byEmail[t.Email] = t
+	}
+	if len(emails) == 0 {
+		return nil
+	}
+
+	return submitTrafficWrite(func() error {
+		db := database.GetDB()
+		known := make([]string, 0, len(emails))
+		for _, batch := range chunkStrings(emails, sqlInChunk) {
+			var page []string
+			if err := db.Model(xray.ClientTraffic{}).
+				Where("email IN ?", batch).
+				Pluck("email", &page).Error; err != nil {
+				return err
+			}
+			known = append(known, page...)
+		}
+		if len(known) == 0 {
+			return nil
+		}
+
+		now := time.Now().UnixMilli()
+		rows := make([]model.ClientGlobalTraffic, 0, len(known))
+		for _, email := range known {
+			t := byEmail[email]
+			if t == nil {
+				continue
+			}
+			rows = append(rows, model.ClientGlobalTraffic{
+				MasterGuid: masterGuid,
+				Email:      email,
+				Up:         t.Up,
+				Down:       t.Down,
+				UpdatedAt:  now,
+			})
+		}
+
+		return db.Transaction(func(tx *gorm.DB) error {
+			for _, batch := range chunkGlobalRows(rows, 200) {
+				if err := tx.Clauses(clause.OnConflict{
+					Columns:   []clause.Column{{Name: "master_guid"}, {Name: "email"}},
+					DoUpdates: clause.AssignmentColumns([]string{"up", "down", "updated_at"}),
+				}).Create(&batch).Error; err != nil {
+					return err
+				}
+			}
+			return nil
+		})
+	})
+}
+
+func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.ClientGlobalTraffic {
+	if len(rows) == 0 {
+		return nil
+	}
+	out := make([][]model.ClientGlobalTraffic, 0, (len(rows)+size-1)/size)
+	for start := 0; start < len(rows); start += size {
+		end := min(start+size, len(rows))
+		out = append(out, rows[start:end])
+	}
+	return out
+}
+
+// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
+// value any master pushed for that email. Read-path only — callers hand it
+// rows about to be serialized for display; the stored counters are untouched.
+func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
+	if len(rows) == 0 {
+		return
+	}
+	// Cheap short-circuit for the common case (a panel no master pushes to).
+	var probe int64
+	if err := db.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err != nil || probe == 0 {
+		return
+	}
+
+	emails := make([]string, 0, len(rows))
+	byEmail := make(map[string][]*xray.ClientTraffic, len(rows))
+	for _, r := range rows {
+		if r == nil || r.Email == "" {
+			continue
+		}
+		key := strings.ToLower(r.Email)
+		if _, ok := byEmail[key]; !ok {
+			emails = append(emails, r.Email)
+		}
+		byEmail[key] = append(byEmail[key], r)
+	}
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var globals []model.ClientGlobalTraffic
+		if err := db.Where("email IN ?", batch).Find(&globals).Error; err != nil {
+			logger.Warning("overlayGlobalTraffic:", err)
+			return
+		}
+		for i := range globals {
+			for _, r := range byEmail[strings.ToLower(globals[i].Email)] {
+				if globals[i].Up > r.Up {
+					r.Up = globals[i].Up
+				}
+				if globals[i].Down > r.Down {
+					r.Down = globals[i].Down
+				}
+			}
+		}
+	}
+}
+
+// overlayGlobalTrafficValues is overlayGlobalTraffic for value slices.
+func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
+	if len(rows) == 0 {
+		return
+	}
+	ptrs := make([]*xray.ClientTraffic, 0, len(rows))
+	for i := range rows {
+		ptrs = append(ptrs, &rows[i])
+	}
+	overlayGlobalTraffic(db, ptrs)
+}
+
+// GetNodeClientTraffics returns this panel's aggregated traffic rows for the
+// clients known to live on the given node (those with a delta baseline) —
+// the payload for Remote.PushGlobalClientTraffics. The rows carry the global
+// overlay so a mid-chain panel forwards the widest view it has seen, not just
+// its own aggregate.
+func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
+	db := database.GetDB()
+	var emails []string
+	if err := db.Model(&model.NodeClientTraffic{}).
+		Where("node_id = ?", nodeID).
+		Pluck("email", &emails).Error; err != nil {
+		return nil, err
+	}
+	if len(emails) == 0 {
+		return nil, nil
+	}
+	out := make([]*xray.ClientTraffic, 0, len(emails))
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var page []*xray.ClientTraffic
+		if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
+			return nil, err
+		}
+		out = append(out, page...)
+	}
+	overlayGlobalTraffic(db, out)
+	return out, nil
+}
+
+// overlayInboundsClientStats applies the global overlay to every preloaded
+// ClientStats row across the given inbounds. UI read paths only — never the
+// full /panel/api/inbounds/list payload, which doubles as the traffic
+// snapshot masters poll: overlaying that would leak pushed globals back into
+// the masters' delta accounting.
+func (s *InboundService) overlayInboundsClientStats(db *gorm.DB, inbounds []*model.Inbound) {
+	rows := make([]*xray.ClientTraffic, 0)
+	for _, ib := range inbounds {
+		for j := range ib.ClientStats {
+			rows = append(rows, &ib.ClientStats[j])
+		}
+	}
+	overlayGlobalTraffic(db, rows)
+}
+
+// clearGlobalTraffic drops every master's pushed rows for the given emails.
+// Used by client deletion and traffic-reset flows: after a node-local reset
+// the next master push restores the master's (authoritative) numbers, and
+// after a master-side reset that push carries the reset values anyway.
+func clearGlobalTraffic(tx *gorm.DB, emails ...string) error {
+	if len(emails) == 0 {
+		return nil
+	}
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		if err := tx.Where("email IN ?", batch).Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 75 - 0
internal/web/service/node_client_traffic_sum_test.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"fmt"
 	"path/filepath"
 	"testing"
 
@@ -31,6 +32,18 @@ func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port i
 	}
 }
 
+// createNodeInboundWithClient mirrors createNodeInbound but stores the client
+// in the settings JSON so emailUsedByOtherInbounds can see the attachment.
+func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) {
+	t.Helper()
+	nid := nodeID
+	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid, Settings: settings}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create node inbound %q: %v", tag, err)
+	}
+}
+
 func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
 	t.Helper()
 	snap := &runtime.TrafficSnapshot{
@@ -41,6 +54,20 @@ func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats .
 	}
 }
 
+// syncNodeWithSettings mirrors syncNode but carries a real settings JSON on
+// the snapshot inbound, like production nodes do — the sync mirrors snapshot
+// settings onto the central row, and the shared-accumulator guard reads the
+// clients out of those settings.
+func syncNodeWithSettings(t *testing.T, svc *InboundService, nodeID int, tag, settings string, stats ...xray.ClientTraffic) {
+	t.Helper()
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
+	}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
+	}
+}
+
 func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
 	t.Helper()
 	var ct xray.ClientTraffic
@@ -151,6 +178,54 @@ func TestCentralReset_NoReAdd(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
 }
 
+func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")
+	createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared")
+	svc := &InboundService{}
+
+	const email = "shared"
+	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
+	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 210, 210, "baseline sum")
+
+	// Node 1 rebuilt (reinstall / another master's reconcile): its inbound
+	// vanishes from the snapshot. The shared accumulator must survive — losing
+	// it would let the next node sync re-seed the row with that node's counter
+	// alone, showing only the last panel's number instead of the sum.
+	if _, err := svc.setRemoteTrafficLocked(1, &runtime.TrafficSnapshot{}, false); err != nil {
+		t.Fatalf("sync node 1 with empty snapshot: %v", err)
+	}
+	assertUpDown(t, readTraffic(t, db, email), 210, 210, "after node 1 inbound removal")
+
+	// Node 2 keeps accruing onto the surviving row.
+	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 250, 250, "after node 2 grows")
+}
+
+func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")
+	createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared")
+	svc := &InboundService{}
+
+	const email = "shared"
+	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+
+	// Client detached from node 1's inbound only: its stats vanish from that
+	// inbound's snapshot while node 2 still hosts the email.
+	syncNodeWithSettings(t, svc, 1, "n1-in", `{"clients": []}`)
+	assertUpDown(t, readTraffic(t, db, email), 100, 100, "after client left node 1")
+
+	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 240, Down: 240, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 140, 140, "node 2 keeps accruing")
+}
+
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}

+ 15 - 8
internal/web/service/setting.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"os"
 	"reflect"
 	"strconv"
 	"strings"
@@ -37,7 +38,7 @@ var defaultValueMap = map[string]string{
 	"secret":                      random.Seq(32),
 	"panelGuid":                   uuid.NewString(),
 	"apiToken":                    "",
-	"webBasePath":                 "/",
+	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"trustedProxyCIDRs":           "127.0.0.1/32,::1/128",
 	"pageSize":                    "25",
@@ -237,6 +238,18 @@ func mustString(value string, _ error) string {
 	return value
 }
 
+func getEnv(key, fallback string) string {
+	val, ok := os.LookupEnv(key)
+	if !ok {
+		return fallback
+	}
+	val = strings.TrimSpace(val)
+	if val == "" {
+		return fallback
+	}
+	return val
+}
+
 func (s *SettingService) ResetSettings() error {
 	db := database.GetDB()
 	err := db.Where("1 = 1").Delete(model.Setting{}).Error
@@ -587,13 +600,7 @@ func (s *SettingService) GetBasePath() (string, error) {
 	if err != nil {
 		return "", err
 	}
-	if !strings.HasPrefix(basePath, "/") {
-		basePath = "/" + basePath
-	}
-	if !strings.HasSuffix(basePath, "/") {
-		basePath += "/"
-	}
-	return basePath, nil
+	return normalizeBasePath(basePath), nil
 }
 
 func (s *SettingService) GetTimeLocation() (*time.Location, error) {

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

@@ -95,7 +95,9 @@
     "active": "نشط",
     "inactive": "غير نشط",
     "unlimited": "غير محدود",
-    "noExpiry": "بدون انتهاء"
+    "noExpiry": "بدون انتهاء",
+    "copyAllConfigs": "نسخ جميع الإعدادات",
+    "copyAllConfigsCopied": "تم نسخ جميع الإعدادات"
   },
   "menu": {
     "theme": "الثيم",
@@ -701,6 +703,8 @@
       "duration": "المدة",
       "attachedInbounds": "الاتصالات الواردة المرتبطة",
       "selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر",
+      "selectAllInbounds": "تحديد الكل",
+      "clearAllInbounds": "مسح الكل",
       "noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.",
       "noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
       "link": "الرابط",

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

@@ -95,7 +95,9 @@
     "active": "Active",
     "inactive": "Inactive",
     "unlimited": "Unlimited",
-    "noExpiry": "No expiry"
+    "noExpiry": "No expiry",
+    "copyAllConfigs": "Copy All Configs",
+    "copyAllConfigsCopied": "All configs copied"
   },
   "menu": {
     "theme": "Theme",
@@ -702,6 +704,8 @@
       "duration": "Duration",
       "attachedInbounds": "Attached inbounds",
       "selectInbound": "Select one or more inbounds",
+      "selectAllInbounds": "Select all",
+      "clearAllInbounds": "Clear all",
       "noSubId": "This client has no subId, no shareable link.",
       "noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
       "link": "Link",

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

@@ -95,7 +95,9 @@
     "active": "Activo",
     "inactive": "Inactivo",
     "unlimited": "Ilimitado",
-    "noExpiry": "Sin caducidad"
+    "noExpiry": "Sin caducidad",
+    "copyAllConfigs": "Copiar Todas las Configuraciones",
+    "copyAllConfigsCopied": "Todas las configuraciones copiadas"
   },
   "menu": {
     "theme": "Tema",
@@ -701,6 +703,8 @@
       "duration": "Duración",
       "attachedInbounds": "Inbounds asociados",
       "selectInbound": "Selecciona uno o más inbounds",
+      "selectAllInbounds": "Seleccionar todo",
+      "clearAllInbounds": "Limpiar todo",
       "noSubId": "Este cliente no tiene subId, no hay enlace compartible.",
       "noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.",
       "link": "Enlace",

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

@@ -95,7 +95,9 @@
     "active": "فعال",
     "inactive": "غیرفعال",
     "unlimited": "نامحدود",
-    "noExpiry": "بدون انقضا"
+    "noExpiry": "بدون انقضا",
+    "copyAllConfigs": "کپی همه کانفیگ‌ها",
+    "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند"
   },
   "menu": {
     "theme": "تم",
@@ -701,6 +703,8 @@
       "duration": "مدت",
       "attachedInbounds": "اینباندهای متصل",
       "selectInbound": "یک یا چند اینباند انتخاب کنید",
+      "selectAllInbounds": "انتخاب همه",
+      "clearAllInbounds": "پاک کردن همه",
       "noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.",
       "noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
       "link": "لینک",

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

@@ -95,7 +95,9 @@
     "active": "Aktif",
     "inactive": "Nonaktif",
     "unlimited": "Tanpa batas",
-    "noExpiry": "Tanpa kedaluwarsa"
+    "noExpiry": "Tanpa kedaluwarsa",
+    "copyAllConfigs": "Salin Semua Konfigurasi",
+    "copyAllConfigsCopied": "Semua konfigurasi tersalin"
   },
   "menu": {
     "theme": "Tema",
@@ -701,6 +703,8 @@
       "duration": "Durasi",
       "attachedInbounds": "Inbound terlampir",
       "selectInbound": "Pilih satu atau lebih inbound",
+      "selectAllInbounds": "Pilih semua",
+      "clearAllInbounds": "Hapus semua",
       "noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.",
       "noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.",
       "link": "Tautan",

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

@@ -95,7 +95,9 @@
     "active": "有効",
     "inactive": "無効",
     "unlimited": "無制限",
-    "noExpiry": "期限なし"
+    "noExpiry": "期限なし",
+    "copyAllConfigs": "すべての設定をコピー",
+    "copyAllConfigsCopied": "すべての設定をコピーしました"
   },
   "menu": {
     "theme": "テーマ",
@@ -701,6 +703,8 @@
       "duration": "期間",
       "attachedInbounds": "関連付けされたインバウンド",
       "selectInbound": "1 つ以上のインバウンドを選択",
+      "selectAllInbounds": "すべて選択",
+      "clearAllInbounds": "すべてクリア",
       "noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。",
       "noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
       "link": "リンク",

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

@@ -95,7 +95,9 @@
     "active": "Ativo",
     "inactive": "Inativo",
     "unlimited": "Ilimitado",
-    "noExpiry": "Sem validade"
+    "noExpiry": "Sem validade",
+    "copyAllConfigs": "Copiar Todas as Configurações",
+    "copyAllConfigsCopied": "Todas as configurações copiadas"
   },
   "menu": {
     "theme": "Tema",
@@ -701,6 +703,8 @@
       "duration": "Duração",
       "attachedInbounds": "Inbounds associados",
       "selectInbound": "Selecione um ou mais inbounds",
+      "selectAllInbounds": "Selecionar tudo",
+      "clearAllInbounds": "Limpar tudo",
       "noSubId": "Este cliente não tem subId, sem link compartilhável.",
       "noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.",
       "link": "Link",

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

@@ -95,7 +95,9 @@
     "active": "Активна",
     "inactive": "Неактивна",
     "unlimited": "Неограниченно",
-    "noExpiry": "Бессрочно"
+    "noExpiry": "Бессрочно",
+    "copyAllConfigs": "Копировать все конфигурации",
+    "copyAllConfigsCopied": "Все конфигурации скопированы"
   },
   "menu": {
     "theme": "Тема",
@@ -701,6 +703,8 @@
       "duration": "Длительность",
       "attachedInbounds": "Привязанные входящие",
       "selectInbound": "Выберите один или несколько входящих",
+      "selectAllInbounds": "Выбрать всё",
+      "clearAllInbounds": "Очистить всё",
       "noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.",
       "noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
       "link": "Ссылка",

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

@@ -95,7 +95,9 @@
     "active": "Aktif",
     "inactive": "Pasif",
     "unlimited": "Sınırsız",
-    "noExpiry": "Süresiz"
+    "noExpiry": "Süresiz",
+    "copyAllConfigs": "Tüm Yapılandırmaları Kopyala",
+    "copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı"
   },
   "menu": {
     "theme": "Tema",
@@ -702,6 +704,8 @@
       "duration": "Süre",
       "attachedInbounds": "Bağlı Gelen Bağlantılar",
       "selectInbound": "Bir veya Daha Fazla Gelen Bağlantı Seçin",
+      "selectAllInbounds": "Tümünü Seç",
+      "clearAllInbounds": "Tümünü Temizle",
       "noSubId": "Bu kullanıcının subId'si yok, dolayısıyla paylaşılabilir bir bağlantısı bulunmuyor.",
       "noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı bir protokole sahip olan gelen bağlantıya bağlayın.",
       "link": "Bağlantı",
@@ -1703,4 +1707,3 @@
     }
   }
 }
-

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

@@ -95,7 +95,9 @@
     "active": "Активна",
     "inactive": "Неактивна",
     "unlimited": "Безліміт",
-    "noExpiry": "Без строку"
+    "noExpiry": "Без строку",
+    "copyAllConfigs": "Копіювати всі конфігурації",
+    "copyAllConfigsCopied": "Всі конфігурації скопійовано"
   },
   "menu": {
     "theme": "Тема",
@@ -701,6 +703,8 @@
       "duration": "Тривалість",
       "attachedInbounds": "Прив'язані вхідні",
       "selectInbound": "Виберіть один або кілька вхідних",
+      "selectAllInbounds": "Вибрати все",
+      "clearAllInbounds": "Очистити все",
       "noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.",
       "noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
       "link": "Посилання",

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

@@ -95,7 +95,9 @@
     "active": "Hoạt động",
     "inactive": "Không hoạt động",
     "unlimited": "Không giới hạn",
-    "noExpiry": "Không hết hạn"
+    "noExpiry": "Không hết hạn",
+    "copyAllConfigs": "Sao chép tất cả cấu hình",
+    "copyAllConfigsCopied": "Đã sao chép tất cả cấu hình"
   },
   "menu": {
     "theme": "Chủ đề",
@@ -701,6 +703,8 @@
       "duration": "Thời hạn",
       "attachedInbounds": "Inbound đã gắn",
       "selectInbound": "Chọn một hoặc nhiều inbound",
+      "selectAllInbounds": "Chọn tất cả",
+      "clearAllInbounds": "Xóa tất cả",
       "noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.",
       "noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.",
       "link": "Liên kết",

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

@@ -95,7 +95,9 @@
     "active": "启用",
     "inactive": "停用",
     "unlimited": "无限制",
-    "noExpiry": "无到期"
+    "noExpiry": "无到期",
+    "copyAllConfigs": "复制全部配置",
+    "copyAllConfigsCopied": "已复制全部配置"
   },
   "menu": {
     "theme": "主题",
@@ -701,6 +703,8 @@
       "duration": "时长",
       "attachedInbounds": "关联入站",
       "selectInbound": "选择一个或多个入站",
+      "selectAllInbounds": "全选",
+      "clearAllInbounds": "全部清除",
       "noSubId": "该客户端没有 subId,无法生成共享链接。",
       "noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
       "link": "链接",

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

@@ -95,7 +95,9 @@
     "active": "啟用",
     "inactive": "停用",
     "unlimited": "無限制",
-    "noExpiry": "無到期"
+    "noExpiry": "無到期",
+    "copyAllConfigs": "複製全部配置",
+    "copyAllConfigsCopied": "已複製全部配置"
   },
   "menu": {
     "theme": "主題",
@@ -701,6 +703,8 @@
       "duration": "時長",
       "attachedInbounds": "關聯入站",
       "selectInbound": "選擇一個或多個入站",
+      "selectAllInbounds": "全選",
+      "clearAllInbounds": "全部清除",
       "noSubId": "此客戶端沒有 subId,無法產生共享連結。",
       "noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
       "link": "連結",

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä