7 次代碼提交 af5a8e5d40 ... c377dca27c

作者 SHA1 備註 提交日期
  Sanaei c377dca27c v3.6.0 14 小時之前
  Sanaei c56f6447a8 chore: refresh dependencies and modernize Go test idioms 14 小時之前
  PathGao 66740b7ef4 fix(frontend): preserve edited server drafts (#6156) 14 小時之前
  PathGao 8d02ae28f5 fix(frontend): preserve theme body classes (#6157) 14 小時之前
  PathGao 2c943da3e0 fix(frontend): keep DNS hosts synchronized (#6158) 14 小時之前
  Sanaei f52c3c4837 perf(clients): make the clients page scale to large panels 14 小時之前
  Sanaei 1e2d6f6081 fix(ci): close the TOCTOU race in the conflict-resolution bot 20 小時之前
共有 39 個文件被更改,包括 2643 次插入755 次删除
  1. 31 1
      .github/workflows/claude-bot.yml
  2. 6 5
      frontend/.storybook/preview.tsx
  3. 123 106
      frontend/package-lock.json
  4. 9 9
      frontend/package.json
  5. 8 2
      frontend/public/openapi.json
  6. 37 20
      frontend/src/api/queries/useAllSettings.ts
  7. 9 3
      frontend/src/components/clients/ClientTrafficCell.tsx
  8. 98 14
      frontend/src/hooks/useClients.ts
  9. 37 0
      frontend/src/hooks/useServerDraft.ts
  10. 24 4
      frontend/src/hooks/useTheme.tsx
  11. 25 26
      frontend/src/hooks/useXraySetting.ts
  12. 2 2
      frontend/src/pages/api-docs/endpoints.ts
  13. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  14. 7 0
      frontend/src/pages/clients/ClientsPage.css
  15. 127 95
      frontend/src/pages/clients/ClientsPage.tsx
  16. 154 0
      frontend/src/pages/clients/RowCells.tsx
  17. 1 1
      frontend/src/pages/groups/GroupsPage.tsx
  18. 26 23
      frontend/src/pages/xray/dns/DnsTab.tsx
  19. 6 0
      frontend/src/schemas/client.ts
  20. 127 0
      frontend/src/test/clients-query-gating.test.tsx
  21. 117 0
      frontend/src/test/clients-row-cells-memo.test.tsx
  22. 58 2
      frontend/src/test/clients-summary.test.ts
  23. 107 0
      frontend/src/test/dns-tab.test.tsx
  24. 51 0
      frontend/src/test/storybook-theme.test.tsx
  25. 78 1
      frontend/src/test/use-all-settings.test.tsx
  26. 70 0
      frontend/src/test/use-xray-setting.test.tsx
  27. 86 0
      frontend/src/test/useServerDraft.test.tsx
  28. 4 4
      go.mod
  29. 8 8
      go.sum
  30. 1 1
      internal/config/version
  31. 1 1
      internal/database/backup_test.go
  32. 4 8
      internal/sub/external_subscription_test.go
  33. 5 9
      internal/sub/forwarded_trust_test.go
  34. 27 9
      internal/web/job/xray_traffic_job.go
  35. 59 0
      internal/web/job/xray_traffic_job_broadcast_test.go
  36. 481 394
      internal/web/service/client_paging.go
  37. 624 0
      internal/web/service/client_paging_test.go
  38. 2 3
      internal/web/service/golden_fixtures_xray_test.go
  39. 2 3
      internal/xray/api_users_e2e_test.go

+ 31 - 1
.github/workflows/claude-bot.yml

@@ -766,7 +766,7 @@ jobs:
           fi
 
   resolve-conflicts:
-    if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner
+    if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner && github.event.comment.author_association == 'OWNER'
     runs-on: ubuntu-latest
     permissions:
       contents: read
@@ -774,6 +774,29 @@ jobs:
       pull-requests: write
       id-token: write
     steps:
+      - name: Refuse a head that moved after the request
+        id: freshness
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          PR: ${{ github.event.issue.number }}
+          COMMENT_AT: ${{ github.event.comment.created_at }}
+        run: |
+          set -euo pipefail
+          head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
+          HEAD_SHA=${head%% *}
+          HEAD_PUSHED_AT=${head#* }
+          if [ -z "$HEAD_PUSHED_AT" ]; then
+            gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so its branch cannot be verified or merged. Nothing was changed."
+            echo "::error::The head repository is unavailable; refusing to check it out."
+            exit 1
+          fi
+          if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
+            gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code that was reviewed. Nothing was changed. Ask again to act on the current head."
+            echo "::error::The head moved after the request; refusing to check it out."
+            exit 1
+          fi
+          echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
       - uses: actions/checkout@v7
         with:
           fetch-depth: 0
@@ -783,6 +806,7 @@ jobs:
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           PR: ${{ github.event.issue.number }}
+          PINNED_SHA: ${{ steps.freshness.outputs.sha }}
         run: |
           set -euo pipefail
           hand_back() {
@@ -801,6 +825,12 @@ jobs:
           git config user.name "github-actions[bot]"
           git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
           gh pr checkout "$PR"
+          checked_out=$(git rev-parse HEAD)
+          if [ "$checked_out" != "$PINNED_SHA" ]; then
+            gh pr comment "$PR" --body "The head of this pull request moved from \`${PINNED_SHA}\` to \`${checked_out}\` while this run was starting, so nothing was changed."
+            echo "::error::The head moved from ${PINNED_SHA} to ${checked_out} during the run."
+            exit 1
+          fi
           git fetch origin "$base"
           if git merge --no-commit --no-ff "origin/${base}"; then
             git merge --abort 2>/dev/null || true

+ 6 - 5
frontend/.storybook/preview.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useLayoutEffect } from 'react';
 import type { Decorator, Preview } from '@storybook/react-vite';
 import { ConfigProvider } from 'antd';
 import i18next from 'i18next';
@@ -17,11 +17,12 @@ if (!i18next.isInitialized) {
   });
 }
 
-const withTheme: Decorator = (Story, context) => {
+export const withTheme: Decorator = (Story, context) => {
   const dark = context.globals.theme === 'dark';
-  useEffect(() => {
-    document.body.setAttribute('class', dark ? 'dark' : 'light');
-    document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
+  useLayoutEffect(() => {
+    document.body.classList.remove('dark', 'light');
+    document.body.classList.add(dark ? 'dark' : 'light');
+    document.documentElement.removeAttribute('data-theme');
   }, [dark]);
   return (
     <ConfigProvider theme={buildAntdThemeConfig(dark, false)}>

+ 123 - 106
frontend/package-lock.json

@@ -1,17 +1,17 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.4.3",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.4.3",
+      "version": "0.6.0",
       "dependencies": {
         "@ant-design/icons": "^6.3.2",
         "@codemirror/lang-json": "^6.0.2",
         "@codemirror/theme-one-dark": "^6.1.3",
-        "@hookform/resolvers": "^5.4.3",
+        "@hookform/resolvers": "^5.5.7",
         "@noble/hashes": "^2.2.0",
         "@tanstack/react-query": "^5.101.4",
         "@tanstack/react-query-devtools": "^5.101.4",
@@ -32,10 +32,10 @@
       },
       "devDependencies": {
         "@eslint/js": "^10.0.1",
-        "@storybook/addon-a11y": "^10.5.4",
-        "@storybook/addon-docs": "^10.5.4",
-        "@storybook/addon-vitest": "^10.5.4",
-        "@storybook/react-vite": "^10.5.4",
+        "@storybook/addon-a11y": "^10.5.5",
+        "@storybook/addon-docs": "^10.5.5",
+        "@storybook/addon-vitest": "^10.5.5",
+        "@storybook/react-vite": "^10.5.5",
         "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.2",
         "@types/react": "^19.2.17",
@@ -47,13 +47,13 @@
         "eslint": "^10.8.0",
         "eslint-plugin-jsx-a11y": "^6.10.2",
         "eslint-plugin-react-hooks": "^7.1.1",
-        "globals": "^17.7.0",
+        "globals": "^17.8.0",
         "husky": "^9.1.7",
-        "jsdom": "^29.1.1",
+        "jsdom": "^30.0.1",
         "lint-staged": "^17.2.0",
         "msw": "^2.15.0",
         "playwright": "^1.62.0",
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "typescript": "6.0.3",
         "typescript-eslint": "^8.65.0",
         "vite": "8.1.5",
@@ -165,56 +165,58 @@
       }
     },
     "node_modules/@asamuzakjp/css-color": {
-      "version": "5.1.11",
-      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
-      "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
+      "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@asamuzakjp/generational-cache": "^1.0.1",
-        "@csstools/css-calc": "^3.2.0",
-        "@csstools/css-color-parser": "^4.1.0",
+        "@csstools/css-calc": "^3.2.1",
+        "@csstools/css-color-parser": "^4.1.9",
         "@csstools/css-parser-algorithms": "^4.0.0",
-        "@csstools/css-tokenizer": "^4.0.0"
+        "@csstools/css-tokenizer": "^4.0.0",
+        "lru-cache": "^11.5.2"
       },
       "engines": {
-        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+        "node": "^22.13.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+      "version": "11.5.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
       }
     },
     "node_modules/@asamuzakjp/dom-selector": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
-      "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
+      "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@asamuzakjp/generational-cache": "^1.0.1",
-        "@asamuzakjp/nwsapi": "^2.3.9",
         "bidi-js": "^1.0.3",
         "css-tree": "^3.2.1",
-        "is-potential-custom-element-name": "^1.0.1"
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.5.2"
       },
       "engines": {
-        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+        "node": "^22.13.0 || >=24.0.0"
       }
     },
-    "node_modules/@asamuzakjp/generational-cache": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
-      "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+    "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+      "version": "11.5.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
       "dev": true,
-      "license": "MIT",
+      "license": "BlueOak-1.0.0",
       "engines": {
-        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+        "node": "20 || >=22"
       }
     },
-    "node_modules/@asamuzakjp/nwsapi": {
-      "version": "2.3.9",
-      "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
-      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@babel/code-frame": {
       "version": "7.29.7",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1384,9 +1386,9 @@
       }
     },
     "node_modules/@hookform/resolvers": {
-      "version": "5.4.3",
-      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.3.tgz",
-      "integrity": "sha512-jtS1DEvkT1ql8ImIDQL+UmkhEnDmt3Bp7Yt4q5XQpc0JiQ8b8+I7yT/KQze4gw9Ocmi9xa9ef6zxifizPgdp3A==",
+      "version": "5.5.7",
+      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz",
+      "integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==",
       "license": "MIT",
       "dependencies": {
         "@standard-schema/utils": "^0.3.0"
@@ -1413,7 +1415,7 @@
         "react-hook-form": "^7.55.0",
         "superstruct": ">=0.12.0",
         "typanion": "^3.3.2",
-        "valibot": "^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
+        "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
         "vest": ">=3.0.0",
         "yup": "^1.0.0",
         "zod": "^3.25.0 || ^4.0.0"
@@ -3631,9 +3633,9 @@
       "license": "MIT"
     },
     "node_modules/@storybook/addon-a11y": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.4.tgz",
-      "integrity": "sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz",
+      "integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3645,20 +3647,20 @@
         "url": "https://opencollective.com/storybook"
       },
       "peerDependencies": {
-        "storybook": "^10.5.4"
+        "storybook": "^10.5.5"
       }
     },
     "node_modules/@storybook/addon-docs": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz",
-      "integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz",
+      "integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@mdx-js/react": "^3.0.0",
-        "@storybook/csf-plugin": "10.5.4",
+        "@storybook/csf-plugin": "10.5.5",
         "@storybook/icons": "^2.0.2",
-        "@storybook/react-dom-shim": "10.5.4",
+        "@storybook/react-dom-shim": "10.5.5",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "ts-dedent": "^2.0.0"
@@ -3669,7 +3671,7 @@
       },
       "peerDependencies": {
         "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.5.4"
+        "storybook": "^10.5.5"
       },
       "peerDependenciesMeta": {
         "@types/react": {
@@ -3678,9 +3680,9 @@
       }
     },
     "node_modules/@storybook/addon-vitest": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.4.tgz",
-      "integrity": "sha512-5lr81sgrbK3Tjjbstieg/yYj3jdtQYP70ihpOiN1TwogvFATY2/9eMT46FE5ioKtEFcfJRDcgJ2wSlmJKE+kZw==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz",
+      "integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3695,7 +3697,7 @@
         "@vitest/browser": "^3.0.0 || ^4.0.0",
         "@vitest/browser-playwright": "^4.0.0",
         "@vitest/runner": "^3.0.0 || ^4.0.0",
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "vitest": "^3.0.0 || ^4.0.0"
       },
       "peerDependenciesMeta": {
@@ -3714,13 +3716,13 @@
       }
     },
     "node_modules/@storybook/builder-vite": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.4.tgz",
-      "integrity": "sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz",
+      "integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@storybook/csf-plugin": "10.5.4",
+        "@storybook/csf-plugin": "10.5.5",
         "ts-dedent": "^2.0.0"
       },
       "funding": {
@@ -3728,14 +3730,14 @@
         "url": "https://opencollective.com/storybook"
       },
       "peerDependencies": {
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
     "node_modules/@storybook/csf-plugin": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz",
-      "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz",
+      "integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3748,7 +3750,7 @@
       "peerDependencies": {
         "esbuild": "*",
         "rollup": "*",
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "vite": "*",
         "webpack": "*"
       },
@@ -3785,14 +3787,14 @@
       }
     },
     "node_modules/@storybook/react": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.4.tgz",
-      "integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz",
+      "integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@storybook/global": "^5.0.0",
-        "@storybook/react-dom-shim": "10.5.4",
+        "@storybook/react-dom-shim": "10.5.5",
         "react-docgen": "^8.0.2",
         "react-docgen-typescript": "^2.2.2"
       },
@@ -3805,7 +3807,7 @@
         "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "typescript": ">= 4.9.x"
       },
       "peerDependenciesMeta": {
@@ -3821,9 +3823,9 @@
       }
     },
     "node_modules/@storybook/react-dom-shim": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz",
-      "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz",
+      "integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -3835,7 +3837,7 @@
         "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.5.4"
+        "storybook": "^10.5.5"
       },
       "peerDependenciesMeta": {
         "@types/react": {
@@ -3847,16 +3849,16 @@
       }
     },
     "node_modules/@storybook/react-vite": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.4.tgz",
-      "integrity": "sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz",
+      "integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
         "@rollup/pluginutils": "^5.0.2",
-        "@storybook/builder-vite": "10.5.4",
-        "@storybook/react": "10.5.4",
+        "@storybook/builder-vite": "10.5.5",
+        "@storybook/react": "10.5.5",
         "empathic": "^2.0.0",
         "magic-string": "^0.30.0",
         "react-docgen": "^8.0.2",
@@ -3870,7 +3872,7 @@
       "peerDependencies": {
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "storybook": "^10.5.4",
+        "storybook": "^10.5.5",
         "typescript": ">= 4.9.x",
         "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
       },
@@ -7165,7 +7167,7 @@
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/fast-json-patch": {
@@ -7531,9 +7533,9 @@
       }
     },
     "node_modules/globals": {
-      "version": "17.7.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
-      "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
+      "version": "17.8.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
+      "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -8525,39 +8527,39 @@
       }
     },
     "node_modules/jsdom": {
-      "version": "29.1.1",
-      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
-      "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+      "version": "30.0.1",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+      "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@asamuzakjp/css-color": "^5.1.11",
-        "@asamuzakjp/dom-selector": "^7.1.1",
+        "@asamuzakjp/css-color": "^6.0.5",
+        "@asamuzakjp/dom-selector": "^8.3.0",
         "@bramus/specificity": "^2.4.2",
-        "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
-        "@exodus/bytes": "^1.15.0",
+        "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+        "@exodus/bytes": "^1.15.1",
         "css-tree": "^3.2.1",
         "data-urls": "^7.0.0",
         "decimal.js": "^10.6.0",
         "html-encoding-sniffer": "^6.0.0",
         "is-potential-custom-element-name": "^1.0.1",
-        "lru-cache": "^11.3.5",
+        "lru-cache": "^11.5.2",
         "parse5": "^8.0.1",
         "saxes": "^6.0.0",
         "symbol-tree": "^3.2.4",
-        "tough-cookie": "^6.0.1",
-        "undici": "^7.25.0",
+        "tough-cookie": "^6.0.2",
+        "undici": "^8.9.0",
         "w3c-xmlserializer": "^5.0.0",
         "webidl-conversions": "^8.0.1",
         "whatwg-mimetype": "^5.0.0",
-        "whatwg-url": "^16.0.1",
+        "whatwg-url": "^17.1.0",
         "xml-name-validator": "^5.0.0"
       },
       "engines": {
-        "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+        "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
       },
       "peerDependencies": {
-        "canvas": "^3.0.0"
+        "canvas": "^3.2.3"
       },
       "peerDependenciesMeta": {
         "canvas": {
@@ -8575,6 +8577,21 @@
         "node": "20 || >=22"
       }
     },
+    "node_modules/jsdom/node_modules/whatwg-url": {
+      "version": "17.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+      "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.15.1",
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.1"
+      },
+      "engines": {
+        "node": "^22.14.0 || >=24.0.0"
+      }
+    },
     "node_modules/jsesc": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -10420,7 +10437,7 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
       "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -10967,9 +10984,9 @@
       }
     },
     "node_modules/storybook": {
-      "version": "10.5.4",
-      "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.4.tgz",
-      "integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==",
+      "version": "10.5.5",
+      "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz",
+      "integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -10989,7 +11006,7 @@
         "recast": "^0.23.5",
         "semver": "^7.7.3",
         "use-sync-external-store": "^1.5.0",
-        "ws": "^8.18.0"
+        "ws": "^8.21.1"
       },
       "bin": {
         "storybook": "dist/bin/dispatcher.js"
@@ -11760,13 +11777,13 @@
       }
     },
     "node_modules/undici": {
-      "version": "7.29.0",
-      "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
-      "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+      "version": "8.9.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
+      "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
       "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=20.18.1"
+        "node": ">=22.19.0"
       }
     },
     "node_modules/undici-types": {

+ 9 - 9
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.4.3",
+  "version": "0.6.0",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -30,7 +30,7 @@
     "@ant-design/icons": "^6.3.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "@hookform/resolvers": "^5.4.3",
+    "@hookform/resolvers": "^5.5.7",
     "@noble/hashes": "^2.2.0",
     "@tanstack/react-query": "^5.101.4",
     "@tanstack/react-query-devtools": "^5.101.4",
@@ -51,10 +51,10 @@
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
-    "@storybook/addon-a11y": "^10.5.4",
-    "@storybook/addon-docs": "^10.5.4",
-    "@storybook/addon-vitest": "^10.5.4",
-    "@storybook/react-vite": "^10.5.4",
+    "@storybook/addon-a11y": "^10.5.5",
+    "@storybook/addon-docs": "^10.5.5",
+    "@storybook/addon-vitest": "^10.5.5",
+    "@storybook/react-vite": "^10.5.5",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.17",
@@ -66,13 +66,13 @@
     "eslint": "^10.8.0",
     "eslint-plugin-jsx-a11y": "^6.10.2",
     "eslint-plugin-react-hooks": "^7.1.1",
-    "globals": "^17.7.0",
+    "globals": "^17.8.0",
     "husky": "^9.1.7",
-    "jsdom": "^29.1.1",
+    "jsdom": "^30.0.1",
     "lint-staged": "^17.2.0",
     "msw": "^2.15.0",
     "playwright": "^1.62.0",
-    "storybook": "^10.5.4",
+    "storybook": "^10.5.5",
     "typescript": "6.0.3",
     "typescript-eslint": "^8.65.0",
     "vite": "8.1.5",

+ 8 - 2
frontend/public/openapi.json

@@ -5691,7 +5691,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
+        "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
         "operationId": "get_panel_api_clients_list_paged",
         "parameters": [
           {
@@ -5807,12 +5807,18 @@
                     "summary": {
                       "total": 2000,
                       "active": 1850,
+                      "onlineCount": 1,
+                      "depletedCount": 0,
+                      "expiringCount": 0,
+                      "deactiveCount": 150,
                       "online": [
                         "[email protected]"
                       ],
                       "depleted": [],
                       "expiring": [],
-                      "deactive": []
+                      "deactive": [
+                        "[email protected]"
+                      ]
                     }
                   }
                 }

+ 37 - 20
frontend/src/api/queries/useAllSettings.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
 import { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
 import { AllSetting } from '@/models/setting';
 import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
+import { useServerDraft } from '@/hooks/useServerDraft';
 
 type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
+type SettingSaveResult = {
+  msg: Msg<unknown>;
+  saved?: AllSetting;
+};
 
 async function fetchAllSetting(): Promise<AllSettingInput | null> {
   const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
 
 export function useAllSettings() {
   const queryClient = useQueryClient();
-  const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
   const [extraSpinning, setExtraSpinning] = useState(false);
 
   const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
   });
 
   const server = useMemo(() => new AllSetting(query.data), [query.data]);
-
-  useEffect(() => {
-    if (query.data !== undefined) {
-      setDraft(new AllSetting(query.data));
-    }
-  }, [query.data]);
+  const { draft, setDraft, isDirty, markSaved } = useServerDraft(
+    query.data === undefined ? undefined : server,
+    (setting) => new AllSetting(setting),
+    (left, right) => left.equals(right),
+  );
+  const allSetting = draft ?? server;
 
   const updateSetting = useCallback((patch: Partial<AllSetting>) => {
     setDraft((prev) => {
-      const next = new AllSetting(prev);
+      const next = new AllSetting(prev ?? server);
       Object.assign(next, patch);
       return next;
     });
-  }, []);
+  }, [server, setDraft]);
 
   const saveMut = useMutation({
-    mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
-      const payload = { ...next };
-      const body = AllSettingSchema.partial().safeParse(payload);
+    mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
+      const next = { ...payload };
+      const body = AllSettingSchema.partial().safeParse(next);
       if (!body.success) {
         console.warn('[zod] setting/update body failed validation', body.error.issues);
       }
-      return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
+      const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
+      return { msg, saved };
     },
-    onSuccess: (msg) => {
-      if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+    onSuccess: ({ msg, saved }) => {
+      if (!msg?.success) return;
+      if (saved) markSaved(saved);
+      queryClient.invalidateQueries({ queryKey: keys.settings.all() });
     },
   });
 
-  const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
-  const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
-  const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
+  const saveAll = useCallback(async () => {
+    const saved = new AllSetting(allSetting);
+    return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
+  }, [allSetting, saveMut]);
+  const savePayload = useCallback(
+    async (payload: SettingSavePayload) => {
+      const saved = new AllSetting(allSetting);
+      Object.assign(saved, payload);
+      return (await saveMut.mutateAsync({ payload, saved })).msg;
+    },
+    [allSetting, saveMut],
+  );
+  const saveDisabled = !isDirty;
 
   return {
-    allSetting: draft,
+    allSetting,
     updateSetting,
     fetched: query.data !== undefined,
     spinning: extraSpinning || saveMut.isPending,

+ 9 - 3
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { memo, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Popover, Progress } from 'antd';
 
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
   compact?: boolean;
 }
 
-export default function ClientTrafficCell({
+// Every prop is a primitive and the component is pure, so the memo bails out
+// whenever a client's counters did not move — which is most of them on most
+// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
+// a useTranslation subscription and a theme context read, times up to 200 rows.
+const ClientTrafficCell = memo(function ClientTrafficCell({
   up = 0,
   down = 0,
   total = 0,
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
       </div>
     </Popover>
   );
-}
+});
+
+export default ClientTrafficCell;

+ 98 - 14
frontend/src/hooks/useClients.ts

@@ -73,7 +73,9 @@ export interface ClientQueryParams {
 
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
 const DEFAULT_SUMMARY: ClientsSummary = {
-  total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
+  total: 0, active: 0,
+  onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
+  online: [], depleted: [], expiring: [], deactive: [],
 };
 
 export interface ClientSpeedEntry {
@@ -114,7 +116,50 @@ export function computeClientsSummary(
     if (nearExpiry || nearLimit) expiring.push(email);
     else active += 1;
   }
-  return { total: stats.length, active, online, depleted, expiring, deactive };
+  return {
+    total: stats.length,
+    active,
+    onlineCount: online.length,
+    depletedCount: depleted.length,
+    expiringCount: expiring.length,
+    deactiveCount: deactive.length,
+    online,
+    depleted,
+    expiring,
+    deactive,
+  };
+}
+
+export function sameSpeedMap(
+  a: Record<string, ClientSpeedEntry>,
+  b: Record<string, ClientSpeedEntry>,
+): boolean {
+  const aKeys = Object.keys(a);
+  if (aKeys.length !== Object.keys(b).length) return false;
+  for (const key of aKeys) {
+    const left = a[key];
+    const right = b[key];
+    if (!right || left.up !== right.up || left.down !== right.down) return false;
+  }
+  return true;
+}
+
+// The field list computeClientsSummary reads, and deliberately nothing else.
+// lastOnline in particular churns for every online client on every push and no
+// counter depends on it, so including it here would defeat the comparison.
+export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    const left = a[i];
+    const right = b[i];
+    if (left.email !== right.email
+      || left.up !== right.up
+      || left.down !== right.down
+      || left.total !== right.total
+      || left.enable !== right.enable
+      || left.expiryTime !== right.expiryTime) return false;
+  }
+  return true;
 }
 
 export function pickClientsSummary(
@@ -174,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
   return validated.obj || {};
 }
 
-export function useClients() {
+export interface UseClientsOptions {
+  // Callers that only need the mutations — the bulk modals, the groups page —
+  // pass false. Mounting them used to start a second 5-second poll of the paged
+  // list whose result they never read, which on a large panel means a full
+  // summary aggregate every 5 seconds for nothing.
+  list?: boolean;
+}
+
+export function useClients(options: UseClientsOptions = {}) {
+  const withList = options.list ?? true;
   const queryClient = useQueryClient();
 
-  const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
+  // Null until the page has settled on a query. The clients page cannot build
+  // one until the persisted sort and the panel's configured page size are both
+  // known, and fetching before then cost three sequential requests per load —
+  // the first two thrown away (#trace).
+  const [query, setQueryState] = useState<ClientQueryParams | null>(null);
   // setQuery shallow-compares so callers can pass a fresh object every render
   // (the common React pattern) without triggering a re-fetch when nothing
   // actually changed.
   const setQuery = useCallback((next: ClientQueryParams) => {
     setQueryState((prev) => {
       if (
-        prev.page === next.page
+        prev
+        && prev.page === next.page
         && prev.pageSize === next.pageSize
         && (prev.search ?? '') === (next.search ?? '')
         && (prev.filter ?? '') === (next.filter ?? '')
@@ -206,8 +265,9 @@ export function useClients() {
   }, []);
 
   const listQuery = useQuery({
-    queryKey: keys.clients.list(query),
-    queryFn: () => fetchClientPage(query),
+    queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
+    queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
+    enabled: withList && query !== null,
     staleTime: Infinity,
     // List is sorted/paged server-side, so the WS patch can't add new or
     // re-sort rows; poll the current page to keep it live (pauses when hidden).
@@ -218,6 +278,7 @@ export function useClients() {
   const inboundOptionsQuery = useQuery({
     queryKey: keys.inbounds.options(),
     queryFn: fetchInboundOptions,
+    enabled: withList,
     staleTime: Infinity,
   });
 
@@ -235,6 +296,7 @@ export function useClients() {
       const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
       return Array.isArray(validated.obj) ? validated.obj : [];
     },
+    enabled: withList,
     staleTime: Infinity,
   });
 
@@ -244,7 +306,11 @@ export function useClients() {
   const allGroups = listQuery.data?.groups ?? [];
   const fetched = listQuery.data !== undefined || listQuery.isError;
   const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
-  const loading = listQuery.isFetching;
+  // isFetching is deliberately NOT read here. Touching it makes it a tracked
+  // property, so the 5s refetchInterval notifies twice per cycle — two whole
+  // page renders even when structural sharing leaves the data identical, and
+  // each one bumps rc-table's immutable mark and re-runs every cell renderer.
+  // Callers that want a spinner for an explicit refresh drive it locally.
   // Showing kept-previous data for a new key (filter/sort/page) — drives the
   // table overlay so the 5s background poll doesn't flash it.
   const transitioning = listQuery.isPlaceholderData;
@@ -277,6 +343,11 @@ export function useClients() {
   const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
   const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
   const pageSize = (defaults.pageSize as number) ?? 0;
+  // pageSize 0 means "one long page", which is indistinguishable from "the
+  // settings have not arrived yet" — so callers need this flag to know when the
+  // configured page size is real. isFetched (not isSuccess) so a failed
+  // settings request still lets the page fall back and render.
+  const settingsReady = defaultsQuery.isFetched;
 
   const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
   const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
@@ -565,15 +636,23 @@ export function useClients() {
       queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
     }
     if (Array.isArray(p.clientTraffics)) {
+      // Xray reports a row per client whether or not it moved a byte, so most of
+      // this map used to be zeros. A missing entry and a zero entry render
+      // identically (isActiveSpeed treats both as inactive), so the zeros are
+      // dropped and an unchanged result returns the previous object — which lets
+      // React bail out of the update instead of re-rendering the table.
       const next: Record<string, ClientSpeedEntry> = {};
       for (const ct of p.clientTraffics) {
         if (!ct || !ct.email) continue;
+        const up = ct.up || 0;
+        const down = ct.down || 0;
+        if (up === 0 && down === 0) continue;
         next[ct.email] = {
-          up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
-          down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
+          up: up / TRAFFIC_POLL_INTERVAL_S,
+          down: down / TRAFFIC_POLL_INTERVAL_S,
         };
       }
-      setClientSpeed(next);
+      setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
     }
   }, [queryClient]);
 
@@ -581,12 +660,17 @@ export function useClients() {
     if (!payload || typeof payload !== 'object') return;
     const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
     if (!Array.isArray(p.clients) || p.clients.length === 0) return;
-    if (p.snapshot !== false) setAllClientStats(p.clients);
+    if (p.snapshot !== false) {
+      const rows = p.clients;
+      setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
+    }
+    const active = queryRef.current;
+    if (!active) return;
     const byEmail = new Map<string, ClientTraffic>();
     for (const row of p.clients) {
       if (row && row.email) byEmail.set(row.email, row);
     }
-    queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
+    queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
       if (!prev) return prev;
       let touched = false;
       const next = prev.items.slice();
@@ -624,7 +708,6 @@ export function useClients() {
     setQuery,
     inbounds,
     onlines,
-    loading,
     transitioning,
     fetched,
     fetchError,
@@ -634,6 +717,7 @@ export function useClients() {
     expireDiff,
     trafficDiff,
     pageSize,
+    settingsReady,
     refresh,
     create,
     bulkCreate,

+ 37 - 0
frontend/src/hooks/useServerDraft.ts

@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
+  const cloneRef = useRef(clone);
+  const equalsRef = useRef(equals);
+  cloneRef.current = clone;
+  equalsRef.current = equals;
+
+  const [draft, setDraft] = useState<T | undefined>();
+  const [baseline, setBaseline] = useState<T | undefined>();
+  const draftRef = useRef(draft);
+  const baselineRef = useRef(baseline);
+  draftRef.current = draft;
+  baselineRef.current = baseline;
+
+  useEffect(() => {
+    if (server === undefined) return;
+    const currentDraft = draftRef.current;
+    const currentBaseline = baselineRef.current;
+    const isDirty = currentDraft !== undefined
+      && (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
+    setBaseline(server);
+    if (isDirty && !equalsRef.current(currentDraft, server)) return;
+    setDraft(cloneRef.current(server));
+  }, [server]);
+
+  const markSaved = useCallback((value: T) => {
+    setBaseline(cloneRef.current(value));
+  }, []);
+
+  const isDirty = useMemo(
+    () => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
+    [baseline, draft],
+  );
+
+  return { draft, setDraft, isDirty, markSaved };
+}

+ 24 - 4
frontend/src/hooks/useTheme.tsx

@@ -1,4 +1,4 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
 import type { ReactNode } from 'react';
 import { theme as antdTheme } from 'antd';
 import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
 }
 
 function applyDom(isDark: boolean, isUltra: boolean) {
-  document.body.setAttribute('class', isDark ? 'dark' : 'light');
+  document.body.classList.remove('dark', 'light');
+  document.body.classList.add(isDark ? 'dark' : 'light');
   if (isUltra) {
     document.documentElement.setAttribute('data-theme', 'ultra-dark');
   } else {
     document.documentElement.removeAttribute('data-theme');
   }
   const msg = document.getElementById('message');
-  if (msg) msg.className = isDark ? 'dark' : 'light';
+  if (msg) {
+    msg.classList.remove('dark', 'light');
+    msg.classList.add(isDark ? 'dark' : 'light');
+  }
 }
 
 // module load so the document is in the right theme before React mounts.
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
   colorPrimaryActive: '#073ea8',
 };
 
+// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
+// rule. It costs nothing in specificity — `:where()` contributes zero, so the
+// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
+// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
+//
+// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
+// ConfigProvider (there is no root one), and without a fixed key each mints a
+// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
+// token block under a new class instead of reusing the one already in the head.
+const SHARED_STYLE_CONFIG = {
+  hashed: false,
+  cssVar: { key: 'xui' },
+} as const;
+
 export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
   if (!isDark) {
     return {
+      ...SHARED_STYLE_CONFIG,
       algorithm: antdTheme.defaultAlgorithm,
       token: LIGHT_CONTRAST_TOKENS,
       components: {
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
     };
   }
   return {
+    ...SHARED_STYLE_CONFIG,
     algorithm: antdTheme.darkAlgorithm,
     token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
     components: {
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
   const [isDark, setIsDark] = useState<boolean>(initialDark);
   const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
 
-  useEffect(() => {
+  useLayoutEffect(() => {
     applyDom(isDark, isUltra);
     localStorage.setItem(STORAGE_DARK, String(isDark));
     localStorage.setItem(STORAGE_ULTRA, String(isUltra));

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

@@ -14,7 +14,6 @@ import {
   type OutboundTrafficRow,
 } from '@/schemas/xray';
 
-const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 // One HTTP-mode batch request tests this many outbounds through a single
 // shared temp xray instance; chunking keeps responses bounded (~30s worst
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 // results progressively.
 const HTTP_BATCH_CHUNK = 16;
 
+function normalizeOutboundTestUrl(url: string) {
+  return url || DEFAULT_TEST_URL;
+}
+
 export function isUdpOutbound(outbound: unknown): boolean {
   const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
   const p = o?.protocol;
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
     staleTime: Infinity,
   });
 
-  const [saveDisabled, setSaveDisabled] = useState(true);
   const [xraySetting, setXraySettingState] = useState('');
   const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
+  const [savedXraySetting, setSavedXraySetting] = useState('');
+  const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
   const [inboundTags, setInboundTags] = useState<string[]>([]);
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
   const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
   const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
   const [testingAll, setTestingAll] = useState(false);
 
-  const oldXraySettingRef = useRef('');
-  const oldOutboundTestUrlRef = useRef('');
   const syncingRef = useRef(false);
   const xraySettingRef = useRef('');
   const outboundTestUrlRef = useRef(outboundTestUrl);
+  const savedXraySettingRef = useRef(savedXraySetting);
+  const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
   const templateSettingsRef = useRef<XraySettingsValue | null>(null);
   const subscriptionOutboundsRef = useRef<unknown[]>([]);
 
   xraySettingRef.current = xraySetting;
   outboundTestUrlRef.current = outboundTestUrl;
+  savedXraySettingRef.current = savedXraySetting;
+  savedOutboundTestUrlRef.current = savedOutboundTestUrl;
   templateSettingsRef.current = templateSettings;
   subscriptionOutboundsRef.current = subscriptionOutbounds;
 
-  // Seed local editor state from the config query. Runs on first fetch and
-  // every time the query refetches (e.g. after a successful save).
   useEffect(() => {
     if (!configQuery.data) return;
     const obj = configQuery.data;
     const pretty = JSON.stringify(obj.xraySetting, null, 2);
-    syncingRef.current = true;
-    setXraySettingState(pretty);
-    setTemplateSettingsState(obj.xraySetting);
-    oldXraySettingRef.current = pretty;
-    syncingRef.current = false;
+    const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
     setInboundTags(obj.inboundTags || []);
     setClientReverseTags(obj.clientReverseTags || []);
     setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
     setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
-    const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
+    const isDirty = savedXraySettingRef.current !== xraySettingRef.current
+      || savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
+    if (isDirty) return;
+    syncingRef.current = true;
+    setXraySettingState(pretty);
+    setTemplateSettingsState(obj.xraySetting);
+    setSavedXraySetting(pretty);
+    syncingRef.current = false;
     setOutboundTestUrlState(nextUrl);
-    oldOutboundTestUrlRef.current = nextUrl;
-    setSaveDisabled(true);
+    setSavedOutboundTestUrl(nextUrl);
   }, [configQuery.data]);
 
   const fetched = configQuery.data !== undefined || configQuery.isError;
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
   const saveMut = useMutation({
     mutationFn: async () => {
       const sentXraySetting = xraySettingRef.current;
-      const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
+      const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
       const msg = await HttpUtil.post('/panel/api/xray/update', {
         xraySetting: sentXraySetting,
         outboundTestUrl: sentTestUrl,
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
     },
     onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
       if (!msg?.success) return;
-      oldXraySettingRef.current = sentXraySetting;
-      oldOutboundTestUrlRef.current = sentTestUrl;
-      setSaveDisabled(true);
+      setSavedXraySetting(sentXraySetting);
+      setSavedOutboundTestUrl(sentTestUrl);
       queryClient.invalidateQueries({ queryKey: keys.xray.config() });
     },
   });
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
     }
   }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
 
-  useEffect(() => {
-    const timer = window.setInterval(() => {
-      const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
-      const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
-      setSaveDisabled(!(dirtyXray || dirtyUrl));
-    }, DIRTY_POLL_MS);
-    return () => window.clearInterval(timer);
-  }, []);
+  const saveDisabled = savedXraySetting === xraySetting
+    && savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
 
   const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
 

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -564,7 +564,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/clients/list/paged',
-        summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
+        summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
         params: [
           { name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
           { name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
@@ -575,7 +575,7 @@ export const sections: readonly Section[] = [
           { name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": []\n    }\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
       },
       {
         method: 'GET',

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

@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
 }: ClientBulkAddModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const { bulkCreate } = useClients();
+  const { bulkCreate } = useClients({ list: false });
 
   const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
   const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });

+ 7 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -16,6 +16,13 @@
   white-space: nowrap;
 }
 
+.client-email-more {
+  margin-top: 4px;
+  padding-top: 4px;
+  border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
+  opacity: 0.65;
+}
+
 .filter-bar {
   display: flex;
   flex-wrap: wrap;

+ 127 - 95
frontend/src/pages/clients/ClientsPage.tsx

@@ -1,4 +1,4 @@
-import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Badge,
@@ -16,7 +16,6 @@ import {
   Result,
   Row,
   Select,
-  Space,
   Spin,
   Statistic,
   Switch,
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
+import { ClientInboundChips, ClientRowActions } from './RowCells';
 import { emptyFilters, activeFilterCount } from './filters';
 import type { ClientFilters } from './filters';
 import './ClientsPage.css';
 
 const FILTER_STATE_KEY = 'clientsFilterState';
 const DISABLED_PAGE_SIZE = 200;
+const DEFAULT_TABLE_PAGE_SIZE = 25;
 
 function UngroupIcon() {
   return (
@@ -126,12 +127,29 @@ function UngroupIcon() {
   );
 }
 
+// The server sends exact counters but caps the email arrays behind them, so a
+// panel with thousands of depleted clients neither ships nor renders them all.
+// The trailing chip reports what the popover left out.
+function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
+  const hidden = total - emails.length;
+  return (
+    <div className="client-email-list">
+      {emails.map((e) => <div key={e}>{e}</div>)}
+      {hidden > 0 && <div className="client-email-more">+{hidden}</div>}
+    </div>
+  );
+}
+
 type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 
 interface PersistedFilterState {
   searchKey: string;
   filters: ClientFilters;
   sort: string;
+  // The page size resolved on the previous visit. Without it the first list
+  // request has to wait for /setting/defaultSettings just to learn how many rows
+  // to ask for, which serialises two round trips on every load.
+  pageSize: number | null;
 }
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
   tunnel: 'orange',
 };
 const INBOUND_CHIP_LIMIT = 1;
+// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
+// every unattached client on every render.
+const EMPTY_INBOUND_IDS: number[] = [];
 
 function readFilterState(): PersistedFilterState {
   try {
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
         groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
       },
       sort: typeof raw.sort === 'string' ? raw.sort : '',
+      pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
     };
   } catch {
-    return { searchKey: '', filters: emptyFilters(), sort: '' };
+    return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
   }
 }
 
@@ -208,8 +230,8 @@ export default function ClientsPage() {
     summary,
     allGroups,
     setQuery,
-    inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
-    tgBotEnable, expireDiff, trafficDiff, pageSize,
+    inbounds, onlines, transitioning, fetched, fetchError, subSettings,
+    tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
     create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
     clientSpeed,
@@ -265,14 +287,31 @@ export default function ClientsPage() {
   const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
   const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
   const [currentPage, setCurrentPage] = useState(1);
-  const [tablePageSize, setTablePageSize] = useState(25);
+  // Derived, not mirrored into state by an effect: an effect lags one render
+  // behind the settings arriving, and that lag is what made the page fetch the
+  // list once with the placeholder size and again with the real one.
+  const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
+  const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
+  // Last visit's resolved size stands in until the settings land, so the list
+  // request goes out with the page mount instead of queueing behind them. If the
+  // admin has since changed the setting the authoritative value replaces it and
+  // costs one refetch — only on the load that follows the change. Null means
+  // nothing is known yet, which is the one case worth waiting for.
+  const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
+  const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
   // debouncedSearch lags behind the input so we don't spam the server on every
   // keystroke; the search box still feels instant locally.
   const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
 
   useEffect(() => {
-    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
-  }, [searchKey, filters, sortColumn, sortOrder]);
+    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
+      searchKey,
+      filters,
+      sort: sortValueFor(sortColumn, sortOrder),
+      // Only ever persist a size we actually resolved, never the render fallback.
+      pageSize: resolvedPageSize,
+    }));
+  }, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
 
   useEffect(() => {
     const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -303,6 +342,10 @@ export default function ClientsPage() {
   }, [filters.nodeIds, filters.inboundIds, inbounds]);
 
   useEffect(() => {
+    // With no remembered size and no settings yet, any query we build would be a
+    // guess, and issuing it costs a full server round trip that is thrown away as
+    // soon as the real size arrives.
+    if (resolvedPageSize === null) return;
     setQuery({
       page: currentPage,
       pageSize: tablePageSize,
@@ -321,13 +364,21 @@ export default function ClientsPage() {
       sort: sortColumn || undefined,
       order: sortOrder || undefined,
     });
-  }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
+  }, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
 
   const activeCount = activeFilterCount(filters);
 
-  useEffect(() => {
-    setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
-  }, [pageSize]);
+  // Row handlers take an email and look the row up here at call time. Keying
+  // them on the record object instead would defeat the memoised cells: every
+  // traffic push replaces the row object of every client whose counters moved,
+  // so the memo would miss on exactly the rows that are busy. Reading through
+  // the ref also means a modal opened mid-poll shows current usage.
+  const rowsByEmail = useRef(new Map<string, ClientRecord>());
+  rowsByEmail.current = useMemo(() => {
+    const map = new Map<string, ClientRecord>();
+    for (const c of clients) map.set(c.email, c);
+    return map;
+  }, [clients]);
 
   const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
   const inboundsById = useMemo(() => {
@@ -454,7 +505,9 @@ export default function ClientsPage() {
     setFormOpen(true);
   }
 
-  async function onEdit(row: ClientRecord) {
+  const onEdit = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     setFormMode('edit');
     // Paged list omits per-client secrets to keep the row payload tiny;
     // edit needs them, so fetch the full record first.
@@ -465,9 +518,11 @@ export default function ClientsPage() {
     setEditingAttachedIds([...ids]);
     setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
     setFormOpen(true);
-  }
+  }, [hydrate]);
 
-  function onDelete(row: ClientRecord) {
+  const onDelete = useCallback((email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     modal.confirm({
       title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
       content: t('pages.clients.deleteConfirmContent'),
@@ -479,9 +534,10 @@ export default function ClientsPage() {
         if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
       },
     });
-  }
+  }, [modal, t, remove, messageApi]);
 
-  function onResetTraffic(row: ClientRecord) {
+  const onResetTraffic = useCallback((email: string) => {
+    const row = rowsByEmail.current.get(email);
     if (!row?.email) {
       messageApi.warning(t('pages.clients.resetNotPossible'));
       return;
@@ -496,19 +552,33 @@ export default function ClientsPage() {
         if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
       },
     });
-  }
+  }, [modal, t, resetTraffic, messageApi]);
 
-  async function onShowInfo(row: ClientRecord) {
+  const onShowInfo = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     const full = await hydrate(row.email);
     setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
     setInfoOpen(true);
-  }
+  }, [hydrate]);
 
-  async function onShowQr(row: ClientRecord) {
+  const onShowQr = useCallback(async (email: string) => {
+    const row = rowsByEmail.current.get(email);
+    if (!row) return;
     const full = await hydrate(row.email);
     setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
     setQrOpen(true);
-  }
+  }, [hydrate]);
+
+  const [refreshing, setRefreshing] = useState(false);
+  const onRefreshClick = useCallback(async () => {
+    setRefreshing(true);
+    try {
+      await refresh();
+    } finally {
+      setRefreshing(false);
+    }
+  }, [refresh]);
 
   const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
     setTextTitle(opts.title);
@@ -743,7 +813,7 @@ export default function ClientsPage() {
 
   const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
     if (pag?.current) setCurrentPage(pag.current);
-    if (pag?.pageSize) setTablePageSize(pag.pageSize);
+    if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
   };
 
   const columns = useMemo<ColumnsType<ClientRecord>>(() => [
@@ -752,23 +822,14 @@ export default function ClientsPage() {
       key: 'actions',
       width: 200,
       render: (_v, record) => (
-        <Space size={4}>
-          <Tooltip title={t('pages.clients.qrCode')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
-          </Tooltip>
-          <Tooltip title={t('pages.clients.clientInfo')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
-          </Tooltip>
-          <Tooltip title={t('pages.inbounds.resetTraffic')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
-          </Tooltip>
-          <Tooltip title={t('edit')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
-          </Tooltip>
-          <Tooltip title={t('delete')}>
-            <Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
-          </Tooltip>
-        </Space>
+        <ClientRowActions
+          email={record.email}
+          onShowQr={onShowQr}
+          onShowInfo={onShowInfo}
+          onResetTraffic={onResetTraffic}
+          onEdit={onEdit}
+          onDelete={onDelete}
+        />
       ),
     },
     {
@@ -850,42 +911,13 @@ export default function ClientsPage() {
       key: 'inboundIds',
       width: 170,
       render: (_v, record) => {
-        const ids = record.inboundIds || [];
-        if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-        const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
-        const overflow = ids.slice(INBOUND_CHIP_LIMIT);
-        const chip = (id: number, compact: boolean) => {
-          const ib = inboundsById[id];
-          const proto = (ib?.protocol || '').toLowerCase();
-          const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
-          return (
-            <Tooltip key={id} title={inboundLabel(id)}>
-              <Tag color={color} style={{ margin: 2 }}>
-                {compact ? compactLabel : inboundLabel(id)}
-              </Tag>
-            </Tooltip>
-          );
-        };
         return (
-          <>
-            {visible.map((id) => chip(id, true))}
-            {overflow.length > 0 && (
-              <Popover
-                trigger="click"
-                placement="bottomRight"
-                content={
-                  <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
-                    {overflow.map((id) => chip(id, false))}
-                  </div>
-                }
-              >
-                <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
-                  +{overflow.length}
-                </Tag>
-              </Popover>
-            )}
-          </>
+          <ClientInboundChips
+            ids={record.inboundIds || EMPTY_INBOUND_IDS}
+            inboundsById={inboundsById}
+            protocolColors={INBOUND_PROTOCOL_COLORS}
+            chipLimit={INBOUND_CHIP_LIMIT}
+          />
         );
       },
     },
@@ -994,7 +1026,7 @@ export default function ClientsPage() {
                   status="error"
                   title={t('somethingWentWrong')}
                   subTitle={fetchError}
-                  extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
+                  extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
                 />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -1007,37 +1039,37 @@ export default function ClientsPage() {
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('online')}
-                            open={summary.online.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.onlineCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
                           >
-                            <Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
+                            <Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('depleted')}
-                            open={summary.depleted.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.depletedCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
                           >
-                            <Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
+                            <Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('depletingSoon')}
-                            open={summary.expiring.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.expiringCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
                           >
-                            <Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
+                            <Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
                           <Popover
                             title={t('disabled')}
-                            open={summary.deactive.length ? undefined : false}
-                            content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
+                            open={summary.deactiveCount ? undefined : false}
+                            content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
                           >
-                            <Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
+                            <Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
                           </Popover>
                         </Col>
                         <Col xs={12} sm={8} md={4}>
@@ -1364,7 +1396,7 @@ export default function ClientsPage() {
                                   showTotal={(n) => `${n}`}
                                   onChange={(p, s) => {
                                     setCurrentPage(p);
-                                    if (s && s !== tablePageSize) setTablePageSize(s);
+                                    if (s && s !== tablePageSize) setPageSizeChoice(s);
                                   }}
                                 />
                               </div>
@@ -1391,8 +1423,8 @@ export default function ClientsPage() {
                                           role="button"
                                           tabIndex={0}
                                           aria-label={t('pages.clients.clientInfo')}
-                                          onClick={() => onShowInfo(row)}
-                                          onKeyDown={activateOnKey(() => onShowInfo(row))}
+                                          onClick={() => onShowInfo(row.email)}
+                                          onKeyDown={activateOnKey(() => onShowInfo(row.email))}
                                         />
                                       </Tooltip>
                                       <Switch
@@ -1409,23 +1441,23 @@ export default function ClientsPage() {
                                             {
                                               key: 'qr',
                                               label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
-                                              onClick: () => onShowQr(row),
+                                              onClick: () => onShowQr(row.email),
                                             },
                                             {
                                               key: 'reset',
                                               label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
-                                              onClick: () => onResetTraffic(row),
+                                              onClick: () => onResetTraffic(row.email),
                                             },
                                             {
                                               key: 'edit',
                                               label: <><EditOutlined /> {t('edit')}</>,
-                                              onClick: () => onEdit(row),
+                                              onClick: () => onEdit(row.email),
                                             },
                                             {
                                               key: 'delete',
                                               danger: true,
                                               label: <><DeleteOutlined /> {t('delete')}</>,
-                                              onClick: () => onDelete(row),
+                                              onClick: () => onDelete(row.email),
                                             },
                                           ],
                                         }}

+ 154 - 0
frontend/src/pages/clients/RowCells.tsx

@@ -0,0 +1,154 @@
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Popover, Space, Tag, Tooltip } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  InfoCircleOutlined,
+  QrcodeOutlined,
+  RetweetOutlined,
+} from '@ant-design/icons';
+
+import { formatInboundLabel } from '@/lib/inbounds/label';
+import type { InboundOption } from '@/hooks/useClients';
+
+const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
+
+interface ClientRowActionsProps {
+  email: string;
+  onShowQr: (email: string) => void;
+  onShowInfo: (email: string) => void;
+  onResetTraffic: (email: string) => void;
+  onEdit: (email: string) => void;
+  onDelete: (email: string) => void;
+}
+
+// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
+// inline they re-ran rc-tooltip's alignment machinery for every visible row on
+// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
+// Keyed on the email rather than the row object, because a push replaces the row
+// object of every client whose counters moved; the page resolves the live row.
+export const ClientRowActions = memo(function ClientRowActions({
+  email,
+  onShowQr,
+  onShowInfo,
+  onResetTraffic,
+  onEdit,
+  onDelete,
+}: ClientRowActionsProps) {
+  const { t } = useTranslation();
+  return (
+    <Space size={4}>
+      <Tooltip title={t('pages.clients.qrCode')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<QrcodeOutlined />}
+          aria-label={t('pages.clients.qrCode')}
+          onClick={() => onShowQr(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('pages.clients.clientInfo')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<InfoCircleOutlined />}
+          aria-label={t('pages.clients.clientInfo')}
+          onClick={() => onShowInfo(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('pages.inbounds.resetTraffic')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<RetweetOutlined />}
+          aria-label={t('pages.inbounds.resetTraffic')}
+          onClick={() => onResetTraffic(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('edit')}>
+        <Button
+          size="small"
+          type="text"
+          style={ICON_BUTTON_STYLE}
+          icon={<EditOutlined />}
+          aria-label={t('edit')}
+          onClick={() => onEdit(email)}
+        />
+      </Tooltip>
+      <Tooltip title={t('delete')}>
+        <Button
+          size="small"
+          type="text"
+          danger
+          style={ICON_BUTTON_STYLE}
+          icon={<DeleteOutlined />}
+          aria-label={t('delete')}
+          onClick={() => onDelete(email)}
+        />
+      </Tooltip>
+    </Space>
+  );
+});
+
+const CHIP_STYLE = { margin: 2 } as const;
+const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
+const OVERFLOW_LIST_STYLE = {
+  display: 'flex',
+  flexDirection: 'column' as const,
+  gap: 4,
+  maxWidth: 280,
+  maxHeight: 280,
+  overflowY: 'auto' as const,
+};
+
+interface ClientInboundChipsProps {
+  ids: number[];
+  inboundsById: Record<number, InboundOption>;
+  protocolColors: Record<string, string>;
+  chipLimit: number;
+}
+
+// Attachments never change on a traffic push either, so the same memoisation
+// applies: one Tooltip per visible chip plus a Popover for the overflow.
+export const ClientInboundChips = memo(function ClientInboundChips({
+  ids,
+  inboundsById,
+  protocolColors,
+  chipLimit,
+}: ClientInboundChipsProps) {
+  if (ids.length === 0) return <span className="cell-empty">—</span>;
+
+  const label = (id: number) => {
+    const ib = inboundsById[id];
+    return formatInboundLabel(ib?.tag, ib?.remark);
+  };
+  const chip = (id: number) => {
+    const proto = (inboundsById[id]?.protocol || '').toLowerCase();
+    return (
+      <Tooltip key={id} title={label(id)}>
+        <Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
+      </Tooltip>
+    );
+  };
+
+  const visible = ids.slice(0, chipLimit);
+  const overflow = ids.slice(chipLimit);
+  return (
+    <>
+      {visible.map(chip)}
+      {overflow.length > 0 && (
+        <Popover
+          trigger="click"
+          placement="bottomRight"
+          content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
+        >
+          <Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
+        </Popover>
+      )}
+    </>
+  );
+});

+ 1 - 1
frontend/src/pages/groups/GroupsPage.tsx

@@ -93,7 +93,7 @@ export default function GroupsPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
   const queryClient = useQueryClient();
 
-  const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
+  const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
 
   const groupsQuery = useQuery({
     queryKey: keys.clients.groups(),

+ 26 - 23
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
 import {
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
 
   const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
   const dnsEnabled = !!dns;
+  const sourceHosts = dns?.hosts;
+  const incomingHosts = JSON.stringify(sourceHosts ?? {});
+  const lastWrittenHostsRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    if (!dnsEnabled) {
+      lastWrittenHostsRef.current = '{}';
+      setHostsList([]);
+      return;
+    }
+    if (incomingHosts === lastWrittenHostsRef.current) return;
+    lastWrittenHostsRef.current = incomingHosts;
+    setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
+      domain,
+      values: Array.isArray(values) ? [...values] : [String(values)],
+    })));
+  }, [dnsEnabled, incomingHosts, sourceHosts]);
 
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {
@@ -79,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     });
   }
 
-  useEffect(() => {
-    if (!dns) {
-      setHostsList([]);
-      return;
-    }
-    const src = dns.hosts || {};
-    setHostsList(
-      Object.entries(src).map(([domain, val]) => ({
-        domain,
-        values: Array.isArray(val) ? [...val] : [String(val)],
-      })),
-    );
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [dnsEnabled]);
-
   function syncHosts(next: HostRow[]) {
+    const obj: Record<string, string | string[]> = {};
+    for (const row of next) {
+      if (!row.domain) continue;
+      const vals = (row.values || []).filter(Boolean);
+      if (vals.length === 0) continue;
+      obj[row.domain] = vals.length === 1 ? vals[0] : vals;
+    }
+    lastWrittenHostsRef.current = JSON.stringify(obj);
     setHostsList(next);
     mutate((tt) => {
       if (!tt.dns) return;
-      const obj: Record<string, string | string[]> = {};
-      for (const row of next) {
-        if (!row.domain) continue;
-        const vals = (row.values || []).filter(Boolean);
-        if (vals.length === 0) continue;
-        obj[row.domain] = vals.length === 1 ? vals[0] : vals;
-      }
       if (Object.keys(obj).length > 0) {
         (tt.dns as DnsConfig).hosts = obj;
       } else if ('hosts' in (tt.dns as DnsConfig)) {

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

@@ -68,9 +68,15 @@ export const InboundOptionSchema = z.object({
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);
 
+// The *Count fields are exact; the email arrays stop at the server's cap and
+// only feed the hover popovers, so never derive a counter from their length.
 export const ClientsSummarySchema = z.object({
   total: z.number(),
   active: z.number(),
+  onlineCount: z.number().optional().default(0),
+  depletedCount: z.number().optional().default(0),
+  expiringCount: z.number().optional().default(0),
+  deactiveCount: z.number().optional().default(0),
   online: nullableStringArray,
   depleted: nullableStringArray,
   expiring: nullableStringArray,

+ 127 - 0
frontend/src/test/clients-query-gating.test.tsx

@@ -0,0 +1,127 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useClients } from '@/hooks/useClients';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+const emptyPage = {
+  items: [],
+  total: 0,
+  filtered: 0,
+  page: 1,
+  pageSize: 25,
+  groups: [],
+  summary: {
+    total: 0,
+    active: 0,
+    onlineCount: 0,
+    depletedCount: 0,
+    expiringCount: 0,
+    deactiveCount: 0,
+    online: [],
+    depleted: [],
+    expiring: [],
+    deactive: [],
+  },
+};
+
+function mockPanel(defaults: Record<string, unknown>) {
+  const pagedUrls: string[] = [];
+  vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+    if (url.includes('/clients/list/paged')) {
+      pagedUrls.push(url);
+      return new Msg(true, '', emptyPage);
+    }
+    if (url.includes('/inbounds/options')) return new Msg(true, '', []);
+    return new Msg(true, '', null);
+  });
+  vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
+    if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
+    if (url.includes('/clients/onlines')) return new Msg(true, '', []);
+    return new Msg(true, '', null);
+  });
+  return pagedUrls;
+}
+
+function wrapperFor() {
+  const queryClient = makeTestQueryClient();
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+}
+
+describe('useClients query gating', () => {
+  it('does not fetch the list until the page supplies a query', async () => {
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    // The page has not called setQuery yet, so nothing should have gone out —
+    // this is what used to cost a thrown-away round trip on every page load.
+    expect(pagedUrls).toEqual([]);
+    expect(result.current.fetched).toBe(false);
+  });
+
+  it('issues exactly one request for a page load that settles on one query', async () => {
+    const pagedUrls = mockPanel({ pageSize: 50 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
+    });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    expect(pagedUrls).toHaveLength(1);
+    expect(pagedUrls[0]).toContain('pageSize=50');
+    expect(pagedUrls[0]).toContain('sort=createdAt');
+  });
+
+  it('fetches as soon as a query arrives, without waiting for the settings', async () => {
+    // The page remembers the previous visit's page size in localStorage, so on a
+    // return visit it can supply a query on the first render. The hook must not
+    // hold that back behind /setting/defaultSettings, or the two round trips
+    // serialise and the list lands ~160ms later than it needs to.
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
+    });
+    await waitFor(() => expect(pagedUrls).toHaveLength(1));
+  });
+
+  it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+  });
+
+  it('skips the list, options and onlines queries for mutation-only callers', async () => {
+    const pagedUrls = mockPanel({ pageSize: 25 });
+    const postSpy = vi.mocked(HttpUtil.post);
+    const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
+    });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    expect(pagedUrls).toEqual([]);
+    // subSettings still needs defaultSettings; onlines must not be polled.
+    const posted = postSpy.mock.calls.map((c) => String(c[0]));
+    expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
+    expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
+    expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
+  });
+});

+ 117 - 0
frontend/src/test/clients-row-cells-memo.test.tsx

@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
+import type { InboundOption } from '@/hooks/useClients';
+
+const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
+
+// Counts how often the cell reads the inbound map, which happens once per chip
+// per render. A traffic push re-renders the row, so if the cell is not memoised
+// this climbs every five seconds for every visible row.
+function countingInboundMap(source: Record<number, InboundOption>) {
+  const reads = { count: 0 };
+  const proxy = new Proxy(source, {
+    get(target, key) {
+      if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
+      return target[key as unknown as number];
+    },
+  });
+  return { proxy, reads };
+}
+
+const INBOUNDS: Record<number, InboundOption> = {
+  1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
+  2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
+};
+
+function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
+  const [, setTick] = useState(0);
+  return <>{children(() => setTick((n) => n + 1))}</>;
+}
+
+describe('clients table row cells', () => {
+  it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
+    const { proxy, reads } = countingInboundMap(INBOUNDS);
+    const ids = [1, 2];
+    let bump: () => void = () => {};
+
+    render(
+      <Harness>
+        {(doBump) => {
+          bump = doBump;
+          return (
+            <ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
+          );
+        }}
+      </Harness>,
+    );
+
+    const afterFirstRender = reads.count;
+    expect(afterFirstRender).toBeGreaterThan(0);
+
+    // Three simulated traffic pushes: the parent re-renders, the props do not change.
+    for (let i = 0; i < 3; i++) bump();
+    await Promise.resolve();
+
+    expect(reads.count).toBe(afterFirstRender);
+  });
+
+  it('re-renders the chips when the attachments actually change', async () => {
+    const { proxy, reads } = countingInboundMap(INBOUNDS);
+
+    function Swapper() {
+      const [ids, setIds] = useState<number[]>([1]);
+      return (
+        <>
+          <button type="button" onClick={() => setIds([1, 2])}>swap</button>
+          <ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
+        </>
+      );
+    }
+    render(<Swapper />);
+    const before = reads.count;
+    await userEvent.click(screen.getByRole('button', { name: 'swap' }));
+    expect(reads.count).toBeGreaterThan(before);
+  });
+
+  it('keeps the row actions wired to the right client across re-renders', async () => {
+    const onShowQr = vi.fn();
+    const onEdit = vi.fn();
+    const noop = vi.fn();
+    let bump: () => void = () => {};
+
+    render(
+      <Harness>
+        {(doBump) => {
+          bump = doBump;
+          return (
+            <ClientRowActions
+              email="alice@x"
+              onShowQr={onShowQr}
+              onShowInfo={noop}
+              onResetTraffic={noop}
+              onEdit={onEdit}
+              onDelete={noop}
+            />
+          );
+        }}
+      </Harness>,
+    );
+
+    for (let i = 0; i < 3; i++) bump();
+
+    // Queried by position rather than label: the suite loads the real en-US
+    // bundle, so the aria-labels are translated strings, not keys. Order is
+    // QR, info, reset traffic, edit, delete.
+    const buttons = screen.getAllByRole('button');
+    expect(buttons).toHaveLength(5);
+    await userEvent.click(buttons[0]);
+    await userEvent.click(buttons[3]);
+
+    expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
+    expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
+  });
+});

+ 58 - 2
frontend/src/test/clients-summary.test.ts

@@ -1,6 +1,6 @@
 import { describe, it, expect } from 'vitest';
 
-import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
+import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
 import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
 
 // Parity with web/service/client.go buildClientsSummary: the same client must
@@ -42,6 +42,24 @@ describe('computeClientsSummary', () => {
     expect(s.active).toBe(2); // online@x + offline@x
   });
 
+  it('reports a counter alongside every bucket list', () => {
+    const stats: Row[] = [
+      row({ email: 'online@x', enable: true }),
+      row({ email: 'disabled@x', enable: false }),
+      row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
+      row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
+    ];
+    const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
+
+    // The server caps its lists but never its counters; the live recompute has
+    // both, so the summary card reads the same either way.
+    expect(s.onlineCount).toBe(s.online.length);
+    expect(s.depletedCount).toBe(s.depleted.length);
+    expect(s.expiringCount).toBe(s.expiring.length);
+    expect(s.deactiveCount).toBe(s.deactive.length);
+    expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
+  });
+
   it('depleted wins over disabled and over online', () => {
     const stats: Row[] = [
       row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
@@ -63,7 +81,9 @@ describe('computeClientsSummary', () => {
 
 describe('pickClientsSummary', () => {
   const serverSummary: ClientsSummary = {
-    total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
+    total: 67, active: 58,
+    onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
+    online: [], depleted: [], expiring: [], deactive: [],
   };
 
   it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
@@ -84,3 +104,39 @@ describe('pickClientsSummary', () => {
     expect(s).toEqual(serverSummary);
   });
 });
+
+describe('websocket payload identity preservation', () => {
+  const speed = (up: number, down: number) => ({ up, down });
+
+  it('treats an unchanged speed map as unchanged', () => {
+    const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
+    expect(sameSpeedMap({}, {})).toBe(true);
+  });
+
+  it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
+    const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
+
+    // lastOnline moves for every online client on every push and no counter
+    // depends on it, so it must not force a new snapshot.
+    const onlyLastOnlineMoved: Row[] = [
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
+    ];
+    expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
+
+    for (const changed of [
+      row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
+      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
+    ]) {
+      expect(sameSummaryInputs(base, [changed])).toBe(false);
+    }
+    expect(sameSummaryInputs(base, [])).toBe(false);
+  });
+});

+ 107 - 0
frontend/src/test/dns-tab.test.tsx

@@ -0,0 +1,107 @@
+import { useState } from 'react';
+import { describe, expect, it } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+
+import DnsTab from '@/pages/xray/dns/DnsTab';
+import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
+import { renderWithProviders } from './test-utils';
+
+function withHosts(hosts: Record<string, string>): XraySettingsValue {
+  return {
+    dns: {
+      hosts,
+      servers: [],
+    },
+  } as unknown as XraySettingsValue;
+}
+
+describe('DnsTab', () => {
+  it('keeps an empty row after adding a host', () => {
+    function Harness() {
+      const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
+      const updateTemplate: SetTemplate = (next) => {
+        setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
+      };
+
+      return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
+    }
+
+    renderWithProviders(
+      <Harness />,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
+    fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
+
+    expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
+  });
+
+  it('keeps a row visible while its domain is incomplete', () => {
+    function Harness() {
+      const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
+      const updateTemplate: SetTemplate = (next) => {
+        setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
+      };
+
+      return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
+    }
+
+    renderWithProviders(<Harness />);
+    fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
+    fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
+
+    expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
+  });
+
+  it('shows hosts from an externally refreshed configuration', () => {
+    function Harness() {
+      const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
+      const updateTemplate: SetTemplate = (next) => {
+        setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
+      };
+
+      return (
+        <>
+          <button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
+            Refresh hosts
+          </button>
+          <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
+        </>
+      );
+    }
+
+    renderWithProviders(<Harness />);
+
+    fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
+    expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
+    expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
+  });
+
+  it('clears an incomplete host draft when DNS is disabled', () => {
+    function Harness() {
+      const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
+      const updateTemplate: SetTemplate = (next) => {
+        setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
+      };
+
+      return (
+        <>
+          <button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
+          <button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
+          <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
+        </>
+      );
+    }
+
+    renderWithProviders(<Harness />);
+    fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
+    fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
+    fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
+    fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
+    fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
+
+    expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
+  });
+});

+ 51 - 0
frontend/src/test/storybook-theme.test.tsx

@@ -0,0 +1,51 @@
+import { render } from '@testing-library/react';
+import { afterEach, expect, test } from 'vitest';
+
+import { withTheme } from '../../.storybook/preview';
+import { ThemeProvider } from '@/hooks/useTheme';
+
+function Story() {
+  return <div>Story</div>;
+}
+
+function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
+  return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
+}
+
+afterEach(() => {
+  document.body.className = '';
+  document.documentElement.removeAttribute('data-theme');
+});
+
+test('preserves unrelated body classes when applying the Storybook theme', () => {
+  document.body.className = 'storybook-fixture dark';
+  document.documentElement.setAttribute('data-theme', 'ultra-dark');
+  const { rerender } = render(<StorybookTheme theme="light" />);
+
+  expect(document.body.classList.contains('storybook-fixture')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(false);
+  expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
+
+  rerender(<StorybookTheme theme="dark" />);
+
+  expect(document.body.classList.contains('storybook-fixture')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(false);
+});
+
+test('preserves unrelated body classes when applying the panel theme', () => {
+  document.body.className = 'panel-fixture';
+  const message = document.createElement('div');
+  message.id = 'message';
+  message.className = 'message-fixture';
+  document.body.append(message);
+
+  render(<ThemeProvider><div>Panel</div></ThemeProvider>);
+
+  expect(document.body.classList.contains('panel-fixture')).toBe(true);
+  expect(document.body.classList.contains('dark')).toBe(true);
+  expect(document.body.classList.contains('light')).toBe(false);
+  expect(message.classList.contains('message-fixture')).toBe(true);
+  expect(message.classList.contains('dark')).toBe(true);
+});

+ 78 - 1
frontend/src/test/use-all-settings.test.tsx

@@ -1,9 +1,10 @@
 import type { ReactNode } from 'react';
-import { renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
 import { QueryClientProvider } from '@tanstack/react-query';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { keys } from '@/api/queryKeys';
 import { makeTestQueryClient } from '@/test/test-utils';
 import { HttpUtil, Msg } from '@/utils';
 
@@ -25,4 +26,80 @@ describe('useAllSettings', () => {
     await waitFor(() => expect(result.current.fetched).toBe(true));
     expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
   });
+
+  it('keeps an edited setting when a refetch returns older server data', async () => {
+    const values = [
+      { webPort: 2053 },
+      { webPort: 2054 },
+    ];
+    let index = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ webPort: 3000 }));
+    await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
+    expect(result.current.allSetting.webPort).toBe(3000);
+    expect(result.current.saveDisabled).toBe(false);
+  });
+
+  it('hydrates redacted secrets after a successful save', async () => {
+    let fetchCount = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/setting/all') {
+        fetchCount += 1;
+        return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
+      }
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
+    await act(async () => {
+      await result.current.saveAll();
+    });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
+    expect(result.current.allSetting.tgBotToken).toBe('');
+    expect(result.current.allSetting.hasTgBotToken).toBe(true);
+    expect(result.current.saveDisabled).toBe(true);
+  });
+
+  it('establishes a saved baseline for a full-payload security save', async () => {
+    let fetchCount = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/setting/all') {
+        fetchCount += 1;
+        return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
+      }
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
+    await act(async () => {
+      await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
+    });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
+    expect(result.current.allSetting.tgBotToken).toBe('');
+    expect(result.current.allSetting.hasTgBotToken).toBe(true);
+    expect(result.current.saveDisabled).toBe(true);
+  });
 });

+ 70 - 0
frontend/src/test/use-xray-setting.test.tsx

@@ -0,0 +1,70 @@
+import type { ReactNode } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useXraySetting } from '@/hooks/useXraySetting';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+function xrayPayload(overrides: Record<string, unknown> = {}) {
+  return {
+    xraySetting: {},
+    inboundTags: [],
+    clientReverseTags: [],
+    outboundTestUrl: 'https://test.example',
+    subscriptionOutbounds: [],
+    subscriptionOutboundTags: [],
+    ...overrides,
+  };
+}
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+beforeEach(() => {
+  vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
+});
+
+describe('useXraySetting', () => {
+  it('refreshes server-derived outbounds while the editor is dirty', async () => {
+    let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.setXraySetting('{"outbounds":[]}'));
+    payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
+    await act(async () => result.current.fetchAll());
+
+    await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
+    expect(result.current.xraySetting).toBe('{"outbounds":[]}');
+  });
+
+  it('keeps the outbound test URL input empty when it is cleared', async () => {
+    const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.setOutboundTestUrl(''));
+
+    expect(result.current.outboundTestUrl).toBe('');
+    expect(result.current.saveDisabled).toBe(true);
+  });
+});

+ 86 - 0
frontend/src/test/useServerDraft.test.tsx

@@ -0,0 +1,86 @@
+import { StrictMode } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { useServerDraft } from '@/hooks/useServerDraft';
+
+describe('useServerDraft', () => {
+  it('keeps an edited draft when the server refetches', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'edited' }));
+    rerender({ server: { value: 'two' } });
+
+    expect(result.current.draft).toEqual({ value: 'edited' });
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('accepts a refetch that matches the saved draft', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'saved' }));
+    rerender({ server: { value: 'saved' } });
+
+    expect(result.current.draft).toEqual({ value: 'saved' });
+    expect(result.current.isDirty).toBe(false);
+  });
+
+  it('compares a preserved draft with the latest server value', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'later' }));
+    rerender({ server: { value: 'saved' } });
+    act(() => result.current.setDraft({ value: 'one' }));
+
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('hydrates clean drafts under StrictMode', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      {
+        initialProps: { server: { value: 'one' } },
+        wrapper: StrictMode,
+      },
+    );
+
+    rerender({ server: { value: 'two' } });
+
+    expect(result.current.draft).toEqual({ value: 'two' });
+    expect(result.current.isDirty).toBe(false);
+  });
+
+  it('preserves an edit made before the first server response', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: undefined as { value: string } | undefined } },
+    );
+
+    act(() => result.current.setDraft({ value: 'edited' }));
+    rerender({ server: { value: 'one' } });
+
+    expect(result.current.draft).toEqual({ value: 'edited' });
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('marks a sent draft clean before its refetch arrives', () => {
+    const { result } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'saved' }));
+    act(() => result.current.markSaved({ value: 'saved' }));
+
+    expect(result.current.isDirty).toBe(false);
+  });
+});

+ 4 - 4
go.mod

@@ -13,14 +13,14 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
-	github.com/mattn/go-sqlite3 v1.14.48
+	github.com/mattn/go-sqlite3 v1.14.49
 	github.com/mymmrac/telego v1.11.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.6
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/valyala/fasthttp v1.72.0
+	github.com/valyala/fasthttp v1.73.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
 	go.uber.org/atomic v1.11.0
@@ -99,7 +99,7 @@ require (
 	go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
 	golang.org/x/arch v0.29.0 // indirect
-	golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
+	golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
 	golang.org/x/mod v0.38.0 // indirect
 	golang.org/x/net v0.57.0
 	golang.org/x/sync v0.22.0 // indirect
@@ -108,7 +108,7 @@ require (
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
 	google.golang.org/protobuf v1.36.11
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 8 - 8
go.sum

@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+
 github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
-github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
+github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
 github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -204,8 +204,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
 github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
-github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
+github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
+github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
 github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
 github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -250,8 +250,8 @@ golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
 golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
 golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
 golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
-golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
+golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
+golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
 golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
 golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
 google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.5.0
+3.6.0

+ 1 - 1
internal/database/backup_test.go

@@ -35,7 +35,7 @@ func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
 	firstWrite := make(chan error, 1)
 	writesDone := make(chan error, 1)
 	go func() {
-		for i := 0; i < 128; i++ {
+		for i := range 128 {
 			if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
 				if i == 0 {
 					firstWrite <- err

+ 4 - 8
internal/sub/external_subscription_test.go

@@ -43,11 +43,9 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
 	results := make(chan []string, callers)
 	var wg sync.WaitGroup
 	for range callers {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			results <- fetchSubscriptionLinks(srv.URL)
-		}()
+		})
 	}
 
 	time.Sleep(100 * time.Millisecond)
@@ -123,11 +121,9 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
 	results := make(chan []string, callers)
 	var wg sync.WaitGroup
 	for range callers {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			results <- fetchSubscriptionLinks(staleURL)
-		}()
+		})
 	}
 
 	time.Sleep(100 * time.Millisecond)

+ 5 - 9
internal/sub/forwarded_trust_test.go

@@ -40,10 +40,6 @@ func setTrustedProxyCIDRs(t *testing.T, value string) {
 	}
 }
 
-func storedAs(value string) *string {
-	return &value
-}
-
 func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 	tests := []struct {
 		name             string
@@ -65,7 +61,7 @@ func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 		},
 		{
 			name:             "empty stored value keeps trusting forwarded headers",
-			stored:           storedAs(""),
+			stored:           new(""),
 			remoteAddr:       "203.0.113.9:51000",
 			wantScheme:       "https",
 			wantHost:         "sub.example.net",
@@ -74,7 +70,7 @@ func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 		},
 		{
 			name:             "stored shipped default keeps trusting forwarded headers",
-			stored:           storedAs(service.DefaultTrustedProxyCIDRs),
+			stored:           new(service.DefaultTrustedProxyCIDRs),
 			remoteAddr:       "203.0.113.9:51000",
 			wantScheme:       "https",
 			wantHost:         "sub.example.net",
@@ -83,7 +79,7 @@ func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 		},
 		{
 			name:             "declared boundary ignores an origin outside it",
-			stored:           storedAs("10.0.0.0/8"),
+			stored:           new("10.0.0.0/8"),
 			remoteAddr:       "203.0.113.9:51000",
 			wantScheme:       "http",
 			wantHost:         "panel.example.com",
@@ -92,7 +88,7 @@ func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 		},
 		{
 			name:             "declared boundary trusts an origin inside it",
-			stored:           storedAs("10.0.0.0/8"),
+			stored:           new("10.0.0.0/8"),
 			remoteAddr:       "10.1.2.3:44000",
 			wantScheme:       "https",
 			wantHost:         "sub.example.net",
@@ -101,7 +97,7 @@ func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
 		},
 		{
 			name:             "declared boundary ignores an unparsable origin",
-			stored:           storedAs("10.0.0.0/8"),
+			stored:           new("10.0.0.0/8"),
 			remoteAddr:       "not-an-ip",
 			wantScheme:       "http",
 			wantHost:         "panel.example.com",

+ 27 - 9
internal/web/job/xray_traffic_job.go

@@ -29,6 +29,31 @@ type XrayTrafficJob struct {
 // refetch for the rest.
 const clientStatsSnapshotMaxClients = 5000
 
+// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
+// alongside the active-email list and set derived from the same pass.
+//
+// Xray reports a row for every known email whether or not it transferred
+// anything, so on a large panel nearly every delta is zero. The database writes
+// and the external-API inform consume the full slice before this point; the
+// WebSocket frame only feeds the dashboard's live speed column, where an absent
+// row and a zero row render identically. Broadcasting just the movers keeps that
+// frame from growing with the client count — at 5k clients it was carrying about
+// a megabyte of zeros every five seconds.
+func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
+	moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
+	emails := make([]string, 0, len(clientTraffics))
+	active := make(map[string]bool, len(clientTraffics))
+	for _, ct := range clientTraffics {
+		if ct == nil || ct.Up+ct.Down <= 0 {
+			continue
+		}
+		moved = append(moved, ct)
+		emails = append(emails, ct.Email)
+		active[ct.Email] = true
+	}
+	return moved, emails, active
+}
+
 const externalInformTimeout = 3 * time.Second
 
 var externalInformClient = &fasthttp.Client{
@@ -88,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
 	// than the shared last_online column, which remote-node syncs also bump
 	// and would otherwise make a client active only on a remote node appear
 	// online on local inbounds.
-	activeEmails := make([]string, 0, len(clientTraffics))
-	deltaActive := make(map[string]bool, len(clientTraffics))
-	for _, ct := range clientTraffics {
-		if ct != nil && ct.Up+ct.Down > 0 {
-			activeEmails = append(activeEmails, ct.Email)
-			deltaActive[ct.Email] = true
-		}
-	}
+	movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
 	// When the core supports the online-stats API, union in connection-based
 	// onlines. Neither signal alone covers everything: an idle-but-connected
 	// client moves no bytes between polls (the delta heuristic's blind spot),
@@ -179,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
 	}
 	websocket.BroadcastTraffic(map[string]any{
 		"traffics":       traffics,
-		"clientTraffics": clientTraffics,
+		"clientTraffics": movedTraffics,
 		"onlineClients":  onlineClients,
 		"onlineByGuid":   j.inboundService.GetOnlineClientsByGuid(),
 		"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),

+ 59 - 0
internal/web/job/xray_traffic_job_broadcast_test.go

@@ -0,0 +1,59 @@
+package job
+
+import (
+	"slices"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSplitMovedClientTraffics(t *testing.T) {
+	rows := []*xray.ClientTraffic{
+		{Email: "idle@x", Up: 0, Down: 0},
+		{Email: "up@x", Up: 1024, Down: 0},
+		{Email: "down@x", Up: 0, Down: 2048},
+		nil,
+		{Email: "both@x", Up: 512, Down: 512},
+		{Email: "alsoidle@x", Up: 0, Down: 0},
+	}
+
+	moved, emails, active := splitMovedClientTraffics(rows)
+
+	t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
+		got := make([]string, 0, len(moved))
+		for _, ct := range moved {
+			got = append(got, ct.Email)
+		}
+		want := []string{"up@x", "down@x", "both@x"}
+		if !slices.Equal(got, want) {
+			t.Fatalf("moved = %v, want %v", got, want)
+		}
+	})
+
+	t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
+		want := []string{"up@x", "down@x", "both@x"}
+		if !slices.Equal(emails, want) {
+			t.Fatalf("activeEmails = %v, want %v", emails, want)
+		}
+		if len(active) != len(want) {
+			t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
+		}
+		for _, e := range want {
+			if !active[e] {
+				t.Fatalf("deltaActive missing %q", e)
+			}
+		}
+		if active["idle@x"] {
+			t.Fatal("an idle client must not count as active")
+		}
+	})
+
+	t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
+		moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
+			{Email: "a@x"}, {Email: "b@x"},
+		})
+		if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
+			t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
+		}
+	})
+}

+ 481 - 394
internal/web/service/client_paging.go

@@ -1,13 +1,16 @@
 package service
 
 import (
-	"slices"
 	"sort"
 	"strconv"
 	"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/xray"
+
+	"gorm.io/gorm"
 )
 
 // ClientSlim is the row-shape used by the clients page. It drops fields the
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
 
 // ClientsSummary collects per-bucket counts plus the matching email lists so
 // the clients page can render the dashboard stat cards and their hover
-// popovers without shipping the full client array.
+// popovers without shipping the full client array. The counters are exact;
+// the lists stop at clientSummaryEmailCap entries and only back the popovers.
 type ClientsSummary struct {
-	Total    int      `json:"total"`
-	Active   int      `json:"active"`
-	Online   []string `json:"online"`
-	Depleted []string `json:"depleted"`
-	Expiring []string `json:"expiring"`
-	Deactive []string `json:"deactive"`
+	Total         int      `json:"total"`
+	Active        int      `json:"active"`
+	OnlineCount   int      `json:"onlineCount"`
+	DepletedCount int      `json:"depletedCount"`
+	ExpiringCount int      `json:"expiringCount"`
+	DeactiveCount int      `json:"deactiveCount"`
+	Online        []string `json:"online"`
+	Depleted      []string `json:"depleted"`
+	Expiring      []string `json:"expiring"`
+	Deactive      []string `json:"deactive"`
 }
 
 const (
 	clientPageDefaultSize = 25
 	clientPageMaxSize     = 200
+	// clientSummaryEmailCap bounds each bucket's email list. Shipping every
+	// matching email made the response — and the Zod validation the page runs
+	// over it — grow with the client count on a request that repeats every 5s,
+	// and left the hover popover rendering thousands of rows.
+	clientSummaryEmailCap = 200
+	// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
+	// matching the sentinel the in-memory comparator used.
+	sqlNeverSentinel = "4611686018427387903"
+	// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
+	// false: without the COALESCE such a row would match neither the enabled
+	// nor the disabled branch of any predicate.
+	sqlClientEnabled = "COALESCE(c.enable, FALSE)"
 )
 
-// ListPaged loads every client (with traffic + attachments) into memory,
-// applies the requested filter / search / protocol predicates, sorts, and
-// returns the requested page along with total and filtered counts. The DB
-// query itself is unchanged from List(); the win is that the response
-// only carries 25-ish slim rows over the wire instead of all 2000 full
-// records, which on real panels was the dominant cost.
-func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
-	all, err := s.List()
-	if err != nil {
-		return nil, err
+const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
+	OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
+	OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
+	OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
+	OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
+	OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
+	OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
+
+// clientQuery builds the statements behind the clients page: a clients row
+// joined to its traffic counters, plus the expressions every bucket predicate
+// shares. Filtering, sorting, paging and the summary all run in the database.
+// Loading every client (with attachments and traffic) into Go and doing it in
+// memory cost ~200ms per request at 20k clients on a page that polls every
+// 5 seconds, which is what made the table feel stuck on large panels.
+type clientQuery struct {
+	db               *gorm.DB
+	joins            []clientQueryJoin
+	usedExpr         string
+	nowMs            int64
+	expireDiffMs     int64
+	trafficDiffBytes int64
+}
+
+type clientQueryJoin struct {
+	sql  string
+	args []any
+}
+
+func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
+	q := clientQuery{
+		db:               db,
+		nowMs:            nowMs,
+		expireDiffMs:     expireDiffMs,
+		trafficDiffBytes: trafficDiffBytes,
+		joins:            []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
+		usedExpr:         "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
+	}
+	freshSince := globalTrafficFreshSince()
+	var probe int64
+	err := db.Model(&model.ClientGlobalTraffic{}).
+		Where("updated_at >= ?", freshSince).
+		Limit(1).Count(&probe).Error
+	if err != nil || probe == 0 {
+		return q
+	}
+	// A master still pushes cross-panel usage here, so the predicates have to
+	// see the same raised counters overlayGlobalTraffic applies on read.
+	q.joins = append(q.joins, clientQueryJoin{
+		sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
+			" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
+		args: []any{freshSince},
+	})
+	q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
+		" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
+	return q
+}
+
+func (q clientQuery) from() *gorm.DB {
+	tx := q.db.Table("clients AS c")
+	for _, j := range q.joins {
+		tx = tx.Joins(j.sql, j.args...)
+	}
+	return tx
+}
+
+func (q clientQuery) depletedExpr() string {
+	return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
+		" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
+}
+
+func (q clientQuery) nearDepletionExpr() string {
+	return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
+		" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
+}
+
+func (q clientQuery) expiringExpr() string {
+	return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
+}
+
+func (q clientQuery) activeExpr() string {
+	return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
+}
+
+// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
+// client that also ran out counts once, under depleted, so the stat cards add
+// up to the client total.
+func (q clientQuery) summaryDeactiveExpr() string {
+	return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
+}
+
+// applyParams narrows tx by every predicate the clients page sends. Matching is
+// OR within a field and AND across fields, mirroring the query-param contract.
+// The second return says whether anything narrowed the set, so an unfiltered
+// request can reuse the total count instead of scanning for it again.
+func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
+	narrowed := false
+	where := func(cond string, args ...any) {
+		narrowed = true
+		tx = tx.Where(cond, args...)
+	}
+
+	if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
+		pattern := "%" + escapeLikeLiteral(needle) + "%"
+		where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
+	}
+	if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
+		where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
+			" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
 	}
-	total := len(all)
+	if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
+		where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
+	}
+	if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
+		cond, args := q.bucketCond(buckets, onlines)
+		where(cond, args...)
+	}
+	if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
+		// 0 means "never expires" and a negative value is the delayed-start
+		// sentinel; both sit outside any bounded range.
+		where("c.expiry_time > 0")
+		if params.ExpiryFrom > 0 {
+			where("c.expiry_time >= ?", params.ExpiryFrom)
+		}
+		if params.ExpiryTo > 0 {
+			where("c.expiry_time <= ?", params.ExpiryTo)
+		}
+	}
+	if params.UsageFrom > 0 {
+		where(q.usedExpr+" >= ?", params.UsageFrom)
+	}
+	if params.UsageTo > 0 {
+		where(q.usedExpr+" <= ?", params.UsageTo)
+	}
+	switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
+	case "on":
+		where("COALESCE(c.reset, 0) > 0")
+	case "off":
+		where("COALESCE(c.reset, 0) <= 0")
+	}
+	switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
+	case "yes":
+		where("COALESCE(c.tg_id, 0) <> 0")
+	case "no":
+		where("COALESCE(c.tg_id, 0) = 0")
+	}
+	switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
+	case "yes":
+		where("TRIM(COALESCE(c.comment, '')) <> ''")
+	case "no":
+		where("TRIM(COALESCE(c.comment, '')) = ''")
+	}
+	if groups := parseCSVStrings(params.Group); len(groups) > 0 {
+		where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
+	}
+	return tx, narrowed
+}
+
+func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
+	conds := make([]string, 0, len(buckets))
+	args := make([]any, 0, len(buckets))
+	for _, b := range buckets {
+		switch b {
+		case "active":
+			conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
+		case "deactive":
+			conds = append(conds, "(NOT "+sqlClientEnabled+")")
+		case "depleted":
+			conds = append(conds, q.depletedExpr())
+		case "expiring":
+			conds = append(conds, q.expiringExpr())
+		case "online":
+			cond, inArgs := emailInCond("c.email", onlines)
+			conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
+			args = append(args, inArgs...)
+		default:
+			// An unrecognised bucket name matched every client before the
+			// predicates moved into SQL; keep that so a stale saved filter
+			// cannot silently empty the table.
+			conds = append(conds, "(1 = 1)")
+		}
+	}
+	return "(" + strings.Join(conds, " OR ") + ")", args
+}
+
+func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
+	dir := " ASC"
+	if order == "descend" {
+		dir = " DESC"
+	}
+	// createdAt / updatedAt / lastOnline broke ties on the client id inside the
+	// comparator, so reversing the sort reversed the tiebreak with it. The
+	// other keys leaned on a stable sort over an id-ordered slice instead.
+	tieDir := " ASC"
+	var expr string
+	switch sortKey {
+	case "enable":
+		expr = sqlClientEnabled
+	case "email":
+		expr = "LOWER(c.email)"
+	case "inboundIds":
+		expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
+	case "traffic":
+		expr = q.usedExpr
+	case "remaining":
+		expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
+	case "expiryTime":
+		expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
+	case "createdAt":
+		expr, tieDir = "c.created_at", dir
+	case "updatedAt":
+		expr, tieDir = "c.updated_at", dir
+	case "lastOnline":
+		expr, tieDir = "COALESCE(ct.last_online, 0)", dir
+	default:
+		return tx.Order("c.id ASC")
+	}
+	return tx.Order(expr + dir + ", c.id" + tieDir)
+}
+
+// ListPaged returns one page of clients together with the counts the clients
+// page header needs. Every predicate runs in SQL, so the cost tracks the page
+// size rather than the number of clients on the panel.
+func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
+	db := database.GetDB()
 
 	pageSize := params.PageSize
 	if pageSize <= 0 {
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		page = 1
 	}
 
-	protocols := parseCSVStrings(params.Protocol)
-	inboundIDs := parseCSVInts(params.Inbound)
-	buckets := parseCSVStrings(params.Filter)
-
-	var protocolByInbound map[int]string
-	if len(protocols) > 0 {
-		inbounds, err := inboundSvc.GetAllInbounds()
-		if err == nil {
-			protocolByInbound = make(map[int]string, len(inbounds))
-			for _, ib := range inbounds {
-				protocolByInbound[ib.Id] = string(ib.Protocol)
-			}
-		}
-	}
-
-	onlines := inboundSvc.GetOnlineClients()
-	onlineSet := make(map[string]struct{}, len(onlines))
-	for _, e := range onlines {
-		onlineSet[e] = struct{}{}
-	}
-
 	var expireDiffMs, trafficDiffBytes int64
 	if settingSvc != nil {
 		if v, err := settingSvc.GetExpireDiff(); err == nil {
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		}
 	}
 
-	nowMs := time.Now().UnixMilli()
-	summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
-
-	needle := strings.ToLower(strings.TrimSpace(params.Search))
+	onlines := inboundSvc.GetOnlineClients()
+	q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
 
-	filtered := make([]ClientWithAttachments, 0, len(all))
-	for _, c := range all {
-		if needle != "" && !clientMatchesSearch(c, needle) {
-			continue
-		}
-		if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
-			continue
-		}
-		if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
-			continue
-		}
-		if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
-			continue
-		}
-		if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
-			continue
-		}
-		if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
-			continue
-		}
-		if !clientMatchesAutoRenew(c, params.AutoRenew) {
-			continue
-		}
-		if !clientMatchesHasTgID(c, params.HasTgID) {
-			continue
-		}
-		if !clientMatchesHasComment(c, params.HasComment) {
-			continue
-		}
-		if !clientMatchesAnyGroup(c, params.Group) {
-			continue
-		}
-		filtered = append(filtered, c)
+	var total int64
+	if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
+		return nil, err
 	}
 
-	sortClients(filtered, params.Sort, params.Order)
-
-	filteredCount := len(filtered)
-	start := (page - 1) * pageSize
-	end := start + pageSize
-	if start > filteredCount {
-		start = filteredCount
-	}
-	if end > filteredCount {
-		end = filteredCount
+	summary, err := q.summary(onlines, int(total))
+	if err != nil {
+		return nil, err
 	}
-	pageRows := filtered[start:end]
 
-	items := make([]ClientSlim, 0, len(pageRows))
-	for _, c := range pageRows {
-		items = append(items, toClientSlim(c))
+	filtered := total
+	if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
+		if err := scoped.Count(&filtered).Error; err != nil {
+			return nil, err
+		}
 	}
 
-	groupRows, gErr := s.ListGroups()
-	if gErr != nil {
-		return nil, gErr
+	items := []ClientSlim{}
+	offset := (page - 1) * pageSize
+	if int64(offset) < filtered {
+		items, err = q.pageRows(params, onlines, offset, pageSize)
+		if err != nil {
+			return nil, err
+		}
 	}
-	groups := make([]string, 0, len(groupRows))
-	for _, g := range groupRows {
-		groups = append(groups, g.Name)
+
+	groups, err := s.listGroupNames()
+	if err != nil {
+		return nil, err
 	}
 
 	return &ClientPageResponse{
 		Items:    items,
-		Total:    total,
-		Filtered: filteredCount,
+		Total:    int(total),
+		Filtered: int(filtered),
 		Page:     page,
 		PageSize: pageSize,
 		Summary:  summary,
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 	}, nil
 }
 
-func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
+// pageRows resolves the requested page to client ids, then loads the records,
+// attachments and traffic for those ids only. A page never exceeds
+// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
+// lists need no chunking.
+func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
+	tx, _ := q.applyParams(q.from(), params, onlines)
+	var ids []int
+	if err := q.applyOrder(tx, params.Sort, params.Order).
+		Offset(offset).Limit(limit).
+		Pluck("c.id", &ids).Error; err != nil {
+		return nil, err
+	}
+	if len(ids) == 0 {
+		return []ClientSlim{}, nil
+	}
+
+	var records []model.ClientRecord
+	if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
+		return nil, err
+	}
+	byId := make(map[int]*model.ClientRecord, len(records))
+	emails := make([]string, 0, len(records))
+	for i := range records {
+		byId[records[i].Id] = &records[i]
+		if records[i].Email != "" {
+			emails = append(emails, records[i].Email)
+		}
+	}
+
+	var links []model.ClientInbound
+	if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
+		return nil, err
+	}
+	attachments := make(map[int][]int, len(ids))
+	for _, l := range links {
+		attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+	}
+
+	trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
+	if len(emails) > 0 {
+		var stats []xray.ClientTraffic
+		if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
+			return nil, err
+		}
+		overlayGlobalTrafficValues(q.db, stats)
+		for i := range stats {
+			trafficByEmail[stats[i].Email] = &stats[i]
+		}
+	}
+
+	items := make([]ClientSlim, 0, len(ids))
+	for _, id := range ids {
+		rec := byId[id]
+		if rec == nil {
+			continue
+		}
+		items = append(items, ClientSlim{
+			Email:      rec.Email,
+			SubID:      rec.SubID,
+			Enable:     rec.Enable,
+			TotalGB:    rec.TotalGB,
+			ExpiryTime: rec.ExpiryTime,
+			LimitIP:    rec.LimitIP,
+			Reset:      rec.Reset,
+			Group:      rec.Group,
+			Comment:    rec.Comment,
+			InboundIds: attachments[rec.Id],
+			Traffic:    trafficByEmail[rec.Email],
+			CreatedAt:  rec.CreatedAt,
+			UpdatedAt:  rec.UpdatedAt,
+		})
+	}
+	return items, nil
+}
+
+func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
 	s := ClientsSummary{
-		Total:    len(all),
+		Total:    total,
 		Online:   []string{},
 		Depleted: []string{},
 		Expiring: []string{},
 		Deactive: []string{},
 	}
-	for _, c := range all {
-		used := int64(0)
-		if c.Traffic != nil {
-			used = c.Traffic.Up + c.Traffic.Down
-		}
-		exhausted := c.TotalGB > 0 && used >= c.TotalGB
-		expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
-		if c.Enable {
-			if _, ok := onlineSet[c.Email]; ok {
-				s.Online = append(s.Online, c.Email)
-			}
-		}
-		if exhausted || expired {
-			s.Depleted = append(s.Depleted, c.Email)
+
+	var counts struct {
+		Active   int64
+		Depleted int64
+		Expiring int64
+		Deactive int64
+	}
+	// SUM over an empty table yields NULL, which not every driver scans into an
+	// int; COALESCE keeps a panel with no clients from erroring out.
+	if err := q.from().Select(
+		"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
+			" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
+			" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
+			" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
+	).Scan(&counts).Error; err != nil {
+		return s, err
+	}
+	s.Active = int(counts.Active)
+	s.DepletedCount = int(counts.Depleted)
+	s.ExpiringCount = int(counts.Expiring)
+	s.DeactiveCount = int(counts.Deactive)
+
+	buckets := []struct {
+		cond  string
+		count int
+		out   *[]string
+	}{
+		{q.depletedExpr(), s.DepletedCount, &s.Depleted},
+		{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
+		{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
+	}
+	for _, b := range buckets {
+		// The counter already says the bucket is empty, so skip the scan that
+		// would look for emails it cannot find.
+		if b.count == 0 {
 			continue
 		}
-		if !c.Enable {
-			s.Deactive = append(s.Deactive, c.Email)
-			continue
+		var emails []string
+		if err := q.from().Where(b.cond).
+			Order("c.id ASC").Limit(clientSummaryEmailCap).
+			Pluck("c.email", &emails).Error; err != nil {
+			return s, err
 		}
-		nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
-		nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
-		if nearExpiry || nearLimit {
-			s.Expiring = append(s.Expiring, c.Email)
-		} else {
-			s.Active++
+		if len(emails) > 0 {
+			*b.out = emails
 		}
 	}
-	return s
-}
 
-func toClientSlim(c ClientWithAttachments) ClientSlim {
-	return ClientSlim{
-		Email:      c.Email,
-		SubID:      c.SubID,
-		Enable:     c.Enable,
-		TotalGB:    c.TotalGB,
-		ExpiryTime: c.ExpiryTime,
-		LimitIP:    c.LimitIP,
-		Reset:      c.Reset,
-		Group:      c.Group,
-		Comment:    c.Comment,
-		InboundIds: c.InboundIds,
-		Traffic:    c.Traffic,
-		CreatedAt:  c.CreatedAt,
-		UpdatedAt:  c.UpdatedAt,
+	online, onlineCount, err := q.onlineEmails(onlines)
+	if err != nil {
+		return s, err
 	}
+	s.Online = online
+	s.OnlineCount = onlineCount
+	return s, nil
+}
+
+// onlineEmails intersects the emails xray reports as connected with the enabled
+// clients this panel stores. The online set lives in memory and is bounded by
+// live connections, so it drives the query rather than a scan of every client.
+func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
+	matched := []string{}
+	count := 0
+	for _, batch := range chunkStrings(onlines, sqlInChunk) {
+		var page []string
+		if err := q.db.Model(&model.ClientRecord{}).
+			Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
+			Order("id ASC").
+			Pluck("email", &page).Error; err != nil {
+			return nil, 0, err
+		}
+		count += len(page)
+		if room := clientSummaryEmailCap - len(matched); room > 0 {
+			matched = append(matched, page[:min(room, len(page))]...)
+		}
+	}
+	return matched, count, nil
 }
 
-func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
-	if needle == "" {
-		return true
+// listGroupNames returns the group names the clients page offers as filters:
+// the stored groups plus any name a client still carries. ListGroups also sums
+// per-client traffic per group, which this page never reads and which costs a
+// full join over client_traffics on every poll.
+func (s *ClientService) listGroupNames() ([]string, error) {
+	db := database.GetDB()
+	var stored []string
+	if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
+		return nil, err
+	}
+	var used []string
+	if err := db.Model(&model.ClientRecord{}).
+		Where("group_name <> ''").
+		Distinct().
+		Pluck("group_name", &used).Error; err != nil {
+		return nil, err
 	}
-	candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
-	for _, v := range candidates {
-		if v != "" && strings.Contains(strings.ToLower(v), needle) {
-			return true
+	seen := make(map[string]struct{}, len(stored)+len(used))
+	out := make([]string, 0, len(stored)+len(used))
+	for _, list := range [][]string{stored, used} {
+		for _, name := range list {
+			if name == "" {
+				continue
+			}
+			if _, dup := seen[name]; dup {
+				continue
+			}
+			seen[name] = struct{}{}
+			out = append(out, name)
 		}
 	}
-	if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
-		return true
+	sort.Slice(out, func(i, j int) bool {
+		return strings.ToLower(out[i]) < strings.ToLower(out[j])
+	})
+	return out, nil
+}
+
+func sqlInt(v int64) string {
+	return strconv.FormatInt(v, 10)
+}
+
+// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
+// matching literally, the way strings.Contains did.
+func escapeLikeLiteral(s string) string {
+	return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
+}
+
+// emailInCond renders an IN over a possibly large email set, split so no single
+// IN list outgrows the drivers' bind-parameter ceiling.
+func emailInCond(column string, emails []string) (string, []any) {
+	if len(emails) == 0 {
+		return "1 = 0", nil
+	}
+	chunks := chunkStrings(emails, sqlInChunk)
+	parts := make([]string, 0, len(chunks))
+	args := make([]any, 0, len(chunks))
+	for _, chunk := range chunks {
+		parts = append(parts, column+" IN ?")
+		args = append(args, chunk)
 	}
-	return false
+	return "(" + strings.Join(parts, " OR ") + ")", args
 }
 
 // parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
 	}
 	return out
 }
-
-func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
-	for _, id := range c.InboundIds {
-		p := byInbound[id]
-		if p == "" {
-			continue
-		}
-		if slices.Contains(protocols, strings.ToLower(p)) {
-			return true
-		}
-	}
-	return false
-}
-
-func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
-	for _, id := range c.InboundIds {
-		if slices.Contains(inboundIds, id) {
-			return true
-		}
-	}
-	return false
-}
-
-func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
-	for _, b := range buckets {
-		if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
-			return true
-		}
-	}
-	return false
-}
-
-func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
-	if fromMs <= 0 && toMs <= 0 {
-		return true
-	}
-	// expiryTime of 0 means "never expires"; treat it as outside any bounded
-	// range so users filtering by date see only clients with concrete expiries.
-	if c.ExpiryTime == 0 {
-		return false
-	}
-	// Negative expiry is the "delayed start" sentinel; same treatment as never.
-	if c.ExpiryTime < 0 {
-		return false
-	}
-	if fromMs > 0 && c.ExpiryTime < fromMs {
-		return false
-	}
-	if toMs > 0 && c.ExpiryTime > toMs {
-		return false
-	}
-	return true
-}
-
-func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
-	if fromBytes <= 0 && toBytes <= 0 {
-		return true
-	}
-	used := int64(0)
-	if c.Traffic != nil {
-		used = c.Traffic.Up + c.Traffic.Down
-	}
-	if fromBytes > 0 && used < fromBytes {
-		return false
-	}
-	if toBytes > 0 && used > toBytes {
-		return false
-	}
-	return true
-}
-
-func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
-	switch strings.ToLower(strings.TrimSpace(mode)) {
-	case "on":
-		return c.Reset > 0
-	case "off":
-		return c.Reset <= 0
-	}
-	return true
-}
-
-func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
-	switch strings.ToLower(strings.TrimSpace(mode)) {
-	case "yes":
-		return c.TgID != 0
-	case "no":
-		return c.TgID == 0
-	}
-	return true
-}
-
-func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
-	switch strings.ToLower(strings.TrimSpace(mode)) {
-	case "yes":
-		return strings.TrimSpace(c.Comment) != ""
-	case "no":
-		return strings.TrimSpace(c.Comment) == ""
-	}
-	return true
-}
-
-func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
-	groups := parseCSVStrings(csv)
-	if len(groups) == 0 {
-		return true
-	}
-	current := strings.TrimSpace(c.Group)
-	for _, g := range groups {
-		if g == "" {
-			if current == "" {
-				return true
-			}
-			continue
-		}
-		if strings.EqualFold(g, current) {
-			return true
-		}
-	}
-	return false
-}
-
-func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
-	if bucket == "" {
-		return true
-	}
-	used := int64(0)
-	if c.Traffic != nil {
-		used = c.Traffic.Up + c.Traffic.Down
-	}
-	exhausted := c.TotalGB > 0 && used >= c.TotalGB
-	expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
-	switch bucket {
-	case "online":
-		if onlineSet == nil {
-			return false
-		}
-		_, ok := onlineSet[c.Email]
-		return ok && c.Enable
-	case "depleted":
-		return exhausted || expired
-	case "deactive":
-		return !c.Enable
-	case "active":
-		return c.Enable && !exhausted && !expired
-	case "expiring":
-		if !c.Enable || exhausted || expired {
-			return false
-		}
-		nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
-		nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
-		return nearExpiry || nearLimit
-	}
-	return true
-}
-
-func sortClients(rows []ClientWithAttachments, sortKey, order string) {
-	if sortKey == "" {
-		return
-	}
-	desc := order == "descend"
-	less := func(i, j int) bool {
-		a, b := rows[i], rows[j]
-		switch sortKey {
-		case "enable":
-			if a.Enable == b.Enable {
-				return false
-			}
-			return !a.Enable && b.Enable
-		case "email":
-			return strings.ToLower(a.Email) < strings.ToLower(b.Email)
-		case "inboundIds":
-			return len(a.InboundIds) < len(b.InboundIds)
-		case "traffic":
-			ua := int64(0)
-			if a.Traffic != nil {
-				ua = a.Traffic.Up + a.Traffic.Down
-			}
-			ub := int64(0)
-			if b.Traffic != nil {
-				ub = b.Traffic.Up + b.Traffic.Down
-			}
-			return ua < ub
-		case "remaining":
-			ra := int64(1<<62 - 1)
-			if a.TotalGB > 0 {
-				used := int64(0)
-				if a.Traffic != nil {
-					used = a.Traffic.Up + a.Traffic.Down
-				}
-				ra = a.TotalGB - used
-			}
-			rb := int64(1<<62 - 1)
-			if b.TotalGB > 0 {
-				used := int64(0)
-				if b.Traffic != nil {
-					used = b.Traffic.Up + b.Traffic.Down
-				}
-				rb = b.TotalGB - used
-			}
-			return ra < rb
-		case "expiryTime":
-			ea := int64(1<<62 - 1)
-			if a.ExpiryTime > 0 {
-				ea = a.ExpiryTime
-			}
-			eb := int64(1<<62 - 1)
-			if b.ExpiryTime > 0 {
-				eb = b.ExpiryTime
-			}
-			return ea < eb
-		case "createdAt":
-			if a.CreatedAt == b.CreatedAt {
-				return a.Id < b.Id
-			}
-			return a.CreatedAt < b.CreatedAt
-		case "updatedAt":
-			if a.UpdatedAt == b.UpdatedAt {
-				return a.Id < b.Id
-			}
-			return a.UpdatedAt < b.UpdatedAt
-		case "lastOnline":
-			la := int64(0)
-			if a.Traffic != nil {
-				la = a.Traffic.LastOnline
-			}
-			lb := int64(0)
-			if b.Traffic != nil {
-				lb = b.Traffic.LastOnline
-			}
-			if la == lb {
-				return a.Id < b.Id
-			}
-			return la < lb
-		}
-		return false
-	}
-	sort.SliceStable(rows, func(i, j int) bool {
-		if desc {
-			return less(j, i)
-		}
-		return less(i, j)
-	})
-}

+ 624 - 0
internal/web/service/client_paging_test.go

@@ -0,0 +1,624 @@
+package service
+
+import (
+	"slices"
+	"strconv"
+	"testing"
+	"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/xray"
+)
+
+const (
+	pagingDay = int64(86400000)
+	pagingGB  = int64(1) << 30
+)
+
+type pagingSeed struct {
+	email      string
+	enable     bool
+	totalGB    int64
+	expiryTime int64
+	used       int64
+	subID      string
+	uuid       string
+	password   string
+	auth       string
+	comment    string
+	group      string
+	tgID       int64
+	reset      int
+	lastOnline int64
+	inbounds   []int
+}
+
+// seedPagingClients writes one vless and one trojan inbound plus a fixed client
+// set covering every bucket, sort key and search field ListPaged supports.
+// Returns "now" so the expectations can be phrased relative to it.
+func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
+	t.Helper()
+	db := database.GetDB()
+	now := time.Now().UnixMilli()
+
+	vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
+	for _, ib := range []*model.Inbound{vless, trojan} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	seeds := []pagingSeed{
+		{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
+		{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
+		{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
+		{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
+		{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
+		{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
+		{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
+		{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
+		{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
+		{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
+		{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
+		{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
+	}
+
+	for i, s := range seeds {
+		rec := model.ClientRecord{
+			Email:      s.email,
+			SubID:      s.subID,
+			UUID:       s.uuid,
+			Password:   s.password,
+			Auth:       s.auth,
+			Comment:    s.comment,
+			Group:      s.group,
+			TgID:       s.tgID,
+			Reset:      s.reset,
+			Enable:     s.enable,
+			TotalGB:    s.totalGB,
+			ExpiryTime: s.expiryTime,
+			CreatedAt:  now - int64(len(seeds)-i)*pagingDay,
+			UpdatedAt:  now - int64(i)*pagingDay,
+		}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client %s: %v", s.email, err)
+		}
+		if !s.enable {
+			// clients.enable carries a `default:true` tag, so GORM leaves the
+			// zero value out of the INSERT and the column comes back true.
+			// Restate updated_at so the autoUpdateTime hook cannot reshuffle
+			// the sort fixtures.
+			if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
+				Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
+				t.Fatalf("disable %s: %v", s.email, err)
+			}
+		}
+		traffic := xray.ClientTraffic{
+			Email:      s.email,
+			Enable:     s.enable,
+			Up:         s.used / 2,
+			Down:       s.used - s.used/2,
+			Total:      s.totalGB,
+			ExpiryTime: s.expiryTime,
+			LastOnline: s.lastOnline,
+		}
+		if err := db.Create(&traffic).Error; err != nil {
+			t.Fatalf("create traffic %s: %v", s.email, err)
+		}
+		for _, id := range s.inbounds {
+			if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
+				t.Fatalf("attach %s to %d: %v", s.email, id, err)
+			}
+		}
+	}
+	return now, seeds
+}
+
+func pagedEmails(items []ClientSlim) []string {
+	out := make([]string, 0, len(items))
+	for _, it := range items {
+		out = append(out, it.Email)
+	}
+	return out
+}
+
+func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
+	t.Helper()
+	setupBulkDB(t)
+	settingSvc := &SettingService{}
+	if err := settingSvc.setInt("expireDiff", 3); err != nil {
+		t.Fatalf("set expireDiff: %v", err)
+	}
+	if err := settingSvc.setInt("trafficDiff", 1); err != nil {
+		t.Fatalf("set trafficDiff: %v", err)
+	}
+	return &ClientService{}, &InboundService{}, settingSvc
+}
+
+func TestListPagedFilters(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	now, _ := seedPagingClients(t)
+
+	tests := []struct {
+		name   string
+		params ClientPageParams
+		want   []string
+	}{
+		{
+			name:   "no filter returns every client in id order",
+			params: ClientPageParams{PageSize: 50},
+			want:   []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "depleted bucket covers quota and expiry",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted"},
+			want:   []string{"charlie@x", "delta@x", "foxtrot@x"},
+		},
+		{
+			name:   "deactive bucket is every disabled client",
+			params: ClientPageParams{PageSize: 50, Filter: "deactive"},
+			want:   []string{"echo@x", "foxtrot@x"},
+		},
+		{
+			name:   "expiring bucket covers near expiry and near quota",
+			params: ClientPageParams{PageSize: 50, Filter: "expiring"},
+			want:   []string{"golf@x", "hotel@x"},
+		},
+		{
+			name:   "active bucket keeps enabled clients that still have room",
+			params: ClientPageParams{PageSize: 50, Filter: "active"},
+			want:   []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "buckets are ORed",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
+			want:   []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
+		},
+		{
+			name:   "unknown bucket keeps matching everything",
+			params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
+			want:   []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "protocol filter follows the attachments",
+			params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
+			want:   []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
+		},
+		{
+			name:   "inbound filter follows the attachments",
+			params: ClientPageParams{PageSize: 50, Inbound: "2"},
+			want:   []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
+		},
+		{
+			name:   "search matches the email",
+			params: ClientPageParams{PageSize: 50, Search: "KILO"},
+			want:   []string{"kilo_1@x", "kilo1@x"},
+		},
+		{
+			name:   "search treats LIKE wildcards literally",
+			params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
+			want:   []string{"kilo_1@x"},
+		},
+		{
+			name:   "search matches the subId",
+			params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
+			want:   []string{"alpha@x"},
+		},
+		{
+			name:   "search matches the uuid",
+			params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
+			want:   []string{"alpha@x"},
+		},
+		{
+			name:   "search matches the password",
+			params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
+			want:   []string{"bravo@x"},
+		},
+		{
+			name:   "search matches the auth",
+			params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
+			want:   []string{"charlie@x"},
+		},
+		{
+			name:   "search matches the comment",
+			params: ClientPageParams{PageSize: 50, Search: "vip customer"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "search matches the telegram id",
+			params: ClientPageParams{PageSize: 50, Search: "555"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "group filter is case insensitive",
+			params: ClientPageParams{PageSize: 50, Group: "vip"},
+			want:   []string{"juliet@x", "kilo_1@x"},
+		},
+		{
+			name:   "hasComment yes",
+			params: ClientPageParams{PageSize: 50, HasComment: "yes"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "hasTgId yes",
+			params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "autoRenew on",
+			params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
+			want:   []string{"juliet@x"},
+		},
+		{
+			name:   "usage range is inclusive on both bounds",
+			params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
+			want:   []string{"alpha@x", "bravo@x", "delta@x"},
+		},
+		{
+			name:   "expiry range excludes never and delayed start",
+			params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
+			want:   []string{"golf@x"},
+		},
+		{
+			name:   "filters combine with AND",
+			params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
+			want:   []string{"charlie@x", "foxtrot@x"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
+			if err != nil {
+				t.Fatalf("ListPaged: %v", err)
+			}
+			got := pagedEmails(resp.Items)
+			if !slices.Equal(got, tc.want) {
+				t.Fatalf("emails = %v, want %v", got, tc.want)
+			}
+			if resp.Filtered != len(tc.want) {
+				t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
+			}
+			if resp.Total != 12 {
+				t.Fatalf("total = %d, want 12", resp.Total)
+			}
+		})
+	}
+}
+
+func TestListPagedSorting(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	tests := []struct {
+		name  string
+		sort  string
+		order string
+		want  []string
+	}{
+		{
+			name: "no sort key keeps insertion order",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "email ascending", sort: "email", order: "ascend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "email descending", sort: "email", order: "descend",
+			want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
+		},
+		{
+			name: "traffic descending", sort: "traffic", order: "descend",
+			want: []string{"charlie@x", "hotel@x", "alpha@x"},
+		},
+		{
+			name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
+			want: []string{"alpha@x", "india@x", "juliet@x"},
+		},
+		{
+			name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
+			want: []string{"delta@x", "foxtrot@x", "golf@x"},
+		},
+		{
+			name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
+			want: []string{"alpha@x", "bravo@x", "charlie@x"},
+		},
+		{
+			name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
+			want: []string{"bravo@x", "alpha@x", "kilo1@x"},
+		},
+		{
+			name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
+			want: []string{"echo@x", "foxtrot@x", "alpha@x"},
+		},
+		{
+			name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
+			want: []string{"bravo@x", "alpha@x", "charlie@x"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
+			if err != nil {
+				t.Fatalf("ListPaged: %v", err)
+			}
+			got := pagedEmails(resp.Items)
+			if !slices.Equal(got, tc.want) {
+				t.Fatalf("emails = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestListPagedPagination(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	t.Run("second page continues where the first stopped", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
+		if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
+			t.Fatalf("emails = %v, want %v", got, want)
+		}
+		if resp.Page != 2 || resp.PageSize != 5 {
+			t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
+		}
+	})
+
+	t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		if len(resp.Items) != 0 {
+			t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
+		}
+		if resp.Filtered != 12 || resp.Total != 12 {
+			t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
+		}
+	})
+
+	t.Run("page size is clamped to the maximum", func(t *testing.T) {
+		resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
+		if err != nil {
+			t.Fatalf("ListPaged: %v", err)
+		}
+		if resp.PageSize != clientPageMaxSize {
+			t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
+		}
+	})
+}
+
+func TestListPagedRowContents(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	byEmail := make(map[string]ClientSlim, len(resp.Items))
+	for _, it := range resp.Items {
+		byEmail[it.Email] = it
+	}
+
+	t.Run("attachments are reported in inbound order", func(t *testing.T) {
+		got := byEmail["bravo@x"].InboundIds
+		if !slices.Equal(got, []int{1, 2}) {
+			t.Fatalf("inboundIds = %v, want [1 2]", got)
+		}
+	})
+
+	t.Run("an unattached client reports no inbounds", func(t *testing.T) {
+		if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
+			t.Fatalf("inboundIds = %v, want none", got)
+		}
+	})
+
+	t.Run("traffic counters ride along with the row", func(t *testing.T) {
+		got := byEmail["hotel@x"].Traffic
+		if got == nil {
+			t.Fatal("traffic = nil, want the seeded counters")
+		}
+		if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
+			t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
+		}
+	})
+
+	t.Run("groups list every name in use", func(t *testing.T) {
+		if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
+			t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
+		}
+	})
+}
+
+func TestListPagedSummary(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	s := resp.Summary
+
+	t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
+		if s.Total != 12 {
+			t.Fatalf("total = %d, want 12", s.Total)
+		}
+		if s.DepletedCount != 3 {
+			t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
+		}
+		if s.ExpiringCount != 2 {
+			t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
+		}
+		if s.DeactiveCount != 1 {
+			t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
+		}
+		if s.Active != 6 {
+			t.Fatalf("active = %d, want 6", s.Active)
+		}
+	})
+
+	t.Run("every client lands in exactly one counter", func(t *testing.T) {
+		if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
+			t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
+		}
+	})
+
+	t.Run("bucket lists carry the matching emails", func(t *testing.T) {
+		if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
+			t.Fatalf("depleted = %v, want %v", s.Depleted, want)
+		}
+		if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
+			t.Fatalf("expiring = %v, want %v", s.Expiring, want)
+		}
+		if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
+			t.Fatalf("deactive = %v, want %v", s.Deactive, want)
+		}
+	})
+}
+
+func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	db := database.GetDB()
+
+	const n = clientSummaryEmailCap + 25
+	past := time.Now().UnixMilli() - pagingDay
+	for i := range n {
+		rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client %d: %v", i, err)
+		}
+	}
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	if resp.Summary.DepletedCount != n {
+		t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
+	}
+	if len(resp.Summary.Depleted) != clientSummaryEmailCap {
+		t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
+	}
+}
+
+func TestListPagedGlobalTrafficOverlay(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+	seedPagingClients(t)
+
+	// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
+	// cross-panel usage has to move it into the depleted bucket.
+	if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
+		{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
+	}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
+	if err != nil {
+		t.Fatalf("ListPaged: %v", err)
+	}
+	want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
+	if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
+		t.Fatalf("depleted = %v, want %v", got, want)
+	}
+	if resp.Summary.DepletedCount != 4 {
+		t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
+	}
+	for _, it := range resp.Items {
+		if it.Email != "bravo@x" {
+			continue
+		}
+		if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
+			t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
+		}
+	}
+}
+
+func TestClientQueryOnlineEmails(t *testing.T) {
+	_, _, _ = setupPagingServices(t)
+	seedPagingClients(t)
+
+	q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
+	emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
+	if err != nil {
+		t.Fatalf("onlineEmails: %v", err)
+	}
+	if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
+		t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
+	}
+	if count != 2 {
+		t.Fatalf("count = %d, want 2", count)
+	}
+}
+
+func TestEmailInCondChunksLargeSets(t *testing.T) {
+	emails := make([]string, sqlInChunk+1)
+	for i := range emails {
+		emails[i] = "e" + strconv.Itoa(i)
+	}
+
+	cond, args := emailInCond("c.email", emails)
+	if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
+		t.Fatalf("cond = %q, want %q", cond, want)
+	}
+	if len(args) != 2 {
+		t.Fatalf("args = %d chunks, want 2", len(args))
+	}
+	if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
+		t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
+	}
+
+	emptyCond, emptyArgs := emailInCond("c.email", nil)
+	if emptyCond != "1 = 0" || emptyArgs != nil {
+		t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
+	}
+}
+
+func TestEscapeLikeLiteral(t *testing.T) {
+	tests := []struct {
+		in   string
+		want string
+	}{
+		{"plain", "plain"},
+		{"a_b", `a\_b`},
+		{"50%", `50\%`},
+		{`back\slash`, `back\\slash`},
+	}
+	for _, tc := range tests {
+		if got := escapeLikeLiteral(tc.in); got != tc.want {
+			t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestListPagedEmptyPanel(t *testing.T) {
+	svc, inboundSvc, settingSvc := setupPagingServices(t)
+
+	resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
+	if err != nil {
+		t.Fatalf("ListPaged on a panel with no clients: %v", err)
+	}
+	if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
+		t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
+	}
+	if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
+		t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
+	}
+	if resp.Groups == nil {
+		t.Fatal("groups = nil, want an empty list so the filter drawer renders")
+	}
+}

+ 2 - 3
internal/web/service/golden_fixtures_xray_test.go

@@ -8,6 +8,7 @@ import (
 	"crypto/x509/pkix"
 	"encoding/json"
 	"encoding/pem"
+	"maps"
 	"math/big"
 	"os"
 	"path/filepath"
@@ -256,9 +257,7 @@ func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
 				case "stream":
 					stream = fixture
 				case "security":
-					for key, value := range fixture {
-						stream[key] = value
-					}
+					maps.Copy(stream, fixture)
 				case "sockopt":
 					stream["sockopt"] = fixture
 				case "finalmask":

+ 2 - 3
internal/xray/api_users_e2e_test.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"maps"
 	"net"
 	"net/http"
 	"net/url"
@@ -138,9 +139,7 @@ func panelUser(email string, fields map[string]any) map[string]any {
 		"preSharedKey": "",
 		"keepAlive":    "",
 	}
-	for k, v := range fields {
-		user[k] = v
-	}
+	maps.Copy(user, fields)
 	return user
 }