11 Commits f9ae0347c6 ... 3827d7d061

Author SHA1 Message Date
  MHSanaei 3827d7d061 fix(clients): seed all clients when settings.clients has string tgId 23 hours ago
  MHSanaei d7f47d8b6a fix(xray): allow private-IP destinations via freedom finalRules 1 day ago
  Abdalrahman fd3770c8c9 fix: parse XHTTP extra fields from V2Ray links and v2rayN JSON imports (#4426) 1 day ago
  Константин 758e1ad050 Make HSTS policy configurable if https is enabled (#4462) 1 day ago
  Black 121b6e0bd0 feat(panel): copy connection strings for `mixed` inbound (#4450) 1 day ago
  MHSanaei bb5ea3af05 revert install.sh 1 day ago
  MHSanaei b36e5e0869 fix(security): redact at source and cap marshal sizes for CodeQL 1 day ago
  MHSanaei 788c979ad1 fix(client): guard against int overflow in ClientWithAttachments marshal 1 day ago
  MHSanaei 66f946ee54 fix(db): redact credentials in client-merge conflict logs 1 day ago
  MHSanaei 6000bc7134 fix(websocket): order register/unregister via single ops channel 1 day ago
  Sanaei 85e2ded0e1 Feat/multi inbound clients (#4469) 1 day ago
100 changed files with 10065 additions and 4921 deletions
  1. 5 19
      .vscode/launch.json
  2. 14 0
      .vscode/tasks.json
  3. 221 4
      CONTRIBUTING.md
  4. 4 0
      Dockerfile
  5. 32 0
      README.md
  6. 21 0
      config/config.go
  7. 202 42
      database/db.go
  8. 26 0
      database/dialect.go
  9. 143 0
      database/migrate_data.go
  10. 401 14
      database/model/model.go
  11. 188 1
      database/model/model_test.go
  12. 17 0
      docker-compose.yml
  13. 13 0
      frontend/clients.html
  14. 46 53
      frontend/package-lock.json
  15. 16 4
      frontend/src/api/axios-init.js
  16. 3 0
      frontend/src/components/AppSidebar.vue
  17. 21 0
      frontend/src/entries/clients.js
  18. 19 15
      frontend/src/models/dbinbound.js
  19. 30 18
      frontend/src/models/inbound.js
  20. 58 6
      frontend/src/models/outbound.js
  21. 1 1
      frontend/src/pages/api-docs/EndpointSection.vue
  22. 195 149
      frontend/src/pages/api-docs/endpoints.js
  23. 267 0
      frontend/src/pages/clients/ClientBulkAddModal.vue
  24. 402 0
      frontend/src/pages/clients/ClientFormModal.vue
  25. 411 0
      frontend/src/pages/clients/ClientInfoModal.vue
  26. 97 0
      frontend/src/pages/clients/ClientQrModal.vue
  27. 1067 0
      frontend/src/pages/clients/ClientsPage.vue
  28. 217 0
      frontend/src/pages/clients/useClients.js
  29. 0 280
      frontend/src/pages/inbounds/ClientBulkModal.vue
  30. 0 394
      frontend/src/pages/inbounds/ClientFormModal.vue
  31. 0 841
      frontend/src/pages/inbounds/ClientRowTable.vue
  32. 0 185
      frontend/src/pages/inbounds/CopyClientsModal.vue
  33. 577 353
      frontend/src/pages/inbounds/InboundFormModal.vue
  34. 121 29
      frontend/src/pages/inbounds/InboundInfoModal.vue
  35. 23 325
      frontend/src/pages/inbounds/InboundList.vue
  36. 55 250
      frontend/src/pages/inbounds/InboundsPage.vue
  37. 11 23
      frontend/src/pages/inbounds/useInbounds.js
  38. 34 0
      frontend/src/pages/nodes/NodeList.vue
  39. 12 2
      frontend/src/pages/nodes/useNodes.js
  40. 3 14
      frontend/src/pages/xray/BalancerFormModal.vue
  41. 12 10
      frontend/src/pages/xray/BalancersTab.vue
  42. 2 3
      frontend/src/pages/xray/BasicsTab.vue
  43. 43 3
      frontend/src/pages/xray/OutboundFormModal.vue
  44. 3 3
      frontend/src/pages/xray/RuleFormModal.vue
  45. 8 6
      frontend/src/utils/index.js
  46. 15 10
      frontend/vite.config.js
  47. 7 2
      go.mod
  48. 15 4
      go.sum
  49. 31 1
      main.go
  50. 1 0
      sub/links.go
  51. 40 0
      sub/links_test.go
  52. 3 3
      sub/sub.go
  53. 3 11
      sub/subClashService.go
  54. 15 3
      sub/subController.go
  55. 1 8
      sub/subJsonService.go
  56. 92 26
      sub/subService.go
  57. 480 0
      sub/subService_test.go
  58. 28 0
      util/common/format_test.go
  59. 44 0
      util/common/multi_error_test.go
  60. 69 0
      util/crypto/crypto_test.go
  61. 76 0
      util/json_util/json_test.go
  62. 2 7
      util/ldap/ldap.go
  63. 127 0
      util/netsafe/netsafe_test.go
  64. 12 0
      util/random/random.go
  65. 63 0
      util/random/random_test.go
  66. 11 18
      util/sys/sys_darwin.go
  67. 38 75
      util/sys/sys_linux.go
  68. 14 22
      util/sys/sys_windows.go
  69. 5 2
      web/controller/api.go
  70. 4 1
      web/controller/api_docs_test.go
  71. 311 0
      web/controller/client.go
  72. 46 322
      web/controller/inbound.go
  73. 1 1
      web/controller/node.go
  74. 44 136
      web/controller/server.go
  75. 2 2
      web/controller/util.go
  76. 5 0
      web/controller/xui.go
  77. 1 1
      web/entity/entity.go
  78. 25 0
      web/global/global.go
  79. 46 102
      web/job/ldap_sync_job.go
  80. 19 0
      web/job/node_traffic_sync_job.go
  81. 69 0
      web/job/node_traffic_sync_job_test.go
  82. 2 1
      web/job/periodic_traffic_reset_job.go
  83. 49 4
      web/runtime/local.go
  84. 46 19
      web/runtime/remote.go
  85. 3 3
      web/runtime/remote_test.go
  86. 7 1
      web/runtime/runtime.go
  87. 1963 0
      web/service/client.go
  88. 59 0
      web/service/client_test.go
  89. 4 1
      web/service/config.json
  90. 147 0
      web/service/fallback.go
  91. 260 646
      web/service/inbound.go
  92. 1 1
      web/service/metric_history.go
  93. 119 15
      web/service/node.go
  94. 162 0
      web/service/node_test.go
  95. 14 6
      web/service/panel.go
  96. 1 1
      web/service/port_conflict.go
  97. 5 4
      web/service/port_conflict_test.go
  98. 132 7
      web/service/server.go
  99. 284 407
      web/service/tgbot.go
  100. 1 1
      web/service/tgbot_test.go

+ 5 - 19
.vscode/launch.json

@@ -10,26 +10,12 @@
       "program": "${workspaceFolder}",
       "program": "${workspaceFolder}",
       "cwd": "${workspaceFolder}",
       "cwd": "${workspaceFolder}",
       "env": {
       "env": {
-        "XUI_DEBUG": "true"
-      },
-      "console": "integratedTerminal"
-    },
-    {
-      "name": "Run 3x-ui (Debug, custom env)",
-      "type": "go",
-      "request": "launch",
-      "mode": "auto",
-      "program": "${workspaceFolder}",
-      "cwd": "${workspaceFolder}",
-      "env": {
-        // Set to true to serve assets/templates directly from disk for development
         "XUI_DEBUG": "true",
         "XUI_DEBUG": "true",
-        // Uncomment to override DB folder location (by default uses working dir on Windows when debug)
-        // "XUI_DB_FOLDER": "${workspaceFolder}",
-        // Example: override log level (debug|info|notice|warn|error)
-        // "XUI_LOG_LEVEL": "debug"
+        "XUI_DB_FOLDER": "x-ui",
+        "XUI_LOG_FOLDER": "x-ui",
+        "XUI_BIN_FOLDER": "x-ui"
       },
       },
       "console": "integratedTerminal"
       "console": "integratedTerminal"
-    }
+    },
   ]
   ]
-}
+}

+ 14 - 0
.vscode/tasks.json

@@ -70,6 +70,20 @@
       "problemMatcher": [
       "problemMatcher": [
         "$go"
         "$go"
       ]
       ]
+    },
+    {
+      "label": "go: fmt",
+      "type": "shell",
+      "command": "gofmt",
+      "args": [
+        "-l",
+        "-w",
+        "."
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}"
+      },
+      "problemMatcher": []
     }
     }
   ]
   ]
 }
 }

+ 221 - 4
CONTRIBUTING.md

@@ -1,5 +1,222 @@
-## Local Development Setup
+# Contributing
 
 
-- Create a directory named `x-ui` in the project root 
-- Rename `.env.example` to `.env `
-- Run `main.go`
+Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running on your machine in a few minutes.
+
+## Prerequisites
+
+- **Go 1.26+** (the version in `go.mod`)
+- **Node.js 22+** and npm (for the Vue frontend)
+- **Git**
+- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux/macOS already ship one; on Windows see below.
+
+### Windows: MinGW-w64
+
+`go build` on Windows will fail with `cgo: C compiler "gcc" not found` until you install a GCC toolchain. Two options — pick whichever fits.
+
+**Option A — standalone zip (fastest, no package manager)**
+
+1. Grab the latest build from **<https://github.com/niXman/mingw-builds-binaries/releases>**. For most setups you want a release named like:
+   ```
+   x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
+   ```
+   (64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches the modern Windows defaults.)
+2. Extract it somewhere stable, e.g. `C:\mingw64\`.
+3. Add `C:\mingw64\bin` to your **Windows** `PATH` (System Properties → Environment Variables → Path → New).
+4. Open a fresh terminal and confirm:
+   ```powershell
+   gcc --version
+   ```
+
+**Option B — MSYS2 (if you also want a Unix-y shell)**
+
+1. Install MSYS2 from <https://www.msys2.org/>.
+2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
+   ```bash
+   pacman -Syu
+   ```
+3. Install the UCRT64 toolchain:
+   ```bash
+   pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
+   ```
+4. Add `C:\msys64\ucrt64\bin` to your Windows `PATH`.
+5. Verify with `gcc --version` in a fresh terminal.
+
+After either, `go build ./...` and `go run .` work normally.
+
+> Why MinGW-w64 over MSVC: `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain doesn't lock you into a Visual Studio install. If you already have Visual Studio Build Tools installed it works too — just make sure `CC=cl` is **not** set in your environment.
+
+The Linux SQLite cross-build from Windows (or vice versa) needs an extra cross-compiler — out of scope here; build natively on the target OS.
+
+## First-time setup
+
+```bash
+git clone https://github.com/MHSanaei/3x-ui.git
+cd 3x-ui
+
+cp .env.example .env
+
+mkdir x-ui
+
+go mod download
+
+cd frontend
+npm install
+npm run build
+cd ..
+```
+
+`.env.example` ships with sane defaults that point the database, logs, and xray binary at the local `x-ui/` folder so nothing escapes the project directory:
+
+```
+XUI_DEBUG=true
+XUI_DB_FOLDER=x-ui
+XUI_LOG_FOLDER=x-ui
+XUI_BIN_FOLDER=x-ui
+```
+
+You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` / `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows you also want `wintun.dll` if you plan to test TUN inbounds.
+
+## Running
+
+```bash
+go run .
+```
+
+Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. You will be prompted to change the credentials on first login.
+
+### Inside VS Code
+
+The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if it is missing):
+
+```jsonc
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "Run 3x-ui (Debug)",
+      "type": "go",
+      "request": "launch",
+      "mode": "auto",
+      "program": "${workspaceFolder}",
+      "cwd": "${workspaceFolder}",
+      "env": {
+        "XUI_DEBUG": "true",
+        "XUI_DB_FOLDER": "x-ui",
+        "XUI_LOG_FOLDER": "x-ui",
+        "XUI_BIN_FOLDER": "x-ui"
+      },
+      "console": "integratedTerminal"
+    }
+  ]
+}
+```
+
+## Working on the frontend
+
+The panel UI is a Vue 3 + Ant Design Vue 4 app under `frontend/`. A few things worth knowing before you dive in.
+
+### Architecture in one paragraph
+
+It's a **multi-page app**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/sub`, `/panel/api-docs`, plus `login`) has its own HTML entry under `frontend/*.html` and its own bootstrap in `src/entries/<page>.js`. Vite builds them into `web/dist/`, and the Go binary embeds that directory at compile time with `embed.FS`. Each navigation triggers a real document load — but each page's bundle is small, so it stays snappy. There's no Vue Router and no central store; Vuex/Pinia were rejected as overkill for the panel's surface area.
+
+### State and data flow
+
+- **No global store.** State lives where it's used. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is on the same box and responses are cheap.
+- **Composables** in `src/composables/` carry reactive logic worth sharing inside a page (theme switching, status polling, node lists). Reach for one before adding a new global.
+- **Domain classes** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. The Vue components stay dumb; they ask the model "what's my link?" and render the answer.
+- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, which is a thin Axios wrapper with CSRF, response toast handling, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts.
+
+### i18n
+
+Locale strings live in `web/translation/<locale>.json`, not under `frontend/`. The Go side embeds the same JSON and serves it to both backend templates and `vue-i18n`. When you add a new English key, add it to **every** non-English locale too — missing keys don't fail the build, they just render the raw key in the UI.
+
+### Two dev workflows
+
+| When you want… | Use |
+|----------------|-----|
+| To iterate on UI tweaks fast | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
+| To test what users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle either embedded (release mode) or from disk (debug mode). |
+
+The Vite dev proxy auto-rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, etc.) to the matching Vite-served HTML, so the navigation feels identical to prod without round-tripping through Go. The route allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — if you add a new page, register it there too.
+
+> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML out of the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names → blank page with 404s in the browser console. Always restart `go run .` after a frontend rebuild.
+
+### Adding a new page
+
+1. Create `frontend/<page>.html` (copy an existing one and adjust the title + the imported entry).
+2. Create `src/entries/<page>.js` — `createApp(Page).use(antd).use(i18n).mount('#app')`.
+3. Create the page component under `src/pages/<page>/<Page>.vue` (kebab-case folder, PascalCase component).
+4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
+5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
+6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in prod.
+
+### Conventions
+
+- **Ant Design Vue** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back as ugly + bloated. Small targeted UX tweaks beat sweeping rewrites; if a section *really* needs new visual language, raise it first.
+- **Composition API** (`<script setup>`) everywhere. Options API survives only in components nobody has touched yet.
+- **No `//` line comments** in committed JS/Vue. HTML `<!-- ... -->` is fine for template structure. Identifiers should carry the meaning; if you need a comment to explain *what* code does, rename the variable. Comments are for the *why* and only when surprising.
+- **Persian / Arabic users matter.** RTL is supported via `ConfigProvider` + `dir="rtl"`. When you write Persian text in toasts or labels, keep prose clean — isolate code/identifiers on their own lines so the RTL reading flows.
+- **Don't break links.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.vue` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever you touch URL generation, fallback projection, or TLS handling.
+
+### Project layout
+
+```
+frontend/
+├── *.html                — Vite entry HTML, one per panel route
+├── eslint.config.js      — ESLint 10 flat config (vue3-recommended)
+├── vite.config.js
+└── src/
+    ├── entries/          — per-page bootstrap (createApp + mount)
+    ├── pages/            — one folder per route (index, login, inbounds, clients, xray, settings, sub, api-docs)
+    ├── components/       — cross-page Vue components (DateTimePicker, FinalMaskForm, …)
+    ├── composables/      — reusable reactive logic (useTheme, useStatus, useNodeList, …)
+    ├── api/              — Axios setup + CSRF interceptor + WebSocket client
+    ├── i18n/             — vue-i18n bootstrap (the JSON lives in web/translation/)
+    ├── models/           — Inbound, DBInbound, Outbound, Status, reality-targets, …
+    └── utils/            — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
+```
+
+Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/README.md`](frontend/README.md).
+
+## Project layout
+
+| Path | What lives there |
+|------|------------------|
+| `main.go` | Process entry point, CLI subcommands, signal handling |
+| `web/` | Gin HTTP server, controllers, services, embedded frontend |
+| `frontend/` | Vue 3 + Ant Design source for the panel UI |
+| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
+| `xray/` | Xray-core process lifecycle + gRPC API client |
+| `sub/` | Subscription endpoints (raw, JSON, Clash) |
+| `config/` | Environment-var helpers, paths, defaults |
+| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
+
+## Sending a pull request
+
+1. Branch off `main` (e.g. `feat/short-description`).
+2. Keep the diff focused — separate refactors from feature work.
+3. Run the relevant builds before pushing:
+   - `go build ./...`
+   - `go test ./...` (if you touched Go code)
+   - `cd frontend && npm run build` (if you touched the Vue side)
+4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*.
+5. Open the PR against `main` with a brief description of what changed and how to test it.
+
+## Useful environment variables
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
+| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
+| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
+| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
+| `XUI_BIN_FOLDER` | `bin` | Where the xray binary + geo files + xray `config.json` live |
+| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
+| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
+
+## Issues and discussion
+
+- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
+- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
+
+Before filing a bug, please include your OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.

+ 4 - 0
Dockerfile

@@ -64,6 +64,10 @@ RUN chmod +x \
   /usr/bin/x-ui
   /usr/bin/x-ui
 
 
 ENV XUI_ENABLE_FAIL2BAN="true"
 ENV XUI_ENABLE_FAIL2BAN="true"
+# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
+# Default (unset) is SQLite stored under /etc/x-ui.
+ENV XUI_DB_TYPE=""
+ENV XUI_DB_DSN=""
 EXPOSE 2053
 EXPOSE 2053
 VOLUME [ "/etc/x-ui" ]
 VOLUME [ "/etc/x-ui" ]
 CMD [ "./x-ui" ]
 CMD [ "./x-ui" ]

+ 32 - 0
README.md

@@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 
 For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
 For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
 
 
+## Database Options
+
+3X-UI supports two backends, chosen during the install:
+
+- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
+- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
+
+At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### Migrating an existing SQLite install to PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
+systemctl restart x-ui
+```
+
+The source SQLite file is left untouched; remove it manually once you have verified the new backend.
+
+### Docker
+
+The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
+
+```bash
+docker compose --profile postgres up -d
+```
+
 ## A Special Thanks to
 ## A Special Thanks to
 
 
 - [alireza0](https://github.com/alireza0/)
 - [alireza0](https://github.com/alireza0/)

+ 21 - 0
config/config.go

@@ -57,6 +57,11 @@ func IsDebug() bool {
 	return os.Getenv("XUI_DEBUG") == "true"
 	return os.Getenv("XUI_DEBUG") == "true"
 }
 }
 
 
+// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
+func IsSkipHSTS() bool {
+	return os.Getenv("XUI_SKIP_HSTS") == "true"
+}
+
 // GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
 // GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
 func GetBinFolderPath() string {
 func GetBinFolderPath() string {
 	binFolderPath := os.Getenv("XUI_BIN_FOLDER")
 	binFolderPath := os.Getenv("XUI_BIN_FOLDER")
@@ -100,6 +105,22 @@ func GetDBPath() string {
 	return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
 	return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
 }
 }
 
 
+// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
+func GetDBKind() string {
+	v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
+	switch v {
+	case "postgres", "postgresql", "pg":
+		return "postgres"
+	default:
+		return "sqlite"
+	}
+}
+
+// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
+func GetDBDSN() string {
+	return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
+}
+
 // GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
 // GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
 func GetLogFolder() string {
 func GetLogFolder() string {
 	logFolderPath := os.Getenv("XUI_LOG_FOLDER")
 	logFolderPath := os.Getenv("XUI_LOG_FOLDER")

+ 202 - 42
database/db.go

@@ -1,15 +1,17 @@
 // Package database provides database initialization, migration, and management utilities
 // Package database provides database initialization, migration, and management utilities
-// for the 3x-ui panel using GORM with SQLite.
+// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
 package database
 package database
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"encoding/json"
 	"errors"
 	"errors"
 	"io"
 	"io"
 	"log"
 	"log"
 	"os"
 	"os"
 	"path"
 	"path"
 	"slices"
 	"slices"
+	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -18,6 +20,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 
 
+	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
 	"gorm.io/gorm/logger"
@@ -25,6 +28,28 @@ import (
 
 
 var db *gorm.DB
 var db *gorm.DB
 
 
+const (
+	DialectSQLite   = "sqlite"
+	DialectPostgres = "postgres"
+)
+
+// IsPostgres reports whether the active connection is a PostgreSQL backend.
+func IsPostgres() bool {
+	if db == nil {
+		return config.GetDBKind() == "postgres"
+	}
+	return db.Dialector.Name() == "postgres"
+}
+
+// Dialect returns the active GORM dialect name, or "" if the DB is not open.
+func Dialect() string {
+	if db == nil {
+		return ""
+	}
+	return db.Dialector.Name()
+}
+
+
 const (
 const (
 	defaultUsername = "admin"
 	defaultUsername = "admin"
 	defaultPassword = "admin"
 	defaultPassword = "admin"
@@ -42,6 +67,9 @@ func initModels() error {
 		&model.CustomGeoResource{},
 		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.Node{},
 		&model.ApiToken{},
 		&model.ApiToken{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&model.InboundFallback{},
 	}
 	}
 	for _, mdl := range models {
 	for _, mdl := range models {
 		if err := db.AutoMigrate(mdl); err != nil {
 		if err := db.AutoMigrate(mdl); err != nil {
@@ -61,20 +89,25 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
 		return false
 		return false
 	}
 	}
 	errMsg := strings.ToLower(err.Error())
 	errMsg := strings.ToLower(err.Error())
-	const dupPrefix = "duplicate column name:"
-	if !strings.Contains(errMsg, dupPrefix) {
-		return false
-	}
-	idx := strings.Index(errMsg, dupPrefix)
-	if idx < 0 {
-		return false
-	}
-	col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
-	col = strings.Trim(col, "`\"[]")
-	if col == "" {
-		return false
+	// SQLite: "duplicate column name: foo"
+	// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
+	const sqlitePrefix = "duplicate column name:"
+	if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
+		col := strings.TrimSpace(after)
+		col = strings.Trim(col, "`\"[]")
+		return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
+	}
+	if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
+		// Best effort: extract the column name between the first pair of double quotes.
+		if _, after, ok := strings.Cut(errMsg, "column \""); ok {
+			rest := after
+			if e := strings.Index(rest, "\""); e > 0 {
+				col := rest[:e]
+				return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
+			}
+		}
 	}
 	}
-	return db != nil && db.Migrator().HasColumn(mdl, col)
+	return false
 }
 }
 
 
 // initUser creates a default admin user if the users table is empty.
 // initUser creates a default admin user if the users table is empty.
@@ -157,9 +190,124 @@ func runSeeders(isUsersEmpty bool) error {
 			return err
 			return err
 		}
 		}
 	}
 	}
+
+	if !slices.Contains(seedersHistory, "ClientsTable") {
+		if err := seedClientsFromInboundJSON(); err != nil {
+			return err
+		}
+	}
 	return nil
 	return nil
 }
 }
 
 
+// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
+// settings.clients entry so json.Unmarshal into model.Client doesn't fail
+// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
+// drop the key so the field falls back to its zero value.
+func normalizeClientJSONFields(obj map[string]any) {
+	normalizeInt := func(key string) {
+		raw, exists := obj[key]
+		if !exists {
+			return
+		}
+		s, ok := raw.(string)
+		if !ok {
+			return
+		}
+		trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
+		if trimmed == "" {
+			delete(obj, key)
+			return
+		}
+		if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
+			obj[key] = n
+		} else {
+			delete(obj, key)
+		}
+	}
+	for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
+		normalizeInt(k)
+	}
+}
+
+func seedClientsFromInboundJSON() error {
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		byEmail := map[string]*model.ClientRecord{}
+
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.Settings) == "" {
+				continue
+			}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
+				continue
+			}
+			rawList, ok := settings["clients"].([]any)
+			if !ok {
+				continue
+			}
+
+			for _, raw := range rawList {
+				obj, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				normalizeClientJSONFields(obj)
+				blob, err := json.Marshal(obj)
+				if err != nil {
+					continue
+				}
+				var c model.Client
+				if err := json.Unmarshal(blob, &c); err != nil {
+					log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
+						inbound.Id, err, string(blob))
+					continue
+				}
+				email := strings.TrimSpace(c.Email)
+				if email == "" {
+					continue
+				}
+				incoming := c.ToRecord()
+
+				row, dup := byEmail[email]
+				if !dup {
+					if err := tx.Create(incoming).Error; err != nil {
+						return err
+					}
+					byEmail[email] = incoming
+					row = incoming
+				} else {
+					conflicts := model.MergeClientRecord(row, incoming)
+					for _, x := range conflicts {
+						log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
+							email, x.Field, x.Old, x.New, x.Kept)
+					}
+					if err := tx.Save(row).Error; err != nil {
+						return err
+					}
+				}
+
+				link := model.ClientInbound{
+					ClientId:     row.Id,
+					InboundId:    inbound.Id,
+					FlowOverride: c.Flow,
+				}
+				if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
+					FirstOrCreate(&link).Error; err != nil {
+					return err
+				}
+			}
+		}
+
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
+	})
+}
+
 // seedApiTokens copies the legacy `apiToken` setting into the new
 // seedApiTokens copies the legacy `apiToken` setting into the new
 // api_tokens table as a row named "default" so existing central panels
 // api_tokens table as a row named "default" so existing central panels
 // keep working after the upgrade. Idempotent — records itself in
 // keep working after the upgrade. Idempotent — records itself in
@@ -195,43 +343,56 @@ func isTableEmpty(tableName string) (bool, error) {
 }
 }
 
 
 // InitDB sets up the database connection, migrates models, and runs seeders.
 // InitDB sets up the database connection, migrates models, and runs seeders.
+// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
 func InitDB(dbPath string) error {
 func InitDB(dbPath string) error {
-	dir := path.Dir(dbPath)
-	err := os.MkdirAll(dir, 0755)
-	if err != nil {
-		return err
-	}
-
 	var gormLogger logger.Interface
 	var gormLogger logger.Interface
-
 	if config.IsDebug() {
 	if config.IsDebug() {
 		gormLogger = logger.Default
 		gormLogger = logger.Default
 	} else {
 	} else {
 		gormLogger = logger.Discard
 		gormLogger = logger.Discard
 	}
 	}
+	c := &gorm.Config{Logger: gormLogger}
 
 
-	c := &gorm.Config{
-		Logger: gormLogger,
-	}
-	dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
-	db, err = gorm.Open(sqlite.Open(dsn), c)
-	if err != nil {
-		return err
+	var err error
+	switch config.GetDBKind() {
+	case "postgres":
+		dsn := config.GetDBDSN()
+		if dsn == "" {
+			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
+		}
+		db, err = gorm.Open(postgres.Open(dsn), c)
+		if err != nil {
+			return err
+		}
+	default:
+		dir := path.Dir(dbPath)
+		if err = os.MkdirAll(dir, 0755); err != nil {
+			return err
+		}
+		dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
+		db, err = gorm.Open(sqlite.Open(dsn), c)
+		if err != nil {
+			return err
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
+			return err
+		}
 	}
 	}
 
 
 	sqlDB, err := db.DB()
 	sqlDB, err := db.DB()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
-		return err
-	}
-	if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
-		return err
-	}
-	if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
-		return err
-	}
 	sqlDB.SetMaxOpenConns(8)
 	sqlDB.SetMaxOpenConns(8)
 	sqlDB.SetMaxIdleConns(4)
 	sqlDB.SetMaxIdleConns(4)
 	sqlDB.SetConnMaxLifetime(time.Hour)
 	sqlDB.SetConnMaxLifetime(time.Hour)
@@ -284,13 +445,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
 }
 }
 
 
 // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
 // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
+// No-op on PostgreSQL (WAL there is managed by the server).
 func Checkpoint() error {
 func Checkpoint() error {
-	// Update WAL
-	err := db.Exec("PRAGMA wal_checkpoint;").Error
-	if err != nil {
-		return err
+	if IsPostgres() {
+		return nil
 	}
 	}
-	return nil
+	return db.Exec("PRAGMA wal_checkpoint;").Error
 }
 }
 
 
 // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
 // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection

+ 26 - 0
database/dialect.go

@@ -0,0 +1,26 @@
+package database
+
+import "fmt"
+
+// JSONClientsFromInbound returns the FROM clause that yields one row per element
+// of inbounds.settings -> clients, with a column named `client.value` whose text
+// fields can be read with JSONFieldText("client.value", "<key>").
+func JSONClientsFromInbound() string {
+	if IsPostgres() {
+		return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
+	}
+	return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
+}
+
+// JSONFieldText returns a SQL expression that extracts the textual value of <key>
+// from a JSON expression. On both backends the result is the raw (unquoted) string,
+// so callers do NOT need to trim surrounding quotes.
+func JSONFieldText(expr, key string) string {
+	if IsPostgres() {
+		return fmt.Sprintf("(%s ->> '%s')", expr, key)
+	}
+	// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
+	// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
+	// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
+	return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
+}

+ 143 - 0
database/migrate_data.go

@@ -0,0 +1,143 @@
+package database
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path"
+	"reflect"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// migrationModels is the FK-aware order in which tables are created and copied.
+// Parents come before their children so foreign-key constraints stay satisfied
+// even when checks are not explicitly disabled.
+func migrationModels() []any {
+	return []any{
+		&model.User{},
+		&model.Setting{},
+		&model.HistoryOfSeeders{},
+		&model.CustomGeoResource{},
+		&model.Node{},
+		&model.ApiToken{},
+		&model.Inbound{},
+		&xray.ClientTraffic{},
+		&model.OutboundTraffics{},
+		&model.InboundClientIps{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&model.InboundFallback{},
+	}
+}
+
+// MigrateData copies every row from the configured SQLite file at srcPath into
+// a fresh PostgreSQL database described by dstDSN. The destination tables are
+// (re)created with AutoMigrate before the copy. Source data is left untouched.
+func MigrateData(srcPath, dstDSN string) error {
+	if _, err := os.Stat(srcPath); err != nil {
+		return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
+	}
+	if dstDSN == "" {
+		return errors.New("destination DSN is required")
+	}
+
+	if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
+		return err
+	}
+
+	srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
+	src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open sqlite source: %w", err)
+	}
+	srcSQL, err := src.DB()
+	if err != nil {
+		return err
+	}
+	defer srcSQL.Close()
+
+	dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open postgres destination: %w", err)
+	}
+	dstSQL, err := dst.DB()
+	if err != nil {
+		return err
+	}
+	defer dstSQL.Close()
+	dstSQL.SetConnMaxLifetime(time.Hour)
+
+	log.Println("Creating destination schema...")
+	for _, m := range migrationModels() {
+		if err := dst.AutoMigrate(m); err != nil {
+			return fmt.Errorf("AutoMigrate %T: %w", m, err)
+		}
+	}
+
+	totalRows := 0
+	for _, m := range migrationModels() {
+		n, err := copyTable(src, dst, m)
+		if err != nil {
+			return fmt.Errorf("copy %T: %w", m, err)
+		}
+		totalRows += n
+		log.Printf("  %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
+	}
+
+	if err := resetPostgresSequences(dst); err != nil {
+		log.Printf("warning: failed to reset some postgres sequences: %v", err)
+	}
+
+	log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
+	log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
+	return nil
+}
+
+// copyTable streams every row of `mdl` from src to dst in batches.
+func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
+	sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
+	batchPtr := reflect.New(sliceType)
+	batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
+
+	total := 0
+	err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
+		batch := batchPtr.Elem()
+		if batch.Len() == 0 {
+			return nil
+		}
+		if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
+			return err
+		}
+		total += batch.Len()
+		return nil
+	}).Error
+	return total, err
+}
+
+// resetPostgresSequences advances each table's id sequence past MAX(id),
+// otherwise the next INSERT-without-id would clash with copied rows.
+func resetPostgresSequences(dst *gorm.DB) error {
+	tables := []string{
+		"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
+		"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
+		"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
+	}
+	for _, t := range tables {
+		// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
+		_ = dst.Exec(fmt.Sprintf(
+			`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
+			 WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
+			t, t, t,
+		)).Error
+	}
+	return nil
+}

+ 401 - 14
database/model/model.go

@@ -2,7 +2,10 @@
 package model
 package model
 
 
 import (
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
 	"fmt"
+	"strings"
 
 
 	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 	"github.com/mhsanaei/3x-ui/v3/xray"
@@ -13,16 +16,16 @@ type Protocol string
 
 
 // Protocol constants for different Xray inbound protocols
 // Protocol constants for different Xray inbound protocols
 const (
 const (
-	VMESS       Protocol = "vmess"
-	VLESS       Protocol = "vless"
-	Tunnel      Protocol = "tunnel"
-	HTTP        Protocol = "http"
-	Trojan      Protocol = "trojan"
-	Shadowsocks Protocol = "shadowsocks"
-	Mixed       Protocol = "mixed"
-	WireGuard   Protocol = "wireguard"
-	Hysteria    Protocol = "hysteria"
-	Hysteria2   Protocol = "hysteria2"
+	VMESS        Protocol = "vmess"
+	VLESS        Protocol = "vless"
+	Tunnel       Protocol = "tunnel"
+	HTTP         Protocol = "http"
+	Trojan       Protocol = "trojan"
+	Shadowsocks  Protocol = "shadowsocks"
+	Mixed        Protocol = "mixed"
+	WireGuard    Protocol = "wireguard"
+	Hysteria     Protocol = "hysteria"
+	Hysteria2    Protocol = "hysteria2"
 )
 )
 
 
 // IsHysteria returns true for both "hysteria" and "hysteria2".
 // IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -47,7 +50,6 @@ type Inbound struct {
 	Up                   int64                `json:"up" form:"up"`                                                                                    // Upload traffic in bytes
 	Up                   int64                `json:"up" form:"up"`                                                                                    // Upload traffic in bytes
 	Down                 int64                `json:"down" form:"down"`                                                                                // Download traffic in bytes
 	Down                 int64                `json:"down" form:"down"`                                                                                // Download traffic in bytes
 	Total                int64                `json:"total" form:"total"`                                                                              // Total traffic limit in bytes
 	Total                int64                `json:"total" form:"total"`                                                                              // Total traffic limit in bytes
-	AllTime              int64                `json:"allTime" form:"allTime" gorm:"default:0"`                                                         // All-time traffic usage
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
@@ -64,6 +66,23 @@ type Inbound struct {
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
+
+	// FallbackParent is populated by the API layer when this inbound is
+	// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
+	// The frontend uses it to rewrite client-share links so they advertise
+	// the master's externally reachable endpoint instead of the child's
+	// loopback listen. Not persisted.
+	FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
+}
+
+// FallbackParentInfo carries everything the frontend needs to rewrite a
+// child inbound's client link: where to connect (the master's address
+// and port) and which path matched on the master's fallbacks array.
+// The frontend already has the master inbound in its dbInbounds list,
+// so we only ship identifiers + the match path here.
+type FallbackParentInfo struct {
+	MasterId int    `json:"masterId"`
+	Path     string `json:"path,omitempty"`
 }
 }
 
 
 // OutboundTraffics tracks traffic statistics for Xray outbound connections.
 // OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -82,6 +101,38 @@ type InboundClientIps struct {
 	Ips         string `json:"ips" form:"ips"`
 	Ips         string `json:"ips" form:"ips"`
 }
 }
 
 
+// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
+// JSON-text string. Empty or unparseable storage renders as null so API
+// consumers don't have to special-case the legacy double-encoded shape.
+func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
+	type alias InboundClientIps
+	return json.Marshal(struct {
+		alias
+		Ips json.RawMessage `json:"ips"`
+	}{
+		alias: alias(ic),
+		Ips:   jsonStringFieldToRaw(ic.Ips),
+	})
+}
+
+// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
+// JSON-encoded string (legacy shape), normalising back to the JSON-text the
+// column stores.
+func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
+	type alias InboundClientIps
+	aux := struct {
+		*alias
+		Ips json.RawMessage `json:"ips"`
+	}{
+		alias: (*alias)(ic),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	ic.Ips = jsonStringFieldFromRaw(aux.Ips)
+	return nil
+}
+
 // HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
 // HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
 type HistoryOfSeeders struct {
 type HistoryOfSeeders struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -96,19 +147,86 @@ type ApiToken struct {
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
 }
 }
 
 
+// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
+// objects rather than escaped strings, so API consumers don't need to JSON.parse
+// a string inside a string. Empty fields render as null; fields whose stored
+// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
+func (i Inbound) MarshalJSON() ([]byte, error) {
+	type alias Inbound
+	return json.Marshal(struct {
+		alias
+		Settings       json.RawMessage `json:"settings"`
+		StreamSettings json.RawMessage `json:"streamSettings"`
+		Sniffing       json.RawMessage `json:"sniffing"`
+	}{
+		alias:          alias(i),
+		Settings:       jsonStringFieldToRaw(i.Settings),
+		StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
+		Sniffing:       jsonStringFieldToRaw(i.Sniffing),
+	})
+}
+
+// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
+// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
+// string (the legacy shape). Either form is normalised back to the JSON-text
+// string the DB column stores.
+func (i *Inbound) UnmarshalJSON(data []byte) error {
+	type alias Inbound
+	aux := struct {
+		*alias
+		Settings       json.RawMessage `json:"settings"`
+		StreamSettings json.RawMessage `json:"streamSettings"`
+		Sniffing       json.RawMessage `json:"sniffing"`
+	}{
+		alias: (*alias)(i),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	i.Settings = jsonStringFieldFromRaw(aux.Settings)
+	i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
+	i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
+	return nil
+}
+
+func jsonStringFieldToRaw(s string) json.RawMessage {
+	trimmed := strings.TrimSpace(s)
+	if trimmed == "" {
+		return json.RawMessage("null")
+	}
+	if json.Valid([]byte(trimmed)) {
+		return json.RawMessage(trimmed)
+	}
+	b, _ := json.Marshal(s)
+	return b
+}
+
+func jsonStringFieldFromRaw(r json.RawMessage) string {
+	trimmed := bytes.TrimSpace(r)
+	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+		return ""
+	}
+	if trimmed[0] == '"' {
+		var s string
+		if err := json.Unmarshal(trimmed, &s); err == nil {
+			return s
+		}
+	}
+	return string(trimmed)
+}
+
 // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
 // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen := i.Listen
 	listen := i.Listen
-	// Default to 0.0.0.0 (all interfaces) when listen is empty
-	// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
 	if listen == "" {
 	if listen == "" {
 		listen = "0.0.0.0"
 		listen = "0.0.0.0"
 	}
 	}
 	listen = fmt.Sprintf("\"%v\"", listen)
 	listen = fmt.Sprintf("\"%v\"", listen)
+	protocol := string(i.Protocol)
 	return &xray.InboundConfig{
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,
 		Port:           i.Port,
-		Protocol:       string(i.Protocol),
+		Protocol:       protocol,
 		Settings:       json_util.RawMessage(i.Settings),
 		Settings:       json_util.RawMessage(i.Settings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		Tag:            i.Tag,
 		Tag:            i.Tag,
@@ -146,11 +264,17 @@ type Node struct {
 	LastHeartbeat int64   `json:"lastHeartbeat"`                 // unix seconds, 0 = never
 	LastHeartbeat int64   `json:"lastHeartbeat"`                 // unix seconds, 0 = never
 	LatencyMs     int     `json:"latencyMs"`
 	LatencyMs     int     `json:"latencyMs"`
 	XrayVersion   string  `json:"xrayVersion"`
 	XrayVersion   string  `json:"xrayVersion"`
+	PanelVersion  string  `json:"panelVersion" gorm:"column:panel_version"`
 	CpuPct        float64 `json:"cpuPct"`
 	CpuPct        float64 `json:"cpuPct"`
 	MemPct        float64 `json:"memPct"`
 	MemPct        float64 `json:"memPct"`
 	UptimeSecs    uint64  `json:"uptimeSecs"`
 	UptimeSecs    uint64  `json:"uptimeSecs"`
 	LastError     string  `json:"lastError"`
 	LastError     string  `json:"lastError"`
 
 
+	InboundCount  int `json:"inboundCount" gorm:"-"`
+	ClientCount   int `json:"clientCount" gorm:"-"`
+	OnlineCount   int `json:"onlineCount" gorm:"-"`
+	DepletedCount int `json:"depletedCount" gorm:"-"`
+
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
 }
 }
@@ -191,3 +315,266 @@ type Client struct {
 	CreatedAt  int64          `json:"created_at,omitempty"`         // Creation timestamp
 	CreatedAt  int64          `json:"created_at,omitempty"`         // Creation timestamp
 	UpdatedAt  int64          `json:"updated_at,omitempty"`         // Last update timestamp
 	UpdatedAt  int64          `json:"updated_at,omitempty"`         // Last update timestamp
 }
 }
+
+type ClientRecord struct {
+	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Email      string `json:"email" gorm:"uniqueIndex;not null"`
+	SubID      string `json:"subId" gorm:"index;column:sub_id"`
+	UUID       string `json:"uuid" gorm:"column:uuid"`
+	Password   string `json:"password"`
+	Auth       string `json:"auth"`
+	Flow       string `json:"flow"`
+	Security   string `json:"security"`
+	Reverse    string `json:"reverse" gorm:"column:reverse"`
+	LimitIP    int    `json:"limitIp" gorm:"column:limit_ip"`
+	TotalGB    int64  `json:"totalGB" gorm:"column:total_gb"`
+	ExpiryTime int64  `json:"expiryTime" gorm:"column:expiry_time"`
+	Enable     bool   `json:"enable" gorm:"default:true"`
+	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
+	Comment    string `json:"comment"`
+	Reset      int    `json:"reset" gorm:"default:0"`
+	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime"`
+	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime"`
+}
+
+func (ClientRecord) TableName() string { return "clients" }
+
+// MarshalJSON emits the reverse column as a nested JSON object rather than an
+// escaped JSON-text string, matching the same convention Inbound uses for its
+// JSON-text columns. Empty storage renders as null.
+func (r ClientRecord) MarshalJSON() ([]byte, error) {
+	type alias ClientRecord
+	return json.Marshal(struct {
+		alias
+		Reverse json.RawMessage `json:"reverse"`
+	}{
+		alias:   alias(r),
+		Reverse: jsonStringFieldToRaw(r.Reverse),
+	})
+}
+
+// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
+// JSON-encoded string (legacy shape).
+func (r *ClientRecord) UnmarshalJSON(data []byte) error {
+	type alias ClientRecord
+	aux := struct {
+		*alias
+		Reverse json.RawMessage `json:"reverse"`
+	}{
+		alias: (*alias)(r),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
+	return nil
+}
+
+type ClientInbound struct {
+	ClientId     int    `json:"clientId" gorm:"primaryKey;column:client_id;index"`
+	InboundId    int    `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
+	FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
+	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime"`
+}
+
+func (ClientInbound) TableName() string { return "client_inbounds" }
+
+// InboundFallback is one routing rule on a master inbound's
+// settings.fallbacks array. The master is always a VLESS or Trojan
+// inbound on TCP transport with TLS or Reality. The child is any other
+// inbound — its listen+port becomes the fallback dest, with optional
+// SNI/ALPN/path match criteria pulled from the same row.
+type InboundFallback struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	MasterId  int    `json:"masterId" gorm:"index;not null;column:master_id"`
+	ChildId   int    `json:"childId" gorm:"index;not null;column:child_id"`
+	Name      string `json:"name"`
+	Alpn      string `json:"alpn"`
+	Path      string `json:"path"`
+	Xver      int    `json:"xver"`
+	SortOrder int    `json:"sortOrder" gorm:"default:0;column:sort_order"`
+}
+
+func (InboundFallback) TableName() string { return "inbound_fallbacks" }
+
+func (c *Client) ToRecord() *ClientRecord {
+	rec := &ClientRecord{
+		Email:      c.Email,
+		SubID:      c.SubID,
+		UUID:       c.ID,
+		Password:   c.Password,
+		Auth:       c.Auth,
+		Flow:       c.Flow,
+		Security:   c.Security,
+		LimitIP:    c.LimitIP,
+		TotalGB:    c.TotalGB,
+		ExpiryTime: c.ExpiryTime,
+		Enable:     c.Enable,
+		TgID:       c.TgID,
+		Comment:    c.Comment,
+		Reset:      c.Reset,
+		CreatedAt:  c.CreatedAt,
+		UpdatedAt:  c.UpdatedAt,
+	}
+	if c.Reverse != nil {
+		if b, err := json.Marshal(c.Reverse); err == nil {
+			rec.Reverse = string(b)
+		}
+	}
+	return rec
+}
+
+func (r *ClientRecord) ToClient() *Client {
+	c := &Client{
+		ID:         r.UUID,
+		Email:      r.Email,
+		SubID:      r.SubID,
+		Password:   r.Password,
+		Auth:       r.Auth,
+		Flow:       r.Flow,
+		Security:   r.Security,
+		LimitIP:    r.LimitIP,
+		TotalGB:    r.TotalGB,
+		ExpiryTime: r.ExpiryTime,
+		Enable:     r.Enable,
+		TgID:       r.TgID,
+		Comment:    r.Comment,
+		Reset:      r.Reset,
+		CreatedAt:  r.CreatedAt,
+		UpdatedAt:  r.UpdatedAt,
+	}
+	if r.Reverse != "" {
+		var rev ClientReverse
+		if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
+			c.Reverse = &rev
+		}
+	}
+	return c
+}
+
+type ClientMergeConflict struct {
+	Field string
+	Old   any
+	New   any
+	Kept  any
+}
+
+func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
+	var conflicts []ClientMergeConflict
+	keep := func(field string, oldV, newV, kept any) {
+		conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
+	}
+	const redacted = "<redacted>"
+	keepSecret := func(field string) {
+		conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
+	}
+
+	incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
+		(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
+
+	if existing.UUID != incoming.UUID && incoming.UUID != "" {
+		if incomingNewer || existing.UUID == "" {
+			existing.UUID = incoming.UUID
+		}
+		keepSecret("uuid")
+	}
+	if existing.Password != incoming.Password && incoming.Password != "" {
+		if incomingNewer || existing.Password == "" {
+			existing.Password = incoming.Password
+			keepSecret("password")
+		}
+	}
+	if existing.Auth != incoming.Auth && incoming.Auth != "" {
+		if incomingNewer || existing.Auth == "" {
+			existing.Auth = incoming.Auth
+			keepSecret("auth")
+		}
+	}
+	if existing.Flow != incoming.Flow && incoming.Flow != "" {
+		if incomingNewer || existing.Flow == "" {
+			keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
+			existing.Flow = incoming.Flow
+		}
+	}
+	if existing.Security != incoming.Security && incoming.Security != "" {
+		if incomingNewer || existing.Security == "" {
+			keep("security", existing.Security, incoming.Security, incoming.Security)
+			existing.Security = incoming.Security
+		}
+	}
+	if existing.SubID != incoming.SubID && incoming.SubID != "" {
+		if incomingNewer || existing.SubID == "" {
+			existing.SubID = incoming.SubID
+			keepSecret("subId")
+		}
+	}
+	if existing.TotalGB != incoming.TotalGB {
+		picked := existing.TotalGB
+		if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
+			picked = incoming.TotalGB
+		}
+		if picked != existing.TotalGB {
+			keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
+			existing.TotalGB = picked
+		}
+	}
+	if existing.ExpiryTime != incoming.ExpiryTime {
+		picked := existing.ExpiryTime
+		if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
+			picked = incoming.ExpiryTime
+		}
+		if picked != existing.ExpiryTime {
+			keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
+			existing.ExpiryTime = picked
+		}
+	}
+	if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
+		picked := existing.LimitIP
+		if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
+			picked = incoming.LimitIP
+		}
+		if picked != existing.LimitIP {
+			keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
+			existing.LimitIP = picked
+		}
+	}
+	if existing.TgID != incoming.TgID && incoming.TgID != 0 {
+		if incomingNewer || existing.TgID == 0 {
+			keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
+			existing.TgID = incoming.TgID
+		}
+	}
+	if existing.Reset != incoming.Reset && incoming.Reset != 0 {
+		if incomingNewer || existing.Reset == 0 {
+			keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
+			existing.Reset = incoming.Reset
+		}
+	}
+	if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
+		if incomingNewer || existing.Reverse == "" {
+			keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
+			existing.Reverse = incoming.Reverse
+		}
+	}
+	if existing.Comment != incoming.Comment && incoming.Comment != "" {
+		if incomingNewer || existing.Comment == "" {
+			keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
+			existing.Comment = incoming.Comment
+		}
+	}
+	if existing.Enable != incoming.Enable {
+		if incoming.Enable {
+			if !existing.Enable {
+				keep("enable", existing.Enable, incoming.Enable, true)
+				existing.Enable = true
+			}
+		}
+	}
+	if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
+		existing.CreatedAt = incoming.CreatedAt
+	}
+	if incoming.UpdatedAt > existing.UpdatedAt {
+		existing.UpdatedAt = incoming.UpdatedAt
+	}
+	return conflicts
+}

+ 188 - 1
database/model/model_test.go

@@ -1,6 +1,193 @@
 package model
 package model
 
 
-import "testing"
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
+	in := Inbound{
+		Id:             7,
+		Protocol:       VLESS,
+		Port:           443,
+		Settings:       `{"clients":[],"decryption":"none"}`,
+		StreamSettings: `{"network":"tcp"}`,
+		Sniffing:       `{"enabled":true}`,
+	}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	for _, field := range []string{"settings", "streamSettings", "sniffing"} {
+		if _, ok := parsed[field].(map[string]any); !ok {
+			t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
+		}
+	}
+	if strings.Contains(string(out), `"settings":"`) {
+		t.Errorf("settings should not be emitted as a JSON string: %s", out)
+	}
+}
+
+func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
+	in := Inbound{Id: 1, Protocol: VLESS}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	for _, field := range []string{"settings", "streamSettings", "sniffing"} {
+		if parsed[field] != nil {
+			t.Errorf("expected %s to be null, got %v", field, parsed[field])
+		}
+	}
+}
+
+func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{
+			name: "nested objects (modern)",
+			body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
+		},
+		{
+			name: "JSON-encoded strings (legacy)",
+			body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var in Inbound
+			if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(in.Settings, `"decryption":"none"`) {
+				t.Errorf("Settings not normalised: %q", in.Settings)
+			}
+			if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
+				t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
+			}
+			if !strings.Contains(in.Sniffing, `"enabled":true`) {
+				t.Errorf("Sniffing not normalised: %q", in.Sniffing)
+			}
+		})
+	}
+}
+
+func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
+	in := Inbound{Id: 1, Settings: "not json at all"}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	if !strings.Contains(string(out), `"settings":"not json at all"`) {
+		t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
+	}
+}
+
+func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
+	rec := ClientRecord{Id: 1, Email: "[email protected]", Reverse: `{"tag":"vless-in"}`}
+	out, err := json.Marshal(rec)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	obj, ok := parsed["reverse"].(map[string]any)
+	if !ok {
+		t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
+	}
+	if obj["tag"] != "vless-in" {
+		t.Errorf("expected tag to be preserved, got %v", obj["tag"])
+	}
+}
+
+func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
+	rec := ClientRecord{Id: 1, Email: "[email protected]"}
+	out, err := json.Marshal(rec)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if parsed["reverse"] != nil {
+		t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
+	}
+}
+
+func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
+		{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var rec ClientRecord
+			if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
+				t.Errorf("Reverse not normalised: %q", rec.Reverse)
+			}
+		})
+	}
+}
+
+func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
+	row := InboundClientIps{Id: 1, ClientEmail: "[email protected]", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
+	out, err := json.Marshal(row)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	arr, ok := parsed["ips"].([]any)
+	if !ok {
+		t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
+	}
+	if len(arr) != 1 {
+		t.Errorf("expected 1 entry, got %d", len(arr))
+	}
+}
+
+func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
+		{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var row InboundClientIps
+			if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
+				t.Errorf("Ips not normalised: %q", row.Ips)
+			}
+		})
+	}
+}
 
 
 func TestIsHysteria(t *testing.T) {
 func TestIsHysteria(t *testing.T) {
 	cases := []struct {
 	cases := []struct {

+ 17 - 0
docker-compose.yml

@@ -11,7 +11,24 @@ services:
     environment:
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
       XUI_ENABLE_FAIL2BAN: "true"
+      # To use PostgreSQL instead of the default SQLite, run:
+      #   docker compose --profile postgres up -d
+      # and uncomment the two lines below.
+      # XUI_DB_TYPE: "postgres"
+      # XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
     tty: true
     tty: true
     ports:
     ports:
       - "2053:2053"
       - "2053:2053"
+    restart: unless-stopped
+
+  postgres:
+    image: postgres:16-alpine
+    container_name: 3xui_postgres
+    profiles: ["postgres"]
+    environment:
+      POSTGRES_USER: xui
+      POSTGRES_PASSWORD: xui
+      POSTGRES_DB: xui
+    volumes:
+      - $PWD/pgdata/:/var/lib/postgresql/data
     restart: unless-stopped
     restart: unless-stopped

+ 13 - 0
frontend/clients.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Clients</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/clients.js"></script>
+  </body>
+</html>

+ 46 - 53
frontend/package-lock.json

@@ -334,9 +334,9 @@
       }
       }
     },
     },
     "node_modules/@eslint/config-helpers": {
     "node_modules/@eslint/config-helpers": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
-      "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+      "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
@@ -471,61 +471,61 @@
       }
       }
     },
     },
     "node_modules/@intlify/core-base": {
     "node_modules/@intlify/core-base": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
-      "integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
+      "integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@intlify/devtools-types": "11.4.2",
-        "@intlify/message-compiler": "11.4.2",
-        "@intlify/shared": "11.4.2"
+        "@intlify/devtools-types": "11.4.4",
+        "@intlify/message-compiler": "11.4.4",
+        "@intlify/shared": "11.4.4"
       },
       },
       "engines": {
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
         "url": "https://github.com/sponsors/kazupon"
       }
       }
     },
     },
     "node_modules/@intlify/devtools-types": {
     "node_modules/@intlify/devtools-types": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
-      "integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
+      "integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@intlify/core-base": "11.4.2",
-        "@intlify/shared": "11.4.2"
+        "@intlify/core-base": "11.4.4",
+        "@intlify/shared": "11.4.4"
       },
       },
       "engines": {
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
         "url": "https://github.com/sponsors/kazupon"
       }
       }
     },
     },
     "node_modules/@intlify/message-compiler": {
     "node_modules/@intlify/message-compiler": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
-      "integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
+      "integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@intlify/shared": "11.4.2",
+        "@intlify/shared": "11.4.4",
         "source-map-js": "^1.0.2"
         "source-map-js": "^1.0.2"
       },
       },
       "engines": {
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
         "url": "https://github.com/sponsors/kazupon"
       }
       }
     },
     },
     "node_modules/@intlify/shared": {
     "node_modules/@intlify/shared": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
-      "integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
+      "integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
         "url": "https://github.com/sponsors/kazupon"
@@ -895,9 +895,9 @@
       }
       }
     },
     },
     "node_modules/@rolldown/pluginutils": {
     "node_modules/@rolldown/pluginutils": {
-      "version": "1.0.0-rc.13",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
-      "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
@@ -944,13 +944,13 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/@vitejs/plugin-vue": {
     "node_modules/@vitejs/plugin-vue": {
-      "version": "6.0.6",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
-      "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+      "version": "6.0.7",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+      "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@rolldown/pluginutils": "1.0.0-rc.13"
+        "@rolldown/pluginutils": "^1.0.1"
       },
       },
       "engines": {
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
         "node": "^20.19.0 || >=22.12.0"
@@ -1477,16 +1477,16 @@
       }
       }
     },
     },
     "node_modules/eslint": {
     "node_modules/eslint": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
-      "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
+      "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint/config-array": "^0.23.5",
         "@eslint/config-array": "^0.23.5",
-        "@eslint/config-helpers": "^0.5.5",
+        "@eslint/config-helpers": "^0.6.0",
         "@eslint/core": "^1.2.1",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.1",
         "@eslint/plugin-kit": "^0.7.1",
         "@humanfs/node": "^0.16.6",
         "@humanfs/node": "^0.16.6",
@@ -2694,9 +2694,9 @@
       }
       }
     },
     },
     "node_modules/qs": {
     "node_modules/qs": {
-      "version": "6.15.1",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
-      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
       "license": "BSD-3-Clause",
       "license": "BSD-3-Clause",
       "dependencies": {
       "dependencies": {
         "side-channel": "^1.1.0"
         "side-channel": "^1.1.0"
@@ -2748,13 +2748,6 @@
         "@rolldown/binding-win32-x64-msvc": "1.0.1"
         "@rolldown/binding-win32-x64-msvc": "1.0.1"
       }
       }
     },
     },
-    "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
-      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/scroll-into-view-if-needed": {
     "node_modules/scroll-into-view-if-needed": {
       "version": "2.2.31",
       "version": "2.2.31",
       "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
       "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
@@ -3087,18 +3080,18 @@
       }
       }
     },
     },
     "node_modules/vue-i18n": {
     "node_modules/vue-i18n": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
-      "integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
+      "integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@intlify/core-base": "11.4.2",
-        "@intlify/devtools-types": "11.4.2",
-        "@intlify/shared": "11.4.2",
+        "@intlify/core-base": "11.4.4",
+        "@intlify/devtools-types": "11.4.4",
+        "@intlify/shared": "11.4.4",
         "@vue/devtools-api": "^6.5.0"
         "@vue/devtools-api": "^6.5.0"
       },
       },
       "engines": {
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
         "url": "https://github.com/sponsors/kazupon"

+ 16 - 4
frontend/src/api/axios-init.js

@@ -76,7 +76,14 @@ export function setupAxios() {
       if (config.data instanceof FormData) {
       if (config.data instanceof FormData) {
         config.headers['Content-Type'] = 'multipart/form-data';
         config.headers['Content-Type'] = 'multipart/form-data';
       } else {
       } else {
-        config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
+        const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
+        if (declaredType.toLowerCase().startsWith('application/json')) {
+          if (config.data !== undefined && typeof config.data !== 'string') {
+            config.data = JSON.stringify(config.data);
+          }
+        } else {
+          config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
+        }
       }
       }
       return config;
       return config;
     },
     },
@@ -104,9 +111,14 @@ export function setupAxios() {
         if (token) {
         if (token) {
           cfg.headers = cfg.headers || {};
           cfg.headers = cfg.headers || {};
           cfg.headers['X-CSRF-Token'] = token;
           cfg.headers['X-CSRF-Token'] = token;
-          // axios re-stringifies on retry, so unwind our qs.stringify before
-          // letting the same request flow through the interceptor again.
-          if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
+          const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
+          if (typeof cfg.data === 'string') {
+            if (declaredType.toLowerCase().startsWith('application/json')) {
+              try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
+            } else {
+              cfg.data = qs.parse(cfg.data);
+            }
+          }
           return axios(cfg);
           return axios(cfg);
         }
         }
       }
       }

+ 3 - 0
frontend/src/components/AppSidebar.vue

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
 import {
 import {
   DashboardOutlined,
   DashboardOutlined,
   UserOutlined,
   UserOutlined,
+  TeamOutlined,
   SettingOutlined,
   SettingOutlined,
   ToolOutlined,
   ToolOutlined,
   ClusterOutlined,
   ClusterOutlined,
@@ -30,6 +31,7 @@ const props = defineProps({
 const iconByName = {
 const iconByName = {
   dashboard: DashboardOutlined,
   dashboard: DashboardOutlined,
   user: UserOutlined,
   user: UserOutlined,
+  team: TeamOutlined,
   setting: SettingOutlined,
   setting: SettingOutlined,
   tool: ToolOutlined,
   tool: ToolOutlined,
   cluster: ClusterOutlined,
   cluster: ClusterOutlined,
@@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
 const tabs = computed(() => [
 const tabs = computed(() => [
   { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
   { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
   { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
   { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
+  { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
   { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
   { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
   { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
   { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
   { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
   { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },

+ 21 - 0
frontend/src/entries/clients.js

@@ -0,0 +1,21 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
+import ClientsPage from '@/pages/clients/ClientsPage.vue';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
+});

+ 19 - 15
frontend/src/models/dbinbound.js

@@ -2,6 +2,19 @@ import dayjs from 'dayjs';
 import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
 import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
 import { Inbound, Protocols } from './inbound.js';
 import { Inbound, Protocols } from './inbound.js';
 
 
+export function coerceInboundJsonField(value) {
+    if (value == null) return {};
+    if (typeof value === 'object') return value;
+    if (typeof value !== 'string') return {};
+    const trimmed = value.trim();
+    if (trimmed === '') return {};
+    try {
+        return JSON.parse(trimmed);
+    } catch (_e) {
+        return {};
+    }
+}
+
 export class DBInbound {
 export class DBInbound {
 
 
     constructor(data) {
     constructor(data) {
@@ -10,7 +23,6 @@ export class DBInbound {
         this.up = 0;
         this.up = 0;
         this.down = 0;
         this.down = 0;
         this.total = 0;
         this.total = 0;
-        this.allTime = 0;
         this.remark = "";
         this.remark = "";
         this.enable = true;
         this.enable = true;
         this.expiryTime = 0;
         this.expiryTime = 0;
@@ -28,6 +40,9 @@ export class DBInbound {
         // Optional FK to web/runtime registered Node. null/undefined =
         // Optional FK to web/runtime registered Node. null/undefined =
         // local panel; otherwise the inbound lives on the named node.
         // local panel; otherwise the inbound lives on the named node.
         this.nodeId = null;
         this.nodeId = null;
+        // Populated by the API when this inbound is a fallback child of
+        // a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
+        this.fallbackParent = null;
         if (data == null) {
         if (data == null) {
             return;
             return;
         }
         }
@@ -111,20 +126,9 @@ export class DBInbound {
             return this._cachedInbound;
             return this._cachedInbound;
         }
         }
 
 
-        let settings = {};
-        if (!ObjectUtil.isEmpty(this.settings)) {
-            settings = JSON.parse(this.settings);
-        }
-
-        let streamSettings = {};
-        if (!ObjectUtil.isEmpty(this.streamSettings)) {
-            streamSettings = JSON.parse(this.streamSettings);
-        }
-
-        let sniffing = {};
-        if (!ObjectUtil.isEmpty(this.sniffing)) {
-            sniffing = JSON.parse(this.sniffing);
-        }
+        const settings = coerceInboundJsonField(this.settings);
+        const streamSettings = coerceInboundJsonField(this.streamSettings);
+        const sniffing = coerceInboundJsonField(this.sniffing);
 
 
         const config = {
         const config = {
             port: this.port,
             port: this.port,

+ 30 - 18
frontend/src/models/inbound.js

@@ -16,6 +16,7 @@ export const Protocols = {
 };
 };
 
 
 export const SSMethods = {
 export const SSMethods = {
+    AES_256_GCM: 'aes-256-gcm',
     CHACHA20_POLY1305: 'chacha20-poly1305',
     CHACHA20_POLY1305: 'chacha20-poly1305',
     CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
     CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
     XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
     XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
@@ -232,14 +233,20 @@ export class TcpStreamSettings extends XrayCommonClass {
     }
     }
 
 
     toJson() {
     toJson() {
-        return {
-            acceptProxyProtocol: this.acceptProxyProtocol,
-            header: {
-                type: this.type,
-                request: this.type === 'http' ? this.request.toJson() : undefined,
-                response: this.type === 'http' ? this.response.toJson() : undefined,
-            },
-        };
+        const json = {};
+        if (this.acceptProxyProtocol) {
+            json.acceptProxyProtocol = true;
+        }
+        if (this.type === 'http') {
+            json.header = {
+                type: 'http',
+                request: this.request.toJson(),
+                response: this.response.toJson(),
+            };
+        } else if (this.type && this.type !== 'none') {
+            json.header = { type: this.type };
+        }
+        return json;
     }
     }
 }
 }
 
 
@@ -1465,7 +1472,9 @@ export class StreamSettings extends XrayCommonClass {
         return {
         return {
             network: network,
             network: network,
             security: this.security,
             security: this.security,
-            externalProxy: this.externalProxy,
+            externalProxy: Array.isArray(this.externalProxy) && this.externalProxy.length > 0
+                ? this.externalProxy
+                : undefined,
             tlsSettings: this.isTls ? this.tls.toJson() : undefined,
             tlsSettings: this.isTls ? this.tls.toJson() : undefined,
             realitySettings: this.isReality ? this.reality.toJson() : undefined,
             realitySettings: this.isReality ? this.reality.toJson() : undefined,
             tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
             tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
@@ -1514,11 +1523,14 @@ export class Sniffing extends XrayCommonClass {
     }
     }
 
 
     toJson() {
     toJson() {
+        if (!this.enabled) {
+            return { enabled: false };
+        }
         return {
         return {
-            enabled: this.enabled,
+            enabled: true,
             destOverride: this.destOverride,
             destOverride: this.destOverride,
-            metadataOnly: this.metadataOnly,
-            routeOnly: this.routeOnly,
+            metadataOnly: this.metadataOnly || undefined,
+            routeOnly: this.routeOnly || undefined,
             ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
             ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
             domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
             domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
         };
         };
@@ -2567,7 +2579,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
 
 
 Inbound.VmessSettings = class extends Inbound.Settings {
 Inbound.VmessSettings = class extends Inbound.Settings {
     constructor(protocol,
     constructor(protocol,
-        vmesses = [new Inbound.VmessSettings.VMESS()]) {
+        vmesses = []) {
         super(protocol);
         super(protocol);
         this.vmesses = vmesses;
         this.vmesses = vmesses;
     }
     }
@@ -2635,7 +2647,7 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
 Inbound.VLESSSettings = class extends Inbound.Settings {
 Inbound.VLESSSettings = class extends Inbound.Settings {
     constructor(
     constructor(
         protocol,
         protocol,
-        vlesses = [new Inbound.VLESSSettings.VLESS()],
+        vlesses = [],
         decryption = "none",
         decryption = "none",
         encryption = "none",
         encryption = "none",
         fallbacks = [],
         fallbacks = [],
@@ -2782,7 +2794,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
 
 
 Inbound.TrojanSettings = class extends Inbound.Settings {
 Inbound.TrojanSettings = class extends Inbound.Settings {
     constructor(protocol,
     constructor(protocol,
-        trojans = [new Inbound.TrojanSettings.Trojan()],
+        trojans = [],
         fallbacks = [],) {
         fallbacks = [],) {
         super(protocol);
         super(protocol);
         this.trojans = trojans;
         this.trojans = trojans;
@@ -2864,8 +2876,8 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
     constructor(protocol,
     constructor(protocol,
         method = SSMethods.BLAKE3_AES_256_GCM,
         method = SSMethods.BLAKE3_AES_256_GCM,
         password = RandomUtil.randomShadowsocksPassword(),
         password = RandomUtil.randomShadowsocksPassword(),
-        network = 'tcp,udp',
-        shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()],
+        network = 'tcp',
+        shadowsockses = [],
         ivCheck = false,
         ivCheck = false,
     ) {
     ) {
         super(protocol);
         super(protocol);
@@ -2927,7 +2939,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
 };
 };
 
 
 Inbound.HysteriaSettings = class extends Inbound.Settings {
 Inbound.HysteriaSettings = class extends Inbound.Settings {
-    constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) {
+    constructor(protocol, version = 2, hysterias = []) {
         super(protocol);
         super(protocol);
         this.version = version;
         this.version = version;
         this.hysterias = hysterias;
         this.hysterias = hysterias;

+ 58 - 6
frontend/src/models/outbound.js

@@ -1138,8 +1138,12 @@ export class StreamSettings extends CommonClass {
     }
     }
 
 
     static fromJson(json = {}) {
     static fromJson(json = {}) {
+        // Xray-core supports both "xhttpSettings" and "splithttpSettings" (backward-compat alias)
+        const xhttpJson = json.xhttpSettings ?? json.splithttpSettings;
+        // Normalize "splithttp" network name to "xhttp" for internal consistency
+        const network = json.network === 'splithttp' ? 'xhttp' : json.network;
         return new StreamSettings(
         return new StreamSettings(
-            json.network,
+            network,
             json.security,
             json.security,
             TlsStreamSettings.fromJson(json.tlsSettings),
             TlsStreamSettings.fromJson(json.tlsSettings),
             RealityStreamSettings.fromJson(json.realitySettings),
             RealityStreamSettings.fromJson(json.realitySettings),
@@ -1148,7 +1152,7 @@ export class StreamSettings extends CommonClass {
             WsStreamSettings.fromJson(json.wsSettings),
             WsStreamSettings.fromJson(json.wsSettings),
             GrpcStreamSettings.fromJson(json.grpcSettings),
             GrpcStreamSettings.fromJson(json.grpcSettings),
             HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
             HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
-            xHTTPStreamSettings.fromJson(json.xhttpSettings),
+            xHTTPStreamSettings.fromJson(xhttpJson),
             HysteriaStreamSettings.fromJson(json.hysteriaSettings),
             HysteriaStreamSettings.fromJson(json.hysteriaSettings),
             FinalMaskStreamSettings.fromJson(json.finalmask),
             FinalMaskStreamSettings.fromJson(json.finalmask),
             SockoptStreamSettings.fromJson(json.sockopt),
             SockoptStreamSettings.fromJson(json.sockopt),
@@ -1379,12 +1383,28 @@ export class Outbound extends CommonClass {
         } else if (network === 'httpupgrade') {
         } else if (network === 'httpupgrade') {
             stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
             stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
         } else if (network === 'xhttp') {
         } else if (network === 'xhttp') {
-            // xHTTPStreamSettings positional args are (path, host, headers, ..., mode);
-            // passing `json.mode` as the 3rd argument used to land in the `headers`
-            // slot, dropping the mode on the floor. Build the object and set mode
-            // explicitly to avoid that.
             const xh = new xHTTPStreamSettings(json.path, json.host);
             const xh = new xHTTPStreamSettings(json.path, json.host);
             if (json.mode) xh.mode = json.mode;
             if (json.mode) xh.mode = json.mode;
+            if (json.type && !json.mode) xh.mode = json.type;
+            // Padding / obfuscation — sing-box families use x_padding_bytes,
+            // while the extra block carries xPaddingBytes.
+            if (json.x_padding_bytes && !json.xPaddingBytes) json.xPaddingBytes = json.x_padding_bytes;
+            if (typeof json.xPaddingBytes === 'string' && json.xPaddingBytes) xh.xPaddingBytes = json.xPaddingBytes;
+            if (json.xPaddingObfsMode === true) {
+                xh.xPaddingObfsMode = true;
+                ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
+                    if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
+                });
+            }
+            // Bidirectional string fields carried in the extra block
+            const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
+            xFields.forEach(k => {
+                if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
+            });
+            // Headers — VMess extra emits them as a {name: value} map
+            if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) {
+                xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value }));
+            }
             stream.xhttp = xh;
             stream.xhttp = xh;
         }
         }
 
 
@@ -1455,6 +1475,16 @@ export class Outbound extends CommonClass {
                     ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
                     ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
                         if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
                         if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
                     });
                     });
+                    if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode;
+                    // Bidirectional string fields carried inside the extra block
+                    const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
+                    xFields.forEach(k => {
+                        if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
+                    });
+                    // Headers — extra emits them as a {name: value} map
+                    if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) {
+                        xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value }));
+                    }
                 } catch (_) { /* ignore malformed extra */ }
                 } catch (_) { /* ignore malformed extra */ }
             }
             }
             stream.xhttp = xh;
             stream.xhttp = xh;
@@ -1997,6 +2027,28 @@ Outbound.VLESSSettings = class extends CommonClass {
     }
     }
 
 
     static fromJson(json = {}) {
     static fromJson(json = {}) {
+        // Handle v2rayN-style nested vnext array (standard Xray JSON format)
+        if (!ObjectUtil.isArrEmpty(json.vnext)) {
+            const v = json.vnext[0] || {};
+            const u = ObjectUtil.isArrEmpty(v.users) ? {} : v.users[0];
+            const saved = json.testseed;
+            const testseed = (Array.isArray(saved)
+                && saved.length === 4
+                && saved.every(v => Number.isInteger(v) && v > 0))
+                ? saved
+                : [];
+            return new Outbound.VLESSSettings(
+                v.address,
+                v.port,
+                u.id,
+                u.flow,
+                u.encryption,
+                json.reverse?.tag || '',
+                ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
+                json.testpre || 0,
+                testseed,
+            );
+        }
         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
         const saved = json.testseed;
         const saved = json.testseed;
         const testseed = (Array.isArray(saved)
         const testseed = (Array.isArray(saved)

+ 1 - 1
frontend/src/pages/api-docs/EndpointSection.vue

@@ -9,7 +9,7 @@ import { safeInlineHtml } from './endpoints.js';
 
 
 const props = defineProps({
 const props = defineProps({
   section: { type: Object, required: true },
   section: { type: Object, required: true },
-  icon: { type: Object, default: null },
+  icon: { type: [Object, Function], default: null },
   collapsed: { type: Boolean, default: false },
   collapsed: { type: Boolean, default: false },
 });
 });
 
 

+ 195 - 149
frontend/src/pages/api-docs/endpoints.js

@@ -76,42 +76,31 @@ export const sections = [
       {
       {
         method: 'GET',
         method: 'GET',
         path: '/panel/api/inbounds/list',
         path: '/panel/api/inbounds/list',
-        summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
+        summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
         response:
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "up": 0,\n      "down": 0,\n      "total": 0,\n      "remark": "VLESS-443",\n      "enable": true,\n      "expiryTime": 0,\n      "listen": "",\n      "port": 443,\n      "protocol": "vless",\n      "settings": "{\\"clients\\":[...]}",\n      "streamSettings": "{...}",\n      "tag": "inbound-443",\n      "sniffing": "{...}",\n      "clientStats": [...]\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "up": 0,\n      "down": 0,\n      "total": 0,\n      "remark": "VLESS-443",\n      "enable": true,\n      "expiryTime": 0,\n      "listen": "",\n      "port": 443,\n      "protocol": "vless",\n      "settings": {\n        "clients": [],\n        "decryption": "none"\n      },\n      "streamSettings": {\n        "network": "tcp",\n        "security": "reality",\n        "realitySettings": { "show": false, "dest": "..." }\n      },\n      "tag": "inbound-443",\n      "sniffing": {\n        "enabled": true,\n        "destOverride": ["http", "tls"]\n      },\n      "clientStats": []\n    }\n  ]\n}',
       },
       },
       {
       {
         method: 'GET',
         method: 'GET',
-        path: '/panel/api/inbounds/get/:id',
-        summary: 'Fetch a single inbound by numeric ID.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-        ],
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/inbounds/getClientTraffics/:email',
-        summary: 'Traffic counters for a client identified by email.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
-        ],
-        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
+        path: '/panel/api/inbounds/options',
+        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "remark": "VLESS-443",\n      "protocol": "vless",\n      "port": 443,\n      "tlsFlowCapable": true\n    }\n  ]\n}',
       },
       },
       {
       {
         method: 'GET',
         method: 'GET',
-        path: '/panel/api/inbounds/getClientTrafficsById/:id',
-        summary: 'Traffic counters for a client identified by its UUID/password.',
+        path: '/panel/api/inbounds/get/:id',
+        summary: 'Fetch a single inbound by numeric ID.',
         params: [
         params: [
-          { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
         ],
-        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/inbounds/add',
         path: '/panel/api/inbounds/add',
-        summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
+        summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
         body:
         body:
-          '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n  "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n  "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
+          '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}',
         errorResponse:
         errorResponse:
           '{\n  "success": false,\n  "msg": "Port 443 is already in use"\n}',
           '{\n  "success": false,\n  "msg": "Port 443 is already in use"\n}',
       },
       },
@@ -140,59 +129,6 @@ export const sections = [
         ],
         ],
         body: '{\n  "enable": false\n}',
         body: '{\n  "enable": false\n}',
       },
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/clientIps/:email',
-        summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/clearClientIps/:email',
-        summary: 'Reset the recorded IP list for a client.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/addClient',
-        summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
-        body:
-          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/copyClients',
-        summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
-          { name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
-          { name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
-          { name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/delClient/:clientId',
-        summary: 'Delete a client by its UUID/password from a specific inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/updateClient/:clientId',
-        summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
-        params: [
-          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
-        ],
-        body:
-          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
-      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/inbounds/:id/resetTraffic',
         path: '/panel/api/inbounds/:id/resetTraffic',
@@ -201,36 +137,11 @@ export const sections = [
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
         ],
       },
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
-        summary: 'Zero out upload + download counters for one client.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/inbounds/resetAllTraffics',
         path: '/panel/api/inbounds/resetAllTraffics',
         summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
         summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
       },
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/resetAllClientTraffics/:id',
-        summary: 'Reset traffic for every client in one inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/delDepletedClients/:id',
-        summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
-        ],
-      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/inbounds/import',
         path: '/panel/api/inbounds/import',
@@ -239,58 +150,26 @@ export const sections = [
           { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
           { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
         ],
         ],
       },
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/onlines',
-        summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
-        response: '{\n  "success": true,\n  "obj": ["user1", "user2"]\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/lastOnline',
-        summary: 'Map of client email → last-seen unix timestamp.',
-        response: '{\n  "success": true,\n  "obj": [\n    { "email": "user1", "lastOnline": 1700000000 },\n    { "email": "user2", "lastOnline": 1699999000 }\n  ]\n}',
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/inbounds/getSubLinks/:subId',
-        summary:
-          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
-        params: [
-          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
-        ],
-        response:
-          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
-      },
       {
       {
         method: 'GET',
         method: 'GET',
-        path: '/panel/api/inbounds/getClientLinks/:id/:email',
-        summary:
-          "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
+        path: '/panel/api/inbounds/:id/fallbacks',
+        summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
         params: [
         params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
         ],
         ],
         response:
         response:
-          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "masterId": 10,\n      "childId": 11,\n      "name": "",\n      "alpn": "",\n      "path": "/vlws",\n      "xver": 2,\n      "sortOrder": 0\n    }\n  ]\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
-        path: '/panel/api/inbounds/updateClientTraffic/:email',
-        summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+        path: '/panel/api/inbounds/:id/fallbacks',
+        summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
         params: [
         params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-        body: '{\n  "upload": 1073741824,\n  "download": 5368709120\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/delClientByEmail/:email',
-        summary: 'Delete a client identified by email rather than UUID.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
+          { name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
         ],
         ],
+        body: '{\n  "fallbacks": [\n    { "childId": 11, "path": "/vlws", "xver": 2 },\n    { "childId": 12, "alpn": "h2" }\n  ]\n}',
+        response: '{\n  "success": true,\n  "msg": "Inbound updated"\n}',
       },
       },
     ],
     ],
   },
   },
@@ -494,6 +373,173 @@ export const sections = [
     ],
     ],
   },
   },
 
 
+  {
+    id: 'clients',
+    title: 'Clients',
+    description:
+      'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/clients/list',
+        summary: 'List every client with its attached inbound IDs and traffic record. The reverse field, if set, is returned as a nested JSON object (legacy JSON-encoded-string form is still accepted on write).',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "email": "[email protected]",\n      "subId": "abcd1234",\n      "uuid": "...",\n      "totalGB": 53687091200,\n      "expiryTime": 1735689600000,\n      "enable": true,\n      "reverse": null,\n      "inboundIds": [3, 5],\n      "traffic": { "up": 1024, "down": 4096, "enable": true }\n    }\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/get/:email',
+        summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5]\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/add',
+        summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
+        params: [
+          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
+        ],
+        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
+        response: '{\n  "success": true,\n  "msg": "Client added"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/update/:email',
+        summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
+        ],
+        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "tgId": 123456789,\n  "enable": true\n}',
+        response: '{\n  "success": true,\n  "msg": "Client updated"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/del/:email',
+        summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
+        ],
+        response: '{\n  "success": true,\n  "msg": "Client deleted"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/attach',
+        summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
+        ],
+        body: '{\n  "inboundIds": [7, 9]\n}',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/detach',
+        summary: 'Detach a client from one or more inbounds without deleting the client.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
+        ],
+        body: '{\n  "inboundIds": [5]\n}',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/resetAllTraffics',
+        summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/delDepleted',
+        summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/resetTraffic/:email',
+        summary: 'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/updateTraffic/:email',
+        summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        body: '{\n  "upload": 1073741824,\n  "download": 5368709120\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/ips/:email',
+        summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/clearIps/:email',
+        summary: 'Reset the recorded IP list for a client.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/onlines',
+        summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
+        response: '{\n  "success": true,\n  "obj": ["user1", "user2"]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/lastOnline',
+        summary: 'Map of client email → last-seen unix timestamp.',
+        response: '{\n  "success": true,\n  "obj": {\n    "user1": 1700000000,\n    "user2": 1699999000\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/traffic/:email',
+        summary: 'Traffic counters for a client identified by email.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
+        ],
+        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/subLinks/:subId',
+        summary:
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
+        params: [
+          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/links/:email',
+        summary:
+          "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+      },
+    ],
+  },
+
   {
   {
     id: 'nodes',
     id: 'nodes',
     title: 'Nodes',
     title: 'Nodes',
@@ -504,7 +550,7 @@ export const sections = [
         method: 'GET',
         method: 'GET',
         path: '/panel/api/nodes/list',
         path: '/panel/api/nodes/list',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "de-fra-1",\n      "scheme": "https",\n      "host": "node1.example.com",\n      "port": 2053,\n      "status": "online",\n      "cpu": 23.5,\n      "mem": 45.1\n    }\n  ]\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "de-fra-1",\n      "remark": "",\n      "scheme": "https",\n      "address": "node1.example.com",\n      "port": 2053,\n      "basePath": "/",\n      "apiToken": "abcdef...",\n      "enable": true,\n      "allowPrivateAddress": false,\n      "status": "online",\n      "lastHeartbeat": 1700000000,\n      "latencyMs": 42,\n      "xrayVersion": "25.x.x",\n      "panelVersion": "v3.x.x",\n      "cpuPct": 23.5,\n      "memPct": 45.1,\n      "uptimeSecs": 86400,\n      "lastError": "",\n      "inboundCount": 5,\n      "clientCount": 27,\n      "onlineCount": 3,\n      "depletedCount": 1,\n      "createdAt": 1700000000,\n      "updatedAt": 1700000000\n    }\n  ]\n}',
       },
       },
       {
       {
         method: 'GET',
         method: 'GET',
@@ -517,9 +563,9 @@ export const sections = [
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/nodes/add',
         path: '/panel/api/nodes/add',
-        summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
+        summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
         body:
         body:
-          '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+          '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
@@ -528,7 +574,7 @@ export const sections = [
         params: [
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
         ],
-        body: '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        body: '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
@@ -550,9 +596,9 @@ export const sections = [
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/nodes/test',
         path: '/panel/api/nodes/test',
-        summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
-        body: '{\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
-        response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "cpu": 12.5,\n    "mem": 45.2\n  }\n}',
+        summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
+        body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "latencyMs": 42,\n    "xrayVersion": "25.x.x",\n    "panelVersion": "v3.x.x",\n    "cpuPct": 12.5,\n    "memPct": 45.2,\n    "uptimeSecs": 86400,\n    "error": ""\n  }\n}',
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',

+ 267 - 0
frontend/src/pages/clients/ClientBulkAddModal.vue

@@ -0,0 +1,267 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { SyncOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
+import DateTimePicker from '@/components/DateTimePicker.vue';
+import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  inbounds: { type: Array, default: () => [] },
+  ipLimitEnable: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
+
+const saving = ref(false);
+const delayedStart = ref(false);
+
+const form = reactive({
+  emailMethod: 0,
+  firstNum: 1,
+  lastNum: 1,
+  emailPrefix: '',
+  emailPostfix: '',
+  quantity: 1,
+  subId: '',
+  comment: '',
+  flow: '',
+  limitIp: 0,
+  totalGB: 0,
+  expiryTime: 0,
+  inboundIds: [],
+});
+
+const flowCapableIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row?.tlsFlowCapable) ids.add(row.id);
+  }
+  return ids;
+});
+
+const showFlow = computed(() =>
+  (form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
+);
+
+watch(showFlow, (next) => {
+  if (!next) form.flow = '';
+});
+
+const expiryDate = computed({
+  get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
+  set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
+});
+
+const delayedExpireDays = computed({
+  get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
+  set: (days) => { form.expiryTime = -86400000 * (days || 0); },
+});
+
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+]);
+
+const inboundOptions = computed(() =>
+  (props.inbounds || [])
+    .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
+    .map((ib) => ({
+      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+      value: ib.id,
+    })),
+);
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  form.emailMethod = 0;
+  form.firstNum = 1;
+  form.lastNum = 1;
+  form.emailPrefix = '';
+  form.emailPostfix = '';
+  form.quantity = 1;
+  form.subId = '';
+  form.comment = '';
+  form.flow = '';
+  form.limitIp = 0;
+  form.totalGB = 0;
+  form.expiryTime = 0;
+  form.inboundIds = [];
+  delayedStart.value = false;
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+function buildEmails() {
+  const method = form.emailMethod;
+  const out = [];
+  let start;
+  let end;
+  if (method > 1) {
+    start = form.firstNum;
+    end = form.lastNum + 1;
+  } else {
+    start = 0;
+    end = form.quantity;
+  }
+  const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
+  const useNum = method > 1;
+  const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
+  for (let i = start; i < end; i++) {
+    let email = '';
+    if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
+    email += useNum ? prefix + String(i) + postfix : prefix + postfix;
+    out.push(email);
+  }
+  return out;
+}
+
+async function submit() {
+  if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
+    message.error(t('pages.clients.selectInbound'));
+    return;
+  }
+  const emails = buildEmails();
+  if (emails.length === 0) return;
+
+  saving.value = true;
+  const silentJsonOpts = { ...JSON_HEADERS, silent: true };
+  try {
+    const results = await Promise.all(emails.map((email) => {
+      const client = {
+        email,
+        subId: form.subId || RandomUtil.randomLowerAndNum(16),
+        id: RandomUtil.randomUUID(),
+        password: RandomUtil.randomLowerAndNum(16),
+        auth: RandomUtil.randomLowerAndNum(16),
+        flow: showFlow.value ? (form.flow || '') : '',
+        totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
+        expiryTime: form.expiryTime,
+        limitIp: Number(form.limitIp) || 0,
+        comment: form.comment,
+        enable: true,
+      };
+      const payload = { client, inboundIds: form.inboundIds };
+      return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
+    }));
+    let ok = 0;
+    let failed = 0;
+    let firstError = '';
+    for (const msg of results) {
+      if (msg?.success) ok++;
+      else {
+        failed++;
+        if (!firstError && msg?.msg) firstError = msg.msg;
+      }
+    }
+    if (failed === 0) {
+      message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
+    } else {
+      message.warning(firstError
+        ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
+        : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
+    }
+    emit('saved');
+    close();
+  } finally {
+    saving.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
+    :confirm-loading="saving" :mask-closable="false" :width="640" @ok="submit" @cancel="close">
+    <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
+      <a-form-item :label="t('pages.clients.attachedInbounds')" required>
+        <a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
+          :placeholder="t('pages.clients.selectInbound')" :show-search="true"
+          :filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.method')">
+        <a-select v-model:value="form.emailMethod">
+          <a-select-option :value="0">Random</a-select-option>
+          <a-select-option :value="1">Random + Prefix</a-select-option>
+          <a-select-option :value="2">Random + Prefix + Num</a-select-option>
+          <a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
+          <a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
+        <a-input-number v-model:value="form.firstNum" :min="1" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
+        <a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
+        <a-input v-model:value="form.emailPrefix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
+        <a-input v-model:value="form.emailPostfix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
+        <a-input-number v-model:value="form.quantity" :min="1" :max="100" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          {{ t('subscription.title') }}
+          <SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
+        </template>
+        <a-input v-model:value="form.subId" />
+      </a-form-item>
+
+      <a-form-item :label="t('comment')">
+        <a-input v-model:value="form.comment" />
+      </a-form-item>
+
+      <a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
+        <a-select v-model:value="form.flow" :style="{ width: '220px' }">
+          <a-select-option value="">{{ t('none') }}</a-select-option>
+          <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
+        <a-input-number v-model:value="form.limitIp" :min="0" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.totalGB')">
+        <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.delayedStart')">
+        <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
+      </a-form-item>
+
+      <a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
+        <a-input-number v-model:value="delayedExpireDays" :min="0" />
+      </a-form-item>
+
+      <a-form-item v-else :label="t('pages.inbounds.expireDate')">
+        <DateTimePicker v-model:value="expiryDate" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.random-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-color-primary, #1677ff);
+}
+</style>

+ 402 - 0
frontend/src/pages/clients/ClientFormModal.vue

@@ -0,0 +1,402 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { HttpUtil, RandomUtil } from '@/utils';
+import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  mode: { type: String, default: 'add' },
+  client: { type: Object, default: null },
+  inbounds: { type: Array, default: () => [] },
+  attachedIds: { type: Array, default: () => [] },
+  ipLimitEnable: { type: Boolean, default: false },
+  tgBotEnable: { type: Boolean, default: false },
+  save: { type: Function, required: true },
+});
+
+const emit = defineEmits(['update:open']);
+const { t } = useI18n();
+
+const submitting = ref(false);
+const form = reactive(emptyForm());
+
+function emptyForm() {
+  return {
+    email: '',
+    subId: '',
+    uuid: '',
+    password: '',
+    auth: '',
+    flow: '',
+    reverseTag: '',
+    totalGB: 0,
+    expiryDate: null,
+    delayedStart: false,
+    delayedDays: 0,
+    limitIp: 0,
+    tgId: 0,
+    comment: '',
+    enable: true,
+    inboundIds: [],
+  };
+}
+
+const isEdit = computed(() => props.mode === 'edit');
+
+watch(
+  () => props.open,
+  (next) => {
+    if (!next) return;
+    Object.assign(form, emptyForm());
+    if (isEdit.value && props.client) {
+      form.email = props.client.email || '';
+      form.subId = props.client.subId || '';
+      form.uuid = props.client.uuid || '';
+      form.password = props.client.password || '';
+      form.auth = props.client.auth || '';
+      form.flow = props.client.flow || '';
+      form.reverseTag = props.client.reverse?.tag || '';
+      form.totalGB = bytesToGB(props.client.totalGB || 0);
+      const et = Number(props.client.expiryTime) || 0;
+      if (et < 0) {
+        form.delayedStart = true;
+        form.delayedDays = Math.round(et / -86400000);
+        form.expiryDate = null;
+      } else {
+        form.delayedStart = false;
+        form.delayedDays = 0;
+        form.expiryDate = et > 0 ? dayjs(et) : null;
+      }
+      form.limitIp = props.client.limitIp || 0;
+      form.tgId = Number(props.client.tgId) || 0;
+      form.comment = props.client.comment || '';
+      form.enable = !!props.client.enable;
+      form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
+      void loadIps();
+    } else {
+      form.email = RandomUtil.randomLowerAndNum(9);
+      form.uuid = RandomUtil.randomUUID();
+      form.subId = RandomUtil.randomLowerAndNum(16);
+      form.password = RandomUtil.randomLowerAndNum(16);
+      form.auth = RandomUtil.randomLowerAndNum(16);
+    }
+  },
+);
+
+function bytesToGB(bytes) {
+  if (!bytes || bytes <= 0) return 0;
+  return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
+}
+
+function gbToBytes(gb) {
+  if (!gb || gb <= 0) return 0;
+  return Math.round(gb * 1024 * 1024 * 1024);
+}
+
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+]);
+
+const inboundOptions = computed(() =>
+  (props.inbounds || [])
+    .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
+    .map((ib) => ({
+      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+      value: ib.id,
+      title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
+    })),
+);
+
+const flowCapableIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row?.tlsFlowCapable) ids.add(row.id);
+  }
+  return ids;
+});
+
+const showFlow = computed(() =>
+  (form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
+);
+
+watch(showFlow, (next) => {
+  if (!next) form.flow = '';
+});
+
+const vlessLikeIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row && row.protocol === 'vless') {
+      ids.add(row.id);
+    }
+  }
+  return ids;
+});
+
+const showReverseTag = computed(() =>
+  (form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
+);
+
+watch(showReverseTag, (next) => {
+  if (!next) form.reverseTag = '';
+});
+
+const clientIps = ref([]);
+const ipsLoading = ref(false);
+const ipsClearing = ref(false);
+
+async function loadIps() {
+  if (!isEdit.value || !props.client?.email) return;
+  ipsLoading.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
+    if (!msg?.success) { clientIps.value = []; return; }
+    const arr = Array.isArray(msg.obj) ? msg.obj : [];
+    clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
+  } finally {
+    ipsLoading.value = false;
+  }
+}
+
+async function clearIps() {
+  if (!isEdit.value || !props.client?.email) return;
+  ipsClearing.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
+    if (msg?.success) clientIps.value = [];
+  } finally {
+    ipsClearing.value = false;
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+function regenerateUUID() {
+  form.uuid = RandomUtil.randomUUID();
+}
+
+function regeneratePassword() {
+  form.password = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateAuth() {
+  form.auth = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateSubId() {
+  form.subId = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateEmail() {
+  form.email = RandomUtil.randomLowerAndNum(12);
+}
+
+function onDelayedStartToggle(next) {
+  if (next) {
+    form.expiryDate = null;
+  } else {
+    form.delayedDays = 0;
+  }
+}
+
+async function onSubmit() {
+  if (!form.email || form.email.trim() === '') {
+    message.error(`${t('pages.clients.email')} *`);
+    return;
+  }
+  if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
+    message.error(t('pages.clients.selectInbound'));
+    return;
+  }
+  const expiryTime = form.delayedStart
+    ? -86400000 * (Number(form.delayedDays) || 0)
+    : (form.expiryDate ? form.expiryDate.valueOf() : 0);
+  const clientPayload = {
+    email: form.email.trim(),
+    subId: form.subId,
+    id: form.uuid,
+    password: form.password,
+    auth: form.auth,
+    flow: showFlow.value ? (form.flow || '') : '',
+    totalGB: gbToBytes(form.totalGB),
+    expiryTime,
+    limitIp: Number(form.limitIp) || 0,
+    tgId: Number(form.tgId) || 0,
+    comment: form.comment,
+    enable: !!form.enable,
+  };
+  const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
+  if (reverseTag) {
+    clientPayload.reverse = { tag: reverseTag };
+  }
+
+  submitting.value = true;
+  try {
+    let msg;
+    if (isEdit.value) {
+      const original = new Set(props.attachedIds || []);
+      const next = new Set(form.inboundIds || []);
+      const toAttach = [...next].filter((id) => !original.has(id));
+      const toDetach = [...original].filter((id) => !next.has(id));
+      msg = await props.save(clientPayload, {
+        isEdit: true,
+        email: props.client.email,
+        attach: toAttach,
+        detach: toDetach,
+      });
+    } else {
+      msg = await props.save(
+        { client: clientPayload, inboundIds: form.inboundIds },
+        { isEdit: false },
+      );
+    }
+    if (msg?.success) close();
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
+    :destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
+    :ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
+    <a-form layout="vertical" :model="form">
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.email')" required>
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
+              <a-button @click="regenerateEmail">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.subId')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.subId" style="flex: 1" />
+              <a-button @click="regenerateSubId">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.hysteriaAuth')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.auth" style="flex: 1" />
+              <a-button @click="regenerateAuth">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.password')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.password" style="flex: 1" />
+              <a-button @click="regeneratePassword">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.uuid')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.uuid" style="flex: 1" />
+              <a-button @click="regenerateUUID">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="ipLimitEnable ? 8 : 12">
+          <a-form-item :label="t('pages.clients.totalGB')">
+            <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col v-if="ipLimitEnable" :span="4">
+          <a-form-item :label="t('pages.clients.limitIp')">
+            <a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
+            <a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
+          </a-form-item>
+          <a-form-item v-else :label="t('pages.clients.expiryTime')">
+            <a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.delayedStart')">
+            <a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row v-if="showFlow || showReverseTag" :gutter="16">
+        <a-col v-if="showFlow" :span="12">
+          <a-form-item :label="t('pages.clients.flow')">
+            <a-select v-model:value="form.flow">
+              <a-select-option value="">{{ t('none') }}</a-select-option>
+              <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col v-if="showReverseTag" :span="12">
+          <a-form-item :label="t('pages.clients.reverseTag')">
+            <a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col v-if="tgBotEnable" :span="12">
+          <a-form-item :label="t('pages.clients.telegramId')">
+            <a-input-number v-model:value="form.tgId" :min="0" :controls="false"
+              :placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="tgBotEnable ? 12 : 24">
+          <a-form-item :label="t('pages.clients.comment')">
+            <a-input v-model:value="form.comment" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
+        <a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
+          :placeholder="t('pages.clients.selectInbound')"
+          :filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
+      </a-form-item>
+
+      <a-form-item>
+        <a-switch v-model:checked="form.enable" />
+        <span style="margin-left: 8px">{{ t('enable') }}</span>
+      </a-form-item>
+
+      <a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
+        <a-space style="margin-bottom: 8px">
+          <a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
+          <a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
+            {{ t('pages.clients.clearAll') }}
+          </a-button>
+        </a-space>
+        <div v-if="clientIps.length > 0">
+          <a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
+        </div>
+        <a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 411 - 0
frontend/src/pages/clients/ClientInfoModal.vue

@@ -0,0 +1,411 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { CopyOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  client: { type: Object, default: null },
+  inboundsById: { type: Object, default: () => ({}) },
+  isOnline: { type: Boolean, default: false },
+  subSettings: {
+    type: Object,
+    default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+  },
+});
+
+const emit = defineEmits(['update:open']);
+
+const links = ref([]);
+const linksLoading = ref(false);
+
+const traffic = computed(() => props.client?.traffic || null);
+const totalBytes = computed(() => props.client?.totalGB || 0);
+const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
+const remaining = computed(() => {
+  if (totalBytes.value <= 0) return -1;
+  const r = totalBytes.value - used.value;
+  return r > 0 ? r : 0;
+});
+
+const subLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.subURI) return '';
+  return props.subSettings.subURI + props.client.subId;
+});
+
+const subJsonLink = computed(() => {
+  if (!props.client?.subId) return '';
+  if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
+  return props.subSettings.subJsonURI + props.client.subId;
+});
+
+const showSubscription = computed(
+  () => !!(props.subSettings?.enable && props.client?.subId),
+);
+
+function expiryLabel(ts) {
+  if (!ts || ts <= 0) return '∞';
+  return IntlUtil.formatDate(ts);
+}
+
+function expiryRelative(ts) {
+  if (!ts || ts <= 0) return '';
+  return IntlUtil.formatRelativeTime(ts);
+}
+
+function lastOnlineLabel(ts) {
+  if (!ts || ts <= 0) return '-';
+  return IntlUtil.formatDate(ts);
+}
+
+function dateLabel(ts) {
+  if (!ts || ts <= 0) return '-';
+  return IntlUtil.formatDate(ts);
+}
+
+async function copyValue(text) {
+  if (!text) return;
+  const ok = await ClipboardManager.copyText(String(text));
+  if (ok) message.success(t('copied'));
+}
+
+async function loadLinks() {
+  if (!props.client?.subId) {
+    links.value = [];
+    return;
+  }
+  linksLoading.value = true;
+  try {
+    const msg = await HttpUtil.get(
+      `/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
+    );
+    links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
+  } finally {
+    linksLoading.value = false;
+  }
+}
+
+watch(() => props.open, (next) => {
+  if (next) loadLinks();
+  else links.value = [];
+});
+
+function close() {
+  emit('update:open', false);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
+    <template v-if="client">
+      <table class="info-table block">
+        <tbody>
+          <tr>
+            <td>{{ t('pages.clients.online') }}</td>
+            <td>
+              <a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
+              <a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
+              <span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('status') }}</td>
+            <td>
+              <a-tag :color="client.enable ? 'green' : 'default'">
+                {{ client.enable ? t('enabled') : t('disabled') }}
+              </a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.email') }}</td>
+            <td>
+              <a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
+              <a-tag v-else color="red">{{ t('none') }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.subId') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
+              <a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.uuid">
+            <td>{{ t('pages.clients.uuid') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.uuid)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.password">
+            <td>{{ t('password') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.password }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.password)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.auth">
+            <td>{{ t('pages.clients.auth') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.auth }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.auth)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.flow') }}</td>
+            <td>
+              <a-tag v-if="client.flow">{{ client.flow }}</a-tag>
+              <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.traffic') }}</td>
+            <td>
+              <a-tag>
+                ↑ {{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
+                / ↓ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
+              </a-tag>
+              <span class="hint">
+                {{ SizeFormatter.sizeFormat(used) }}
+                /
+                {{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
+              </span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('remained') }}</td>
+            <td>
+              <a-tag v-if="remaining < 0" color="purple">∞</a-tag>
+              <a-tag v-else :color="remaining > 0 ? '' : 'red'">
+                {{ SizeFormatter.sizeFormat(remaining) }}
+              </a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.expireDate') }}</td>
+            <td>
+              <a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple">∞</a-tag>
+              <a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
+              <span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.ipLimit') }}</td>
+            <td>
+              <a-tag v-if="!client.limitIp">∞</a-tag>
+              <a-tag v-else>{{ client.limitIp }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.createdAt') }}</td>
+            <td>
+              <a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.updatedAt') }}</td>
+            <td>
+              <a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
+            </td>
+          </tr>
+
+          <tr v-if="client.comment">
+            <td>{{ t('pages.clients.comment') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.comment }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.attachedInbounds') }}</td>
+            <td>
+              <div class="chips">
+                <a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
+                  <template v-if="inboundsById[id]">
+                    {{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
+                  </template>
+                  <template v-else>#{{ id }}</template>
+                </a-tag>
+                <span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint">—</span>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <template v-if="links.length > 0">
+        <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
+        <div v-for="(link, idx) in links" :key="idx" class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(link)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <code class="link-panel-text">{{ link }}</code>
+        </div>
+      </template>
+
+      <template v-if="showSubscription && subLink">
+        <a-divider>{{ t('subscription.title') }}</a-divider>
+        <div class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">{{ t('subscription.title') }}</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(subLink)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
+        </div>
+
+        <div v-if="subJsonLink" class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">JSON</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(subJsonLink)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
+        </div>
+      </template>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.info-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.info-table.block {
+  margin-bottom: 10px;
+}
+
+.info-table td {
+  padding: 4px 8px;
+  vertical-align: top;
+}
+
+.info-table td:first-child {
+  width: 140px;
+  font-size: 13px;
+  opacity: 0.75;
+  white-space: nowrap;
+}
+
+.info-large-tag {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.hint {
+  font-size: 12px;
+  opacity: 0.55;
+  margin-left: 6px;
+}
+
+.chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.link-panel {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 8px;
+  padding: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.link-panel-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.link-panel-text {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  white-space: pre-wrap;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  user-select: all;
+}
+
+:global(body.dark) .link-panel-text {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.link-panel-anchor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  color: var(--ant-color-primary, #1677ff);
+  text-decoration: underline;
+  text-decoration-color: rgba(22, 119, 255, 0.4);
+  transition: background 120ms ease, text-decoration-color 120ms ease;
+}
+
+.link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.08);
+  text-decoration-color: var(--ant-color-primary, #1677ff);
+}
+
+:global(body.dark) .link-panel-anchor {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+:global(body.dark) .link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.16);
+}
+</style>

+ 97 - 0
frontend/src/pages/clients/ClientQrModal.vue

@@ -0,0 +1,97 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { HttpUtil } from '@/utils';
+import QrPanel from '@/pages/inbounds/QrPanel.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  client: { type: Object, default: null },
+  subSettings: {
+    type: Object,
+    default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+  },
+});
+
+const emit = defineEmits(['update:open']);
+
+const links = ref([]);
+const loading = ref(false);
+
+const subLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
+  return props.subSettings.subURI + props.client.subId;
+});
+
+const subJsonLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.enable) return '';
+  if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
+  return props.subSettings.subJsonURI + props.client.subId;
+});
+
+const activeKeys = computed(() => {
+  const keys = [];
+  if (subLink.value) keys.push('sub');
+  if (subJsonLink.value) keys.push('subJson');
+  if (links.value.length > 0) keys.push('l0');
+  return keys;
+});
+
+const hasAnything = computed(
+  () => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
+);
+
+watch(() => props.open, async (next) => {
+  if (!next || !props.client?.subId) {
+    links.value = [];
+    return;
+  }
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
+    links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
+  } finally {
+    loading.value = false;
+  }
+});
+
+function close() {
+  emit('update:open', false);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
+    @cancel="close">
+    <a-spin :spinning="loading">
+      <div v-if="!client?.subId && !loading" class="empty">
+        {{ t('pages.clients.noSubId') }}
+      </div>
+      <div v-else-if="!hasAnything && !loading" class="empty">
+        {{ t('pages.clients.noLinks') }}
+      </div>
+      <a-collapse v-else :active-key="activeKeys" accordion>
+        <a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
+          <QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
+        </a-collapse-panel>
+        <a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
+          <QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
+        </a-collapse-panel>
+        <a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
+          :header="`${t('pages.clients.link')} ${idx + 1}`">
+          <QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
+        </a-collapse-panel>
+      </a-collapse>
+    </a-spin>
+  </a-modal>
+</template>
+
+<style scoped>
+.empty {
+  padding: 24px;
+  text-align: center;
+  opacity: 0.6;
+}
+</style>

+ 1067 - 0
frontend/src/pages/clients/ClientsPage.vue

@@ -0,0 +1,1067 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  PlusOutlined,
+  UserOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+  QrcodeOutlined,
+  RetweetOutlined,
+  RestOutlined,
+  MoreOutlined,
+  UsergroupAddOutlined,
+  SearchOutlined,
+  FilterOutlined,
+  TeamOutlined,
+} from '@ant-design/icons-vue';
+
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import { useWebSocket } from '@/composables/useWebSocket.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import CustomStatistic from '@/components/CustomStatistic.vue';
+import { ObjectUtil, SizeFormatter, IntlUtil } from '@/utils';
+import { useClients } from './useClients.js';
+import ClientFormModal from './ClientFormModal.vue';
+import ClientInfoModal from './ClientInfoModal.vue';
+import ClientQrModal from './ClientQrModal.vue';
+import ClientBulkAddModal from './ClientBulkAddModal.vue';
+
+const { t } = useI18n();
+
+const {
+  clients,
+  inbounds,
+  onlines,
+  loading,
+  fetched,
+  subSettings,
+  ipLimitEnable,
+  tgBotEnable,
+  expireDiff,
+  trafficDiff,
+  create,
+  update,
+  remove,
+  removeMany,
+  attach,
+  detach,
+  resetTraffic,
+  resetAllTraffics,
+  delDepleted,
+  setEnable,
+  applyTrafficEvent,
+  applyClientStatsEvent,
+  applyInvalidate,
+} = useClients();
+
+useWebSocket({
+  traffic: applyTrafficEvent,
+  client_stats: applyClientStatsEvent,
+  invalidate: applyInvalidate,
+});
+
+const togglingEmail = ref(null);
+
+async function onToggleEnable(row, next) {
+  togglingEmail.value = row.email;
+  try {
+    const msg = await setEnable(row, next);
+    if (!msg?.success) {
+      message.error(msg?.msg || t('somethingWentWrong'));
+    }
+  } finally {
+    togglingEmail.value = null;
+  }
+}
+
+const { isMobile } = useMediaQuery();
+const basePath = window.X_UI_BASE_PATH || '';
+const requestUri = window.location.pathname;
+
+const formOpen = ref(false);
+const formMode = ref('add');
+const editingClient = ref(null);
+const editingAttachedIds = ref([]);
+
+const infoOpen = ref(false);
+const infoClient = ref(null);
+
+const qrOpen = ref(false);
+const qrClient = ref(null);
+
+const bulkAddOpen = ref(false);
+const selectedRowKeys = ref([]);
+
+const rowSelection = computed(() => ({
+  selectedRowKeys: selectedRowKeys.value,
+  onChange: (keys) => { selectedRowKeys.value = keys; },
+}));
+
+function toggleSelect(email, checked) {
+  const cur = new Set(selectedRowKeys.value);
+  if (checked) cur.add(email);
+  else cur.delete(email);
+  selectedRowKeys.value = Array.from(cur);
+}
+
+function isSelected(email) {
+  return selectedRowKeys.value.includes(email);
+}
+
+function selectAll(checked) {
+  selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : [];
+}
+
+const allSelected = computed(
+  () => filteredClients.value.length > 0 && selectedRowKeys.value.length === filteredClients.value.length,
+);
+
+const someSelected = computed(
+  () => selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < filteredClients.value.length,
+);
+
+function onBulkAdd() {
+  bulkAddOpen.value = true;
+}
+
+function onBulkDelete() {
+  const emails = [...selectedRowKeys.value];
+  if (emails.length === 0) return;
+  Modal.confirm({
+    title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
+    content: t('pages.clients.bulkDeleteConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const results = await removeMany(emails);
+      selectedRowKeys.value = [];
+      let ok = 0;
+      let failed = 0;
+      let firstError = '';
+      for (const msg of results) {
+        if (msg?.success) ok++;
+        else {
+          failed++;
+          if (!firstError && msg?.msg) firstError = msg.msg;
+        }
+      }
+      if (failed === 0) {
+        message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
+      } else {
+        message.warning(firstError
+          ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
+          : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
+      }
+    },
+  });
+}
+
+async function onBulkAddSaved() {
+  bulkAddOpen.value = false;
+}
+
+function onDelDepleted() {
+  Modal.confirm({
+    title: t('pages.clients.delDepletedConfirmTitle'),
+    content: t('pages.clients.delDepletedConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await delDepleted();
+      if (msg?.success) {
+        const deleted = msg.obj?.deleted ?? 0;
+        message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
+      }
+    },
+  });
+}
+
+const FILTER_STATE_KEY = 'clientsFilterState';
+const savedFilterState = (() => {
+  try { return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); }
+  catch (_e) { return {}; }
+})();
+const enableFilter = ref(!!savedFilterState.enableFilter);
+const searchKey = ref(savedFilterState.searchKey || '');
+const filterBy = ref(savedFilterState.filterBy || '');
+const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
+
+watch([enableFilter, searchKey, filterBy, protocolFilter], () => {
+  localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
+    enableFilter: enableFilter.value,
+    searchKey: searchKey.value,
+    filterBy: filterBy.value,
+    protocolFilter: protocolFilter.value,
+  }));
+});
+
+function onToggleFilter() {
+  if (enableFilter.value) searchKey.value = '';
+  else filterBy.value = '';
+}
+
+const protocolOptions = computed(() => {
+  const values = new Set((inbounds.value || []).map((i) => i.protocol).filter(Boolean));
+  return [...values].sort();
+});
+
+const onlineSet = computed(() => new Set(onlines.value || []));
+const inboundsById = computed(() => {
+  const out = {};
+  for (const ib of inbounds.value) out[ib.id] = ib;
+  return out;
+});
+
+function isOnline(email) {
+  return !!email && onlineSet.value.has(email);
+}
+
+function inboundLabel(id) {
+  const ib = inboundsById.value[id];
+  if (!ib) return `#${id}`;
+  return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
+}
+
+function clientBucket(row) {
+  if (!row) return null;
+  const traffic = row.traffic || {};
+  const used = (traffic.up || 0) + (traffic.down || 0);
+  const total = row.totalGB || 0;
+  const now = Date.now();
+  const expired = row.expiryTime > 0 && row.expiryTime <= now;
+  const exhausted = total > 0 && used >= total;
+  if (expired || exhausted) return 'depleted';
+  if (!row.enable) return 'deactive';
+  const nearExpiry = row.expiryTime > 0 && row.expiryTime - now < (expireDiff.value || 0);
+  const nearLimit = total > 0 && total - used < (trafficDiff.value || 0);
+  if (nearExpiry || nearLimit) return 'expiring';
+  return 'active';
+}
+
+function bucketTagColor(bucket) {
+  switch (bucket) {
+    case 'depleted': return 'red';
+    case 'expiring': return 'orange';
+    case 'deactive': return 'default';
+    case 'active': return 'green';
+    default: return 'default';
+  }
+}
+
+function clientMatchesProtocol(row, protocol) {
+  if (!protocol) return true;
+  const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
+  for (const id of ids) {
+    const ib = inboundsById.value[id];
+    if (ib && ib.protocol === protocol) return true;
+  }
+  return false;
+}
+
+const filteredClients = computed(() => {
+  let rows = clients.value || [];
+  if (enableFilter.value) {
+    if (filterBy.value === 'online') {
+      rows = rows.filter((r) => r.enable && isOnline(r.email));
+    } else if (filterBy.value) {
+      rows = rows.filter((r) => clientBucket(r) === filterBy.value);
+    }
+  } else if (!ObjectUtil.isEmpty(searchKey.value)) {
+    rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey.value));
+  }
+  if (protocolFilter.value) {
+    rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter.value));
+  }
+  return rows;
+});
+
+const summary = computed(() => {
+  const rows = clients.value || [];
+  const deactive = [];
+  const depleted = [];
+  const expiring = [];
+  const online = [];
+  let active = 0;
+  for (const row of rows) {
+    const bucket = clientBucket(row);
+    if (bucket === 'deactive') deactive.push(row.email);
+    else if (bucket === 'depleted') depleted.push(row.email);
+    else if (bucket === 'expiring') expiring.push(row.email);
+    else if (bucket === 'active') active++;
+    if (row.enable && isOnline(row.email)) online.push(row.email);
+  }
+  return { total: rows.length, active, deactive, depleted, expiring, online };
+});
+
+function onAdd() {
+  formMode.value = 'add';
+  editingClient.value = null;
+  editingAttachedIds.value = [];
+  formOpen.value = true;
+}
+
+function onEdit(row) {
+  formMode.value = 'edit';
+  editingClient.value = { ...row };
+  editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
+  formOpen.value = true;
+}
+
+function onDelete(row) {
+  Modal.confirm({
+    title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
+    content: t('pages.clients.deleteConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await remove(row.email);
+      if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
+    },
+  });
+}
+
+function onResetTraffic(row) {
+  if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
+    message.warning(t('pages.clients.resetNotPossible'));
+    return;
+  }
+  Modal.confirm({
+    title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
+    content: t('pages.inbounds.resetTrafficContent'),
+    okText: t('reset'),
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await resetTraffic(row);
+      if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
+    },
+  });
+}
+
+function onShowInfo(row) {
+  infoClient.value = row;
+  infoOpen.value = true;
+}
+
+function onShowQr(row) {
+  qrClient.value = row;
+  qrOpen.value = true;
+}
+
+function onResetAllTraffics() {
+  Modal.confirm({
+    title: t('pages.clients.resetAllTrafficsTitle'),
+    content: t('pages.clients.resetAllTrafficsContent'),
+    okText: t('reset'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await resetAllTraffics();
+      if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
+    },
+  });
+}
+
+async function onSave(payload, meta) {
+  if (!meta?.isEdit) {
+    return create(payload);
+  }
+  const updateMsg = await update(meta.email, payload);
+  if (!updateMsg?.success) return updateMsg;
+  if (Array.isArray(meta.attach) && meta.attach.length > 0) {
+    const r = await attach(meta.email, meta.attach);
+    if (!r?.success) return r;
+  }
+  if (Array.isArray(meta.detach) && meta.detach.length > 0) {
+    const r = await detach(meta.email, meta.detach);
+    if (!r?.success) return r;
+  }
+  return updateMsg;
+}
+
+function trafficLabel(row) {
+  const t0 = row.traffic;
+  if (!t0) return '-';
+  const used = (t0.up || 0) + (t0.down || 0);
+  const total = row.totalGB || 0;
+  if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
+  return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
+}
+
+function remainingLabel(row) {
+  const total = row.totalGB || 0;
+  if (total <= 0) return '∞';
+  const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
+  const r = total - used;
+  return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
+}
+
+function remainingColor(row) {
+  const total = row.totalGB || 0;
+  if (total <= 0) return 'purple';
+  const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
+  const ratio = used / total;
+  if (ratio >= 1) return 'red';
+  if (ratio >= 0.85) return 'orange';
+  return 'green';
+}
+
+function expiryLabel(row) {
+  if (!row.expiryTime) return '∞';
+  if (row.expiryTime < 0) {
+    const days = Math.round(row.expiryTime / -86400000);
+    return `${t('pages.clients.delayedStart')}: ${days}d`;
+  }
+  return IntlUtil.formatDate(row.expiryTime);
+}
+
+function expiryRelative(row) {
+  if (!row.expiryTime) return '';
+  if (row.expiryTime < 0) {
+    const days = Math.round(row.expiryTime / -86400000);
+    return `${days}d`;
+  }
+  return IntlUtil.formatRelativeTime(row.expiryTime);
+}
+
+function expiryColor(row) {
+  if (!row.expiryTime) return 'purple';
+  if (row.expiryTime < 0) return 'blue';
+  const now = Date.now();
+  if (row.expiryTime <= now) return 'red';
+  if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
+  return 'green';
+}
+
+const sortState = ref({ column: null, order: null });
+const paginationState = ref({ current: 1, pageSize: 20 });
+
+function sortableCol(col, key) {
+  return {
+    ...col,
+    sorter: true,
+    showSorterTooltip: false,
+    sortOrder: sortState.value.column === key ? sortState.value.order : null,
+    sortDirections: ['ascend', 'descend'],
+  };
+}
+
+const sortFns = {
+  enable: (a, b) => Number(a.enable) - Number(b.enable),
+  email: (a, b) => (a.email || '').localeCompare(b.email || ''),
+  inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0),
+  traffic: (a, b) => {
+    const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0);
+    const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0);
+    return ua - ub;
+  },
+  remaining: (a, b) => {
+    const ra = a.totalGB > 0 ? a.totalGB - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity;
+    const rb = b.totalGB > 0 ? b.totalGB - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity;
+    return ra - rb;
+  },
+  expiryTime: (a, b) => {
+    const ea = a.expiryTime > 0 ? a.expiryTime : Infinity;
+    const eb = b.expiryTime > 0 ? b.expiryTime : Infinity;
+    return ea - eb;
+  },
+};
+
+const sortedClients = computed(() => {
+  const { column, order } = sortState.value;
+  const rows = filteredClients.value;
+  if (!column || !order) return rows;
+  const fn = sortFns[column];
+  if (!fn) return rows;
+  const sorted = [...rows].sort(fn);
+  return order === 'descend' ? sorted.reverse() : sorted;
+});
+
+function onTableChange(pag, _filters, sorter) {
+  if (pag) {
+    paginationState.value = {
+      current: pag.current || 1,
+      pageSize: pag.pageSize || paginationState.value.pageSize,
+    };
+  }
+  sortState.value = {
+    column: sorter?.columnKey || sorter?.field || null,
+    order: sorter?.order || null,
+  };
+}
+
+const tablePagination = computed(() => ({
+  current: paginationState.value.current,
+  pageSize: paginationState.value.pageSize,
+  total: sortedClients.value.length,
+  showSizeChanger: sortedClients.value.length > 10,
+  pageSizeOptions: ['10', '20', '50', '100'],
+  hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize,
+}));
+
+const columns = computed(() => [
+  { title: t('pages.clients.actions'), key: 'actions', width: 200 },
+  sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
+  { title: t('pages.clients.online'), key: 'online', width: 90 },
+  sortableCol({ title: t('pages.clients.client'), key: 'email' }, 'email'),
+  sortableCol({ title: t('pages.clients.attachedInbounds'), key: 'inboundIds' }, 'inboundIds'),
+  sortableCol({ title: t('pages.clients.traffic'), key: 'traffic' }, 'traffic'),
+  sortableCol({ title: t('pages.clients.remaining'), key: 'remaining', width: 130 }, 'remaining'),
+  sortableCol({ title: t('pages.clients.duration'), key: 'expiryTime' }, 'expiryTime'),
+]);
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="!fetched" :delay="200" :tip="t('loading')" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
+              <a-col :span="24">
+                <a-card size="small" hoverable class="summary-card">
+                  <a-row :gutter="[16, 12]">
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <CustomStatistic :title="t('clients')" :value="String(summary.total)">
+                        <template #prefix>
+                          <TeamOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('online')" :open="summary.online.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.online" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('online')" :value="String(summary.online.length)">
+                          <template #prefix>
+                            <span class="dot dot-blue" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('depleted')" :open="summary.depleted.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.depleted" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('depleted')" :value="String(summary.depleted.length)">
+                          <template #prefix>
+                            <span class="dot dot-red" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('depletingSoon')" :open="summary.expiring.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.expiring" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('depletingSoon')" :value="String(summary.expiring.length)">
+                          <template #prefix>
+                            <span class="dot dot-orange" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('disabled')" :open="summary.deactive.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.deactive" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('disabled')" :value="String(summary.deactive.length)">
+                          <template #prefix>
+                            <span class="dot dot-gray" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <CustomStatistic :title="t('subscription.active')" :value="String(summary.active)">
+                        <template #prefix>
+                          <span class="dot dot-green" />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <a-col :span="24">
+                <a-card size="small">
+                  <template #title>
+                    <div class="card-toolbar">
+                      <a-button type="primary" size="small" @click="onAdd">
+                        <template #icon>
+                          <PlusOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.addClients') }}</template>
+                      </a-button>
+                      <a-button size="small" @click="onBulkAdd">
+                        <template #icon>
+                          <UsergroupAddOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.bulk') }}</template>
+                      </a-button>
+                      <a-button v-if="selectedRowKeys.length > 0" danger size="small" @click="onBulkDelete">
+                        <template #icon>
+                          <DeleteOutlined />
+                        </template>
+                        {{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length }) }}
+                      </a-button>
+                      <a-button size="small" @click="onResetAllTraffics">
+                        <template #icon>
+                          <RetweetOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
+                      </a-button>
+                      <a-button size="small" danger @click="onDelDepleted">
+                        <template #icon>
+                          <RestOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.delDepleted') }}</template>
+                      </a-button>
+                    </div>
+                  </template>
+
+                  <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
+                    <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
+                      <template #checkedChildren>
+                        <SearchOutlined />
+                      </template>
+                      <template #unCheckedChildren>
+                        <FilterOutlined />
+                      </template>
+                    </a-switch>
+                    <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
+                      :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
+                    <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
+                      :size="isMobile ? 'small' : 'middle'">
+                      <a-radio-button value="">{{ t('none') }}</a-radio-button>
+                      <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
+                      <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
+                      <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
+                      <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
+                      <a-radio-button value="online">{{ t('online') }}</a-radio-button>
+                    </a-radio-group>
+                    <a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
+                      :size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
+                      <a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
+                        {{ protocol }}
+                      </a-select-option>
+                    </a-select>
+                  </div>
+
+                  <a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
+                    :row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
+                    <template #bodyCell="{ column, record }">
+                      <template v-if="column.key === 'email'">
+                        <div class="email-cell">
+                          <span class="email">{{ record.email }}</span>
+                          <span v-if="record.subId" class="sub" :title="record.subId">{{ record.subId }}</span>
+                        </div>
+                      </template>
+                      <template v-else-if="column.key === 'online'">
+                        <a-tag v-if="clientBucket(record) === 'depleted'" color="red">
+                          {{ t('depleted') }}
+                        </a-tag>
+                        <a-tag v-else-if="record.enable && isOnline(record.email)" color="green">
+                          {{ t('pages.clients.online') }}
+                        </a-tag>
+                        <a-tag v-else-if="!record.enable">{{ t('disabled') }}</a-tag>
+                        <a-tag v-else-if="clientBucket(record) === 'expiring'" color="orange">
+                          {{ t('depletingSoon') }}
+                        </a-tag>
+                        <a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
+                      </template>
+                      <template v-else-if="column.key === 'inboundIds'">
+                        <a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
+                          {{ inboundLabel(id) }}
+                        </a-tag>
+                        <span v-if="!record.inboundIds || record.inboundIds.length === 0"
+                          style="color: rgba(0,0,0,0.45)">—</span>
+                      </template>
+                      <template v-else-if="column.key === 'traffic'">
+                        {{ trafficLabel(record) }}
+                      </template>
+                      <template v-else-if="column.key === 'remaining'">
+                        <a-tag :color="remainingColor(record)">{{ remainingLabel(record) }}</a-tag>
+                      </template>
+                      <template v-else-if="column.key === 'expiryTime'">
+                        <a-tooltip :title="expiryLabel(record)">
+                          <a-tag :color="expiryColor(record)">
+                            {{ record.expiryTime ? expiryRelative(record) : '∞' }}
+                          </a-tag>
+                        </a-tooltip>
+                      </template>
+                      <template v-else-if="column.key === 'enable'">
+                        <a-switch :checked="record.enable" size="small" :loading="togglingEmail === record.email"
+                          @change="(next) => onToggleEnable(record, next)" />
+                      </template>
+                      <template v-else-if="column.key === 'actions'">
+                        <a-space :size="4">
+                          <a-tooltip :title="t('pages.clients.qrCode')">
+                            <a-button size="small" type="text" @click="onShowQr(record)">
+                              <QrcodeOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('pages.clients.moreInformation')">
+                            <a-button size="small" type="text" @click="onShowInfo(record)">
+                              <InfoCircleOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('pages.inbounds.resetTraffic')">
+                            <a-button size="small" type="text" @click="onResetTraffic(record)">
+                              <RetweetOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('edit')">
+                            <a-button size="small" type="text" @click="onEdit(record)">
+                              <EditOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('delete')">
+                            <a-button size="small" type="text" danger @click="onDelete(record)">
+                              <DeleteOutlined />
+                            </a-button>
+                          </a-tooltip>
+                        </a-space>
+                      </template>
+                    </template>
+
+                    <template #emptyText>
+                      <div class="clients-empty">
+                        <UserOutlined style="font-size: 32px; margin-bottom: 8px" />
+                        <div>{{ t('pages.clients.empty') }}</div>
+                      </div>
+                    </template>
+                  </a-table>
+
+                  <a-spin v-else :spinning="loading">
+                    <div class="client-cards">
+                      <div v-if="filteredClients.length > 0" class="card-bulk-bar">
+                        <a-checkbox :checked="allSelected" :indeterminate="someSelected"
+                          @change="(e) => selectAll(e.target.checked)">
+                          {{ t('pages.clients.selectAll') }}
+                        </a-checkbox>
+                        <span v-if="selectedRowKeys.length > 0" class="bulk-count">
+                          {{ selectedRowKeys.length }}
+                        </span>
+                      </div>
+
+                      <div v-if="filteredClients.length === 0" class="card-empty">
+                        <UserOutlined style="font-size: 28px; opacity: 0.5" />
+                        <div>{{ t('pages.clients.empty') }}</div>
+                      </div>
+
+                      <div v-for="row in filteredClients" :key="row.email" class="client-card"
+                        :class="{ 'is-selected': isSelected(row.email) }">
+                        <div class="card-head">
+                          <a-checkbox :checked="isSelected(row.email)"
+                            @change="(e) => toggleSelect(row.email, e.target.checked)" />
+                          <a-badge :color="bucketTagColor(clientBucket(row))" />
+                          <span class="tag-name">{{ row.email }}</span>
+                          <a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
+                            {{ t('depleted') }}
+                          </a-tag>
+                          <a-tag v-else-if="clientBucket(row) === 'expiring'" color="orange" class="status-tag">
+                            {{ t('depletingSoon') }}
+                          </a-tag>
+                          <div class="card-actions" @click.stop>
+                            <a-tooltip :title="t('pages.clients.moreInformation')">
+                              <InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
+                            </a-tooltip>
+                            <a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
+                              @change="(next) => onToggleEnable(row, next)" />
+                            <a-dropdown :trigger="['click']" placement="bottomRight">
+                              <MoreOutlined class="row-action-trigger" @click.prevent />
+                              <template #overlay>
+                                <a-menu>
+                                  <a-menu-item key="qr" @click="onShowQr(row)">
+                                    <QrcodeOutlined /> {{ t('pages.clients.qrCode') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="reset" @click="onResetTraffic(row)">
+                                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="edit" @click="onEdit(row)">
+                                    <EditOutlined /> {{ t('edit') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="delete" class="danger-item" @click="onDelete(row)">
+                                    <DeleteOutlined /> {{ t('delete') }}
+                                  </a-menu-item>
+                                </a-menu>
+                              </template>
+                            </a-dropdown>
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  </a-spin>
+                </a-card>
+              </a-col>
+            </a-row>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
+        :attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
+        :tg-bot-enable="tgBotEnable" :save="onSave" />
+      <ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
+        :is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
+      <ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
+      <ClientBulkAddModal v-model:open="bulkAddOpen" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
+        @saved="onBulkAddSaved" />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.clients-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.clients-page.is-dark {
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
+}
+
+.clients-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.clients-page :deep(.ant-layout),
+.clients-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.filter-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.filter-bar.mobile {
+  gap: 6px;
+  margin-bottom: 8px;
+}
+
+.filter-bar.mobile > * {
+  flex: 0 0 auto;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 8px;
+  }
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.summary-card {
+  padding: 16px;
+}
+
+@media (max-width: 768px) {
+  .summary-card {
+    padding: 8px;
+  }
+}
+
+.dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: 4px;
+  vertical-align: middle;
+}
+
+.dot-green { background: #52c41a; }
+.dot-blue { background: #1677ff; }
+.dot-red { background: #ff4d4f; }
+.dot-orange { background: #fa8c16; }
+.dot-gray { background: rgba(128, 128, 128, 0.6); }
+
+.status-tag {
+  margin: 0 0 0 4px;
+  font-size: 11px;
+  padding: 0 6px;
+  line-height: 18px;
+}
+
+.card-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.card-title {
+  font-weight: 600;
+  margin-right: 4px;
+}
+
+.email-cell {
+  display: flex;
+  flex-direction: column;
+}
+
+.email {
+  font-weight: 500;
+}
+
+.sub {
+  font-size: 11px;
+  opacity: 0.55;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 220px;
+}
+
+.client-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-top: 4px;
+}
+
+.card-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 4px 8px;
+}
+
+.bulk-count {
+  font-size: 12px;
+  background: rgba(22, 119, 255, 0.12);
+  color: var(--ant-color-primary, #1677ff);
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
+.client-card {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 10px;
+  padding: 10px 12px;
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.client-card.is-selected {
+  border-color: var(--ant-color-primary, #1677ff);
+  background: rgba(22, 119, 255, 0.06);
+}
+
+:global(body.dark) .client-card {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+.card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  user-select: none;
+}
+
+.card-head .tag-name {
+  font-weight: 600;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.row-action-trigger {
+  font-size: 18px;
+  cursor: pointer;
+  opacity: 0.75;
+  transition: opacity 120ms ease;
+}
+
+.row-action-trigger:hover {
+  opacity: 1;
+}
+
+.card-empty {
+  text-align: center;
+  padding: 40px 16px;
+  opacity: 0.55;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.clients-empty {
+  padding: 32px 0;
+  text-align: center;
+  opacity: 0.55;
+}
+
+.danger-item {
+  color: #ff4d4f;
+}
+</style>
+
+<style>
+/* AD-Vue popovers teleport their content to <body>, so scoped styles
+   don't reach them — this block has to be unscoped. */
+.client-email-list {
+  max-height: 280px;
+  min-width: 160px;
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.client-email-list > div {
+  padding: 2px 0;
+  font-size: 12px;
+  white-space: nowrap;
+}
+</style>

+ 217 - 0
frontend/src/pages/clients/useClients.js

@@ -0,0 +1,217 @@
+import { onMounted, ref, shallowRef } from 'vue';
+import { HttpUtil } from '@/utils';
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
+
+export function useClients() {
+  const clients = shallowRef([]);
+  const inbounds = shallowRef([]);
+  const onlines = ref([]);
+  const loading = ref(false);
+  const fetched = ref(false);
+  const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
+  const ipLimitEnable = ref(false);
+  const tgBotEnable = ref(false);
+  const expireDiff = ref(0);
+  const trafficDiff = ref(0);
+
+  async function refresh() {
+    loading.value = true;
+    try {
+      const [clientsMsg, inboundsMsg] = await Promise.all([
+        HttpUtil.get('/panel/api/clients/list'),
+        HttpUtil.get('/panel/api/inbounds/options'),
+      ]);
+      if (clientsMsg?.success) {
+        clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
+      }
+      if (inboundsMsg?.success) {
+        inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
+      }
+      fetched.value = true;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  async function fetchSubSettings() {
+    const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+    if (!msg?.success) return;
+    const s = msg.obj || {};
+    subSettings.value = {
+      enable: !!s.subEnable,
+      subURI: s.subURI || '',
+      subJsonURI: s.subJsonURI || '',
+      subJsonEnable: !!s.subJsonEnable,
+    };
+    ipLimitEnable.value = !!s.ipLimitEnable;
+    tgBotEnable.value = !!s.tgBotEnable;
+    expireDiff.value = (s.expireDiff ?? 0) * 86400000;
+    trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
+  }
+
+  async function create(payload) {
+    const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function update(email, client) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function remove(email, keepTraffic = false) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const url = keepTraffic
+      ? `/panel/api/clients/del/${encoded}?keepTraffic=1`
+      : `/panel/api/clients/del/${encoded}`;
+    const msg = await HttpUtil.post(url);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function removeMany(emails, keepTraffic = false) {
+    if (!Array.isArray(emails) || emails.length === 0) return [];
+    const suffix = keepTraffic ? '?keepTraffic=1' : '';
+    const silentOpts = { silent: true };
+    const results = await Promise.all(emails.map((email) => {
+      const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
+      return HttpUtil.post(url, undefined, silentOpts);
+    }));
+    await refresh();
+    return results;
+  }
+
+  async function attach(email, inboundIds) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function detach(email, inboundIds) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function resetTraffic(client) {
+    if (!client?.email) return null;
+    const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
+    const msg = await HttpUtil.post(url);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function resetAllTraffics() {
+    const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function delDepleted() {
+    const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function setEnable(client, enable) {
+    if (!client?.email) return null;
+    const payload = {
+      email: client.email,
+      subId: client.subId,
+      id: client.uuid,
+      password: client.password,
+      auth: client.auth,
+      totalGB: client.totalGB || 0,
+      expiryTime: client.expiryTime || 0,
+      limitIp: client.limitIp || 0,
+      comment: client.comment || '',
+      enable: !!enable,
+    };
+    return update(client.email, payload);
+  }
+
+  function applyTrafficEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (Array.isArray(payload.onlineClients)) {
+      onlines.value = payload.onlineClients;
+    }
+  }
+
+  function applyClientStatsEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
+    const byEmail = new Map();
+    for (const row of payload.clients) {
+      if (row && row.email) byEmail.set(row.email, row);
+    }
+    let touched = false;
+    const next = clients.value || [];
+    for (let i = 0; i < next.length; i++) {
+      const row = next[i];
+      const upd = byEmail.get(row?.email);
+      if (!upd) continue;
+      const merged = { ...(row.traffic || {}) };
+      if (typeof upd.up === 'number') merged.up = upd.up;
+      if (typeof upd.down === 'number') merged.down = upd.down;
+      if (typeof upd.total === 'number') merged.total = upd.total;
+      if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
+      if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
+      if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
+      next[i] = { ...row, traffic: merged };
+      touched = true;
+    }
+    if (touched) clients.value = [...next];
+  }
+
+  let invalidateTimer = null;
+  function applyInvalidate(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
+    if (invalidateTimer) clearTimeout(invalidateTimer);
+    invalidateTimer = setTimeout(() => {
+      invalidateTimer = null;
+      refresh();
+    }, 200);
+  }
+
+  onMounted(async () => {
+    await Promise.all([refresh(), fetchSubSettings()]);
+  });
+
+  return {
+    clients,
+    inbounds,
+    onlines,
+    loading,
+    fetched,
+    subSettings,
+    ipLimitEnable,
+    tgBotEnable,
+    expireDiff,
+    trafficDiff,
+    refresh,
+    create,
+    update,
+    remove,
+    removeMany,
+    attach,
+    detach,
+    resetTraffic,
+    resetAllTraffics,
+    delDepleted,
+    setEnable,
+    applyTrafficEvent,
+    applyClientStatsEvent,
+    applyInvalidate,
+  };
+}

+ 0 - 280
frontend/src/pages/inbounds/ClientBulkModal.vue

@@ -1,280 +0,0 @@
-<script setup>
-import { computed, reactive, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import dayjs from 'dayjs';
-import { SyncOutlined } from '@ant-design/icons-vue';
-
-import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
-
-const { t } = useI18n();
-import {
-  Inbound,
-  Protocols,
-  USERS_SECURITY,
-  TLS_FLOW_CONTROL,
-} from '@/models/inbound.js';
-import DateTimePicker from '@/components/DateTimePicker.vue';
-
-// Bulk-add up to 500 clients in one go. The legacy panel offers five
-// generation modes — this component preserves them all:
-//   0: Random         — N fully-random emails (no prefix)
-//   1: Random+Prefix  — N random emails preceded by `prefix`
-//   2: Random+Prefix+Num     — emails like `<rand><prefix><num>` for num in [first..last]
-//   3: Random+Prefix+Num+Postfix — same + appended postfix
-//   4: Prefix+Num+Postfix    — no random part, just `<prefix><num><postfix>`
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  dbInbound: { type: Object, default: null },
-  subEnable: { type: Boolean, default: false },
-  tgBotEnable: { type: Boolean, default: false },
-  ipLimitEnable: { type: Boolean, default: false },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-// === Reactive form state ===========================================
-// Cloned inbound (so canEnableTlsFlow() works).
-const inbound = ref(null);
-const saving = ref(false);
-const delayedStart = ref(false);
-
-const form = reactive({
-  emailMethod: 0,
-  firstNum: 1,
-  lastNum: 1,
-  emailPrefix: '',
-  emailPostfix: '',
-  quantity: 1,
-  security: USERS_SECURITY.AUTO,
-  flow: '',
-  subId: '',
-  tgId: 0,
-  comment: '',
-  limitIp: 0,
-  totalGB: 0,
-  expiryTime: 0, // ms epoch; negative => delayed start days
-  reset: 0,
-});
-
-const expiryDate = computed({
-  get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
-  set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
-});
-
-const delayedExpireDays = computed({
-  get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
-  set: (days) => { form.expiryTime = -86400000 * (days || 0); },
-});
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  if (!props.dbInbound) return;
-  inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
-  // Reset all form fields on every open — bulk add is intentionally
-  // stateless between sessions (legacy resets on .show()).
-  form.emailMethod = 0;
-  form.firstNum = 1;
-  form.lastNum = 1;
-  form.emailPrefix = '';
-  form.emailPostfix = '';
-  form.quantity = 1;
-  form.security = USERS_SECURITY.AUTO;
-  form.flow = '';
-  form.subId = '';
-  form.tgId = 0;
-  form.comment = '';
-  form.limitIp = 0;
-  form.totalGB = 0;
-  form.expiryTime = 0;
-  form.reset = 0;
-  delayedStart.value = false;
-});
-
-function close() {
-  emit('update:open', false);
-}
-
-function makeNewClient(parsed) {
-  switch (parsed.protocol) {
-    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
-    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
-    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
-    case Protocols.SHADOWSOCKS: {
-      const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
-      return new Inbound.ShadowsocksSettings.Shadowsocks(method);
-    }
-    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
-    default: return null;
-  }
-}
-
-function buildClients() {
-  if (!inbound.value) return [];
-  const out = [];
-  const method = form.emailMethod;
-  let start;
-  let end;
-  if (method > 1) {
-    start = form.firstNum;
-    end = form.lastNum + 1;
-  } else {
-    start = 0;
-    end = form.quantity;
-  }
-  const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
-  const useNum = method > 1;
-  const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
-
-  for (let i = start; i < end; i++) {
-    const c = makeNewClient(inbound.value);
-    if (!c) continue;
-    if (method === 4) c.email = '';
-    c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
-
-    if (form.subId.length > 0) c.subId = form.subId;
-    c.tgId = form.tgId;
-    if (form.comment.length > 0) c.comment = form.comment;
-    c.security = form.security;
-    c.limitIp = form.limitIp;
-    // Use the clien's totalGB setter (ms epoch and bytes already handled
-    // identically for bulk and single client paths).
-    c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
-    c.expiryTime = form.expiryTime;
-    if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
-    c.reset = form.reset;
-    out.push(c);
-  }
-  return out;
-}
-
-async function submit() {
-  const clients = buildClients();
-  if (clients.length === 0) return;
-
-  saving.value = true;
-  try {
-    const payload = {
-      id: props.dbInbound.id,
-      // Clients all serialize via toString() — same shape the single-
-      // client modal posts. Joining with `,` lets the Go side parse the
-      // outer array directly.
-      settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
-    };
-    const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
-    if (msg?.success) {
-      emit('saved');
-      close();
-    }
-  } finally {
-    saving.value = false;
-  }
-}
-</script>
-
-<template>
-  <a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
-    <a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
-      <a-form-item :label="t('pages.client.method')">
-        <a-select v-model:value="form.emailMethod">
-          <a-select-option :value="0">Random</a-select-option>
-          <a-select-option :value="1">Random + Prefix</a-select-option>
-          <a-select-option :value="2">Random + Prefix + Num</a-select-option>
-          <a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
-          <a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
-        <a-input-number v-model:value="form.firstNum" :min="1" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
-        <a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
-        <a-input v-model:value="form.emailPrefix" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
-        <a-input v-model:value="form.emailPostfix" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
-        <a-input-number v-model:value="form.quantity" :min="1" :max="500" />
-      </a-form-item>
-
-      <a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
-        <a-select v-model:value="form.security">
-          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
-        <a-select v-model:value="form.flow">
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="subEnable">
-        <template #label>
-          {{ t('subscription.title') }}
-          <SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
-        </template>
-        <a-input v-model:value="form.subId" />
-      </a-form-item>
-
-      <a-form-item v-if="tgBotEnable" label="Telegram ID">
-        <a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
-      </a-form-item>
-
-      <a-form-item :label="t('comment')">
-        <a-input v-model:value="form.comment" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
-        <a-input-number v-model:value="form.limitIp" :min="0" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
-      </a-form-item>
-
-      <a-form-item :label="t('pages.client.delayedStart')">
-        <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
-      </a-form-item>
-
-      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
-        <a-input-number v-model:value="delayedExpireDays" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-else>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
-          }}</a-tooltip>
-        </template>
-        <DateTimePicker v-model:value="expiryDate" />
-      </a-form-item>
-
-      <a-form-item v-if="form.expiryTime !== 0">
-        <template #label>
-          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="form.reset" :min="0" />
-      </a-form-item>
-    </a-form>
-  </a-modal>
-</template>
-
-<style scoped>
-.random-icon {
-  margin-left: 4px;
-  cursor: pointer;
-  color: var(--ant-primary-color, #1890ff);
-}
-</style>

+ 0 - 394
frontend/src/pages/inbounds/ClientFormModal.vue

@@ -1,394 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import dayjs from 'dayjs';
-import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
-
-import {
-  HttpUtil,
-  RandomUtil,
-  SizeFormatter,
-  ColorUtils,
-} from '@/utils';
-import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
-import DateTimePicker from '@/components/DateTimePicker.vue';
-
-const { t } = useI18n();
-
-// Add OR edit a single client on a multi-user inbound (VMess / VLess /
-// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
-// flows through the same modal — same here.
-//
-// On submit we serialize the client via its toString() (which is just
-// JSON.stringify of toJson()) and post it inside a one-element clients
-// array so the Go side reuses the same parsing path as the inbound
-// settings update.
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
-  dbInbound: { type: Object, default: null },
-  clientIndex: { type: Number, default: null },
-  // Sidecar config from the inbounds page — controls visibility of
-  // the Subscription, Telegram, and IP-limit fields.
-  subEnable: { type: Boolean, default: false },
-  tgBotEnable: { type: Boolean, default: false },
-  ipLimitEnable: { type: Boolean, default: false },
-  trafficDiff: { type: Number, default: 0 },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-// === Reactive draft =================================================
-const inbound = ref(null);
-const client = ref(null);
-const oldClientId = ref('');
-const clientStats = ref(null);
-
-const saving = ref(false);
-const delayedStart = ref(false);
-
-const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-const protocol = computed(() => inbound.value?.protocol);
-const isVmessOrVless = computed(() =>
-  protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
-);
-const isTrojanOrSS = computed(() =>
-  protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
-);
-
-const expiryDate = computed({
-  get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
-  set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
-});
-
-const delayedExpireDays = computed({
-  get: () => {
-    if (!client.value || client.value.expiryTime >= 0) return 0;
-    return client.value.expiryTime / -86400000;
-  },
-  set: (days) => {
-    if (!client.value) return;
-    client.value.expiryTime = -86400000 * (days || 0);
-  },
-});
-
-const totalGB = computed({
-  get: () => {
-    if (!client.value || !client.value.totalGB) return 0;
-    return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
-  },
-  set: (gb) => {
-    if (!client.value) return;
-    client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
-  },
-});
-
-const isExpired = computed(() => {
-  if (props.mode !== 'edit' || !client.value) return false;
-  return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
-});
-const isTrafficExhausted = computed(() => {
-  if (!clientStats.value || clientStats.value.total <= 0) return false;
-  return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
-});
-
-function getClientId(proto, c) {
-  switch (proto) {
-    case Protocols.TROJAN: return c.password;
-    case Protocols.SHADOWSOCKS: return c.email;
-    case Protocols.HYSTERIA: return c.auth;
-    default: return c.id;
-  }
-}
-
-function makeNewClient(proto, parsed) {
-  switch (proto) {
-    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
-    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
-    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
-    case Protocols.SHADOWSOCKS: {
-      const method = parsed.settings.method;
-      return new Inbound.ShadowsocksSettings.Shadowsocks(
-        method,
-        RandomUtil.randomShadowsocksPassword(method),
-      );
-    }
-    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
-    default: return null;
-  }
-}
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  if (!props.dbInbound) return;
-  const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
-  inbound.value = parsed;
-  delayedStart.value = false;
-
-  if (props.mode === 'edit') {
-    const idx = props.clientIndex ?? 0;
-    client.value = parsed.clients[idx];
-    if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
-    oldClientId.value = getClientId(parsed.protocol, client.value);
-  } else {
-    const c = makeNewClient(parsed.protocol, parsed);
-    if (c) parsed.clients.push(c);
-    client.value = parsed.clients[parsed.clients.length - 1];
-    oldClientId.value = '';
-  }
-
-  clientStats.value = (props.dbInbound.clientStats || []).find(
-    (s) => s.email === client.value?.email,
-  ) || null;
-});
-
-function close() {
-  emit('update:open', false);
-}
-
-function randomEmail() {
-  if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
-}
-function randomId() {
-  if (client.value) client.value.id = RandomUtil.randomUUID();
-}
-function randomPassword() {
-  if (!client.value || !inbound.value) return;
-  if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
-    client.value.password = RandomUtil.randomShadowsocksPassword(
-      inbound.value.settings.method,
-    );
-  } else {
-    client.value.password = RandomUtil.randomSeq(10);
-  }
-}
-function randomAuth() {
-  if (client.value) client.value.auth = RandomUtil.randomSeq(10);
-}
-function randomSubId() {
-  if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
-}
-
-const clientIpsText = ref('');
-async function loadClientIps() {
-  if (!client.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
-  if (!msg?.success) {
-    clientIpsText.value = msg?.obj || '';
-    return;
-  }
-  let ips = msg.obj;
-  if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
-    try {
-      const parsed = JSON.parse(ips);
-      ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
-    } catch (_e) {
-      // leave as raw
-    }
-  }
-  clientIpsText.value = ips || '';
-}
-async function clearClientIps() {
-  if (!client.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
-  if (msg?.success) clientIpsText.value = '';
-}
-
-async function resetClientTraffic() {
-  if (!clientStats.value || !client.value?.email) return;
-  const msg = await HttpUtil.post(
-    `/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
-  );
-  if (msg?.success) {
-    clientStats.value.up = 0;
-    clientStats.value.down = 0;
-  }
-}
-
-async function submit() {
-  if (!client.value || !inbound.value) return;
-  saving.value = true;
-  try {
-    const payload = {
-      id: props.dbInbound.id,
-      settings: `{"clients": [${client.value.toString()}]}`,
-    };
-    const url = props.mode === 'edit'
-      ? `/panel/api/inbounds/updateClient/${oldClientId.value}`
-      : '/panel/api/inbounds/addClient';
-    const msg = await HttpUtil.post(url, payload);
-    if (msg?.success) {
-      emit('saved');
-      close();
-    }
-  } finally {
-    saving.value = false;
-  }
-}
-
-const title = computed(() =>
-  props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
-);
-</script>
-
-<template>
-  <a-modal :open="open" :title="title"
-    :ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
-    <a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
-      {{ t('depleted') }}
-    </a-tag>
-
-    <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
-      :wrapper-col="{ sm: { span: 14 } }">
-      <a-form-item :label="t('enable')">
-        <a-switch v-model:checked="client.enable" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          {{ t('pages.inbounds.email') }}
-          <SyncOutlined class="random-icon" @click="randomEmail" />
-        </template>
-        <a-input v-model:value="client.email" />
-      </a-form-item>
-
-      <a-form-item v-if="isTrojanOrSS">
-        <template #label>
-          {{ t('password') }}
-          <SyncOutlined class="random-icon" @click="randomPassword" />
-        </template>
-        <a-input v-model:value="client.password" />
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.HYSTERIA">
-        <template #label>
-          {{ t('password') }}
-          <SyncOutlined class="random-icon" @click="randomAuth" />
-        </template>
-        <a-input v-model:value="client.auth" />
-      </a-form-item>
-
-      <a-form-item v-if="isVmessOrVless">
-        <template #label>
-          ID
-          <SyncOutlined class="random-icon" @click="randomId" />
-        </template>
-        <a-input v-model:value="client.id" />
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
-        <a-select v-model:value="client.security">
-          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
-            {{ key }}
-          </a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="client.email && subEnable">
-        <template #label>
-          {{ t('subscription.title') }}
-          <SyncOutlined class="random-icon" @click="randomSubId" />
-        </template>
-        <a-input v-model:value="client.subId" />
-      </a-form-item>
-
-      <a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
-        <a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
-      </a-form-item>
-
-      <a-form-item v-if="client.email" :label="t('comment')">
-        <a-input v-model:value="client.comment" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
-        <a-input-number v-model:value="client.limitIp" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
-        :label="t('pages.inbounds.IPLimitlog')">
-        <a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
-          :auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
-        <a-button type="link" size="small" danger @click="clearClientIps">
-          <template #icon>
-            <DeleteOutlined />
-          </template>
-          {{ t('pages.inbounds.IPLimitlogclear') }}
-        </a-button>
-      </a-form-item>
-
-      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
-        <a-select v-model:value="client.flow">
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
-            {{ key }}
-          </a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
-        <a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
-      </a-form-item>
-
-      <a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
-        <a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
-          {{ SizeFormatter.sizeFormat(clientStats.up) }} /
-          {{ SizeFormatter.sizeFormat(clientStats.down) }}
-          ({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
-        </a-tag>
-        <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
-          <RetweetOutlined class="action-icon" @click="resetClientTraffic" />
-        </a-tooltip>
-      </a-form-item>
-
-      <a-form-item :label="t('pages.client.delayedStart')">
-        <a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
-      </a-form-item>
-
-      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
-        <a-input-number v-model:value="delayedExpireDays" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-else>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
-          }}</a-tooltip>
-        </template>
-        <DateTimePicker v-model:value="expiryDate" />
-        <a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
-      </a-form-item>
-
-      <a-form-item v-if="client.expiryTime !== 0">
-        <template #label>
-          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="client.reset" :min="0" />
-      </a-form-item>
-    </a-form>
-  </a-modal>
-</template>
-
-<style scoped>
-.status-banner {
-  display: block;
-  margin-bottom: 10px;
-  text-align: center;
-}
-
-.random-icon,
-.action-icon {
-  margin-left: 4px;
-  cursor: pointer;
-  color: var(--ant-primary-color, #1890ff);
-}
-</style>

+ 0 - 841
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -1,841 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import {
-  EditOutlined,
-  InfoCircleOutlined,
-  QrcodeOutlined,
-  RetweetOutlined,
-  DeleteOutlined,
-  EllipsisOutlined,
-} from '@ant-design/icons-vue';
-import { Modal } from 'ant-design-vue';
-
-import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
-import InfinityIcon from '@/components/InfinityIcon.vue';
-import { useDatepicker } from '@/composables/useDatepicker.js';
-
-const { datepicker } = useDatepicker();
-
-const { t } = useI18n();
-
-// Per-inbound expand-row content. CSS-grid layout (not a nested
-// <a-table>) so it sits flush inside the parent's expanded cell.
-// No API calls here — events bubble to the parent's modals.
-
-const props = defineProps({
-  dbInbound: { type: Object, required: true },
-  isMobile: { type: Boolean, default: false },
-  trafficDiff: { type: Number, default: 0 },
-  expireDiff: { type: Number, default: 0 },
-  onlineClients: { type: Array, default: () => [] },
-  lastOnlineMap: { type: Object, default: () => ({}) },
-  isDarkTheme: { type: Boolean, default: false },
-  pageSize: { type: Number, default: 0 },
-  totalClientCount: { type: Number, default: 0 },
-  statsVersion: { type: Number, default: 0 },
-});
-
-const emit = defineEmits([
-  'edit-client',
-  'qrcode-client',
-  'info-client',
-  'reset-traffic-client',
-  'delete-client',
-  'delete-clients',
-  'toggle-enable-client',
-]);
-
-const inbound = computed(() => props.dbInbound.toInbound());
-const clients = computed(() => inbound.value?.clients || []);
-
-const currentPage = ref(1);
-const paginatedClients = computed(() => {
-  if (!props.pageSize || props.pageSize <= 0) return clients.value;
-  const start = (currentPage.value - 1) * props.pageSize;
-  return clients.value.slice(start, start + props.pageSize);
-});
-
-watch([clients, () => props.pageSize], () => {
-  const total = clients.value.length;
-  const size = props.pageSize > 0 ? props.pageSize : (total || 1);
-  const maxPage = Math.max(1, Math.ceil(total / size));
-  if (currentPage.value > maxPage) currentPage.value = maxPage;
-});
-
-// === Per-client stats lookup =======================================
-// statsVersion bumps on every ws merge so this computed re-evaluates
-// (DBInbound isn't reactive — the in-place stat mutations alone don't
-// trigger Vue's tracking).
-const statsMap = computed(() => {
-  void props.statsVersion;
-  const m = new Map();
-  for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
-  return m;
-});
-function statsFor(email) {
-  return email ? statsMap.value.get(email) : null;
-}
-
-function getUp(email) { return statsFor(email)?.up || 0; }
-function getDown(email) { return statsFor(email)?.down || 0; }
-function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
-function getRem(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  const r = s.total - s.up - s.down;
-  return r > 0 ? r : 0;
-}
-function getAllTime(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  // allTime is the cumulative-historical counter; never let it dip
-  // below up+down (manual edits / partial migrations can push it under).
-  const current = (s.up || 0) + (s.down || 0);
-  return s.allTime > current ? s.allTime : current;
-}
-function isClientDepleted(email) {
-  const s = statsFor(email);
-  if (!s) return false;
-  const total = s.total ?? 0;
-  const used = (s.up ?? 0) + (s.down ?? 0);
-  if (total > 0 && used >= total) return true;
-  const exp = s.expiryTime ?? 0;
-  if (exp > 0 && Date.now() >= exp) return true;
-  return false;
-}
-function isClientOnline(email) {
-  return !!email && props.onlineClients.includes(email);
-}
-function lastOnlineLabel(email) {
-  const ts = props.lastOnlineMap[email];
-  if (!ts) return '-';
-  return IntlUtil.formatDate(ts, datepicker.value);
-}
-
-function statsProgress(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  if (s.total === 0) return 100;
-  return (100 * (s.down + s.up)) / s.total;
-}
-function expireProgress(expTime, reset) {
-  const now = Date.now();
-  const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
-  const resetSec = reset * 86400;
-  if (remainedSec >= resetSec) return 0;
-  return 100 * (1 - remainedSec / resetSec);
-}
-function clientStatsColor(email) {
-  return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
-}
-function statsExpColor(email) {
-  // AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
-  // matches the row's traffic/expiry tags.
-  const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
-  if (!email) return PURPLE;
-  const s = statsFor(email);
-  if (!s) return PURPLE;
-  const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
-  const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
-  if (a === 'red' || b === 'red') return DANGER;
-  if (a === 'orange' || b === 'orange') return WARN;
-  if (a === 'green' || b === 'green') return SUCCESS;
-  return PURPLE;
-}
-
-const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
-
-function totalGbDisplay(client) {
-  if (!client.totalGB || client.totalGB <= 0) return '';
-  return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
-}
-
-const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
-
-function statusBadgeColor(client) {
-  if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
-  return statsExpColor(client.email);
-}
-
-// === Action confirms ==============================================
-function confirmReset(client) {
-  Modal.confirm({
-    title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
-    content: t('pages.inbounds.resetTrafficContent'),
-    okText: t('reset'),
-    cancelText: t('cancel'),
-    onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
-  });
-}
-function confirmDelete(client) {
-  Modal.confirm({
-    title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
-    content: t('pages.inbounds.deleteClientContent'),
-    okText: t('delete'),
-    okType: 'danger',
-    cancelText: t('cancel'),
-    onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
-  });
-}
-
-// Stable row key for v-for — falls back through email/id/password
-// because not every protocol fills the same field.
-function rowKey(client) {
-  return client.email || client.id || client.password || JSON.stringify(client);
-}
-
-const selected = ref(new Set());
-
-const allSelected = computed(() =>
-  clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
-);
-const someSelected = computed(() =>
-  clients.value.some((c) => selected.value.has(rowKey(c))),
-);
-const selectedCount = computed(() => selected.value.size);
-
-function isSelected(key) {
-  return selected.value.has(key);
-}
-function toggleSelect(key, next) {
-  const s = new Set(selected.value);
-  if (next) s.add(key); else s.delete(key);
-  selected.value = s;
-}
-function selectAll(next) {
-  if (next) {
-    selected.value = new Set(clients.value.map(rowKey));
-  } else {
-    selected.value = new Set();
-  }
-}
-function clearSelection() {
-  selected.value = new Set();
-}
-
-watch(clients, (list) => {
-  if (selected.value.size === 0) return;
-  const valid = new Set(list.map(rowKey));
-  const next = new Set();
-  for (const k of selected.value) if (valid.has(k)) next.add(k);
-  if (next.size !== selected.value.size) selected.value = next;
-});
-
-const statsClient = ref(null);
-function openStats(client) {
-  statsClient.value = client;
-}
-function closeStats() {
-  statsClient.value = null;
-}
-
-function confirmBulkDelete() {
-  const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
-  if (picked.length === 0) return;
-
-  const total = clients.value.length;
-  const keepLast = picked.length === total;
-  const toDelete = keepLast ? picked.slice(0, -1) : picked;
-
-  if (toDelete.length === 0) {
-    Modal.warning({
-      title: t('pages.inbounds.deleteClient'),
-      content: 'Inbound must keep at least one client — delete the inbound to remove all.',
-      okText: t('confirm'),
-    });
-    return;
-  }
-
-  Modal.confirm({
-    title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
-    content: keepLast
-      ? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
-      : t('pages.inbounds.deleteClientContent'),
-    okText: t('delete'),
-    okType: 'danger',
-    cancelText: t('cancel'),
-    onOk: () => {
-      emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
-      clearSelection();
-    },
-  });
-}
-</script>
-
-<template>
-  <div class="client-list"
-    :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
-    <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
-      <span class="bulk-count">{{ selectedCount }} selected</span>
-      <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
-      <a-button size="small" danger @click="confirmBulkDelete">
-        <DeleteOutlined /> {{ t('delete') }}
-      </a-button>
-    </div>
-
-    <!-- ====================== Desktop: grid table ===================== -->
-    <template v-if="!isMobile">
-      <div class="client-row client-list-header">
-        <div v-if="isRemovable" class="cell cell-select">
-          <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
-            @change="(e) => selectAll(e.target.checked)" />
-        </div>
-        <div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
-        <div class="cell cell-enable">{{ t('enable') }}</div>
-        <div class="cell cell-online">{{ t('online') }}</div>
-        <div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
-        <div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
-        <div class="cell cell-remained">{{ t('remained') }}</div>
-        <div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
-        <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
-      </div>
-
-      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
-        :class="{ 'is-selected': isSelected(rowKey(client)) }">
-        <div v-if="isRemovable" class="cell cell-select">
-          <a-checkbox :checked="isSelected(rowKey(client))"
-            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
-        </div>
-        <div class="cell cell-actions">
-          <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
-            <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip :title="t('edit')">
-            <EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip :title="t('info')">
-            <InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
-            <RetweetOutlined class="row-icon" @click="confirmReset(client)" />
-          </a-tooltip>
-          <a-tooltip v-if="isRemovable" :title="t('delete')">
-            <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
-          </a-tooltip>
-        </div>
-
-        <div class="cell cell-enable">
-          <a-switch :checked="client.enable" size="small"
-            @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
-        </div>
-
-        <div class="cell cell-online">
-          <a-popover>
-            <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
-            <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
-            <a-tag v-else>{{ t('offline') }}</a-tag>
-          </a-popover>
-        </div>
-
-        <div class="cell cell-client">
-          <a-tooltip>
-            <template #title>
-              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
-              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
-              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
-              <template v-else>{{ t('offline') }}</template>
-            </template>
-            <a-badge :color="statusBadgeColor(client)" />
-          </a-tooltip>
-          <div class="client-id-stack">
-            <a-tooltip :title="client.email">
-              <span class="client-email">{{ client.email }}</span>
-            </a-tooltip>
-            <span v-if="client.comment && client.comment.trim()" class="client-comment">
-              {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
-            </span>
-          </div>
-        </div>
-
-        <div class="cell cell-traffic">
-          <a-popover>
-            <template v-if="client.email" #content>
-              <table cellpadding="2">
-                <tbody>
-                  <tr>
-                    <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
-                    <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
-                  </tr>
-                  <tr v-if="client.totalGB > 0">
-                    <td>{{ t('remained') }}</td>
-                    <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
-                  </tr>
-                </tbody>
-              </table>
-            </template>
-            <div class="usage-bar">
-              <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
-              <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
-                :show-info="false" :percent="statsProgress(client.email)" size="small" />
-              <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
-                :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
-                :percent="statsProgress(client.email)" size="small" />
-              <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
-              <span class="usage-text">
-                <InfinityIcon v-if="isUnlimitedTotal(client)" />
-                <template v-else>{{ totalGbDisplay(client) }}</template>
-              </span>
-            </div>
-          </a-popover>
-        </div>
-
-        <div class="cell cell-remained">
-          <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
-            <InfinityIcon />
-          </a-tag>
-          <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
-            {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
-          </a-tag>
-        </div>
-
-        <div class="cell cell-alltime">
-          <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
-        </div>
-
-        <div class="cell cell-expiry">
-          <template v-if="client.expiryTime !== 0 && client.reset > 0">
-            <a-popover>
-              <template #content>
-                <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-                <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
-              </template>
-              <div class="usage-bar">
-                <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
-                <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
-                  :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
-                <span class="usage-text">{{ client.reset }}d</span>
-              </div>
-            </a-popover>
-          </template>
-          <a-popover v-else-if="client.expiryTime !== 0">
-            <template #content>
-              <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-              <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
-            </template>
-            <a-tag :style="{ minWidth: '50px', border: 'none' }"
-              :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
-            </a-tag>
-          </a-popover>
-          <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
-            class="infinite-tag">
-            <InfinityIcon />
-          </a-tag>
-        </div>
-      </div>
-    </template>
-
-    <!-- ====================== Mobile: card list ======================= -->
-    <template v-else>
-      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
-        :class="{ 'is-selected': isSelected(rowKey(client)) }">
-        <div class="client-card-head">
-          <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
-            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
-          <a-tooltip>
-            <template #title>
-              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
-              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
-              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
-              <template v-else>{{ t('offline') }}</template>
-            </template>
-            <a-badge :color="statusBadgeColor(client)" />
-          </a-tooltip>
-          <a-tooltip :title="client.email">
-            <span class="client-email">{{ client.email }}</span>
-          </a-tooltip>
-          <div class="client-card-actions">
-            <a-tooltip :title="t('info')">
-              <InfoCircleOutlined class="row-icon" @click="openStats(client)" />
-            </a-tooltip>
-            <a-switch :checked="client.enable" size="small"
-              @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
-            <a-dropdown :trigger="['click']" placement="bottomRight">
-              <EllipsisOutlined class="row-icon" @click.prevent />
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
-                    <QrcodeOutlined /> {{ t('qrCode') }}
-                  </a-menu-item>
-                  <a-menu-item @click="emit('edit-client', { dbInbound, client })">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item @click="emit('info-client', { dbInbound, client })">
-                    <InfoCircleOutlined /> {{ t('info') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="client.email" @click="confirmReset(client)">
-                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
-                    <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
-          </div>
-        </div>
-      </div>
-
-      <a-modal :open="!!statsClient" :footer="null" :width="360" centered
-        :title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
-        <div v-if="statsClient" class="client-card-foot">
-          <div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
-            {{ statsClient.comment }}
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-            <a-tag :color="clientStatsColor(statsClient.email)">
-              {{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
-              <InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
-              <template v-else>{{ totalGbDisplay(statsClient) }}</template>
-            </a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('remained') }}</span>
-            <a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
-              <InfinityIcon />
-            </a-tag>
-            <a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
-              {{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
-            </a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-            <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('online') }}</span>
-            <a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
-            <a-tag v-else>{{ t('offline') }}</a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-            <a-tag v-if="statsClient.expiryTime > 0"
-              :color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
-            </a-tag>
-            <a-tag v-else-if="statsClient.expiryTime < 0" color="green">
-              {{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
-            </a-tag>
-            <a-tag v-else color="purple">
-              <InfinityIcon />
-            </a-tag>
-          </div>
-        </div>
-      </a-modal>
-    </template>
-
-    <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
-      :page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
-      class="client-list-pagination" />
-  </div>
-</template>
-
-<style scoped>
-.client-list {
-  margin: -8px 0;
-  font-size: 13px;
-}
-
-.bulk-bar {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 6px 16px;
-  background: rgba(22, 119, 255, 0.08);
-  border-bottom: 1px solid rgba(22, 119, 255, 0.18);
-}
-
-.bulk-count {
-  font-weight: 500;
-  font-size: 13px;
-}
-
-.is-selected {
-  background: rgba(22, 119, 255, 0.06);
-}
-
-.client-row {
-  display: grid;
-  /* Default — no select column (single-client inbounds). The .has-select
-   * modifier below prepends the 40px checkbox column. */
-  grid-template-columns:
-    140px
-    /* actions */
-    60px
-    /* enable */
-    80px
-    /* online */
-    minmax(160px, 2fr)
-    /* client identity */
-    minmax(160px, 2fr)
-    /* traffic */
-    130px
-    /* all-time */
-    130px
-    /* remained */
-    140px;
-  /* expiry */
-  gap: 12px;
-  align-items: center;
-  padding: 8px 16px;
-  border-top: 1px solid rgba(128, 128, 128, 0.12);
-}
-
-.client-list.has-select .client-row {
-  grid-template-columns:
-    40px
-    /* select */
-    140px
-    /* actions */
-    60px
-    /* enable */
-    80px
-    /* online */
-    minmax(160px, 2fr)
-    /* client identity */
-    minmax(160px, 2fr)
-    /* traffic */
-    130px
-    /* all-time */
-    130px
-    /* remained */
-    140px;
-  /* expiry */
-}
-
-.client-row:last-child {
-  border-bottom: 1px solid rgba(128, 128, 128, 0.12);
-}
-
-.client-list-header {
-  font-weight: 500;
-  font-size: 12px;
-  opacity: 0.65;
-  padding-top: 6px;
-  padding-bottom: 6px;
-  border-top: none;
-  text-transform: uppercase;
-  letter-spacing: 0.02em;
-}
-
-.cell {
-  min-width: 0;
-  /* allow grid children to shrink instead of overflowing */
-}
-
-.cell-select,
-.cell-actions,
-.cell-enable,
-.cell-online,
-.cell-alltime,
-.cell-remained {
-  text-align: center;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 6px;
-  flex-wrap: wrap;
-}
-
-.cell-actions {
-  justify-content: flex-start;
-}
-
-.cell-client {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  min-width: 0;
-}
-
-.cell-traffic,
-.cell-expiry {
-  text-align: center;
-}
-
-.client-list-header .cell {
-  text-align: center;
-}
-
-.client-list-header .cell-actions,
-.client-list-header .cell-client {
-  text-align: left;
-}
-
-/* Action icons */
-.row-icon {
-  font-size: 16px;
-  cursor: pointer;
-  padding: 0 2px;
-  color: inherit;
-  transition: color 120ms ease;
-}
-
-.row-icon:hover {
-  color: var(--ant-color-primary, #1677ff);
-}
-
-.row-icon.danger {
-  color: #ff4d4f;
-}
-
-.danger {
-  color: #ff4d4f;
-}
-
-/* Client identity stack (badge + email + comment) */
-.client-id-stack {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-  min-width: 0;
-  overflow: hidden;
-}
-
-.client-email {
-  font-weight: 500;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: inline-block;
-}
-
-.client-comment {
-  font-size: 11px;
-  opacity: 0.7;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: inline-block;
-}
-
-/* Traffic / expiry inline bar:  text  |  progress  |  text */
-.usage-bar {
-  display: grid;
-  grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
-  align-items: center;
-  gap: 6px;
-}
-
-.usage-text {
-  font-size: 12px;
-  white-space: nowrap;
-}
-
-.usage-bar :deep(.ant-progress) {
-  margin: 0;
-  line-height: 1;
-}
-
-.infinite-tag {
-  min-width: 50px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-}
-
-/* Strip AD-Vue's default expanded-cell padding so the desktop grid
- * sits flush against the inbound row's left/right edges. */
-:deep(.ant-table-expanded-row > .ant-table-cell) {
-  padding: 0 !important;
-}
-
-.client-list-pagination {
-  display: flex;
-  justify-content: center;
-  padding: 10px 16px 4px;
-}
-
-/* ===== Mobile card list =========================================== */
-.client-list.is-mobile {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  margin: 0;
-}
-
-.client-card {
-  border: 1px solid rgba(128, 128, 128, 0.18);
-  border-radius: 8px;
-  padding: 10px 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-:global(body.dark) .client-card {
-  border-color: rgba(255, 255, 255, 0.1);
-}
-
-.client-card-head {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  min-width: 0;
-}
-
-.client-card-head .client-email {
-  flex: 1;
-  min-width: 0;
-  font-size: 14px;
-  font-weight: 500;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.client-card-actions {
-  margin-left: auto;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  flex-shrink: 0;
-}
-
-.client-card-actions .row-icon {
-  font-size: 20px;
-  padding: 4px;
-}
-
-.client-comment-line {
-  font-size: 11px;
-  opacity: 0.7;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.client-card-foot {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.client-card-foot .stat-row {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-.client-card-foot .stat-label {
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.04em;
-  opacity: 0.6;
-  min-width: 96px;
-  flex-shrink: 0;
-}
-
-.client-card-foot :deep(.ant-tag) {
-  margin: 0;
-}
-
-/* Bigger status badge for thumb-readable state at a glance. */
-.client-card-head :deep(.ant-badge-status-dot) {
-  width: 9px;
-  height: 9px;
-}
-</style>

+ 0 - 185
frontend/src/pages/inbounds/CopyClientsModal.vue

@@ -1,185 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { message } from 'ant-design-vue';
-
-import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
-import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
-
-const { t } = useI18n();
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  dbInbound: { type: Object, default: null },
-  dbInbounds: { type: Array, default: () => [] },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-const sourceInboundId = ref(null);
-const selectedEmails = ref([]);
-const flow = ref('');
-const saving = ref(false);
-
-const sources = computed(() => {
-  if (!props.dbInbound) return [];
-  return props.dbInbounds
-    .filter(
-      (row) =>
-        row.id !== props.dbInbound.id &&
-        typeof row.isMultiUser === 'function' &&
-        row.isMultiUser(),
-    )
-    .map((row) => {
-      let count = 0;
-      try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
-      return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
-    });
-});
-
-const sourceInbound = computed(() => {
-  if (!sourceInboundId.value) return null;
-  return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
-});
-
-const sourceClients = computed(() => {
-  const sb = sourceInbound.value;
-  if (!sb) return [];
-  let list = [];
-  try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
-  const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
-  return list
-    .filter((c) => c.email)
-    .map((c) => {
-      const s = stats.get(c.email);
-      const used = s ? (s.up || 0) + (s.down || 0) : 0;
-      let expiryLabel = t('unlimited');
-      if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
-      else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
-      return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
-    });
-});
-
-const showFlow = computed(() => {
-  if (!props.dbInbound) return false;
-  try {
-    const inb = props.dbInbound.toInbound();
-    return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
-  } catch (_e) { return false; }
-});
-
-const columns = computed(() => [
-  { title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
-  { title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
-  { title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
-]);
-
-const rowSelection = computed(() => ({
-  selectedRowKeys: selectedEmails.value,
-  onChange: (keys) => { selectedEmails.value = keys; },
-}));
-
-const title = computed(() => {
-  if (!props.dbInbound) return t('pages.client.copyFromInbound');
-  const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
-  return `${t('pages.client.copyToInbound')} ${target}`;
-});
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  sourceInboundId.value = null;
-  selectedEmails.value = [];
-  flow.value = '';
-  saving.value = false;
-});
-
-watch(sourceInboundId, () => {
-  selectedEmails.value = [];
-});
-
-function selectAll() {
-  selectedEmails.value = sourceClients.value.map((c) => c.email);
-}
-function clearAll() {
-  selectedEmails.value = [];
-}
-
-async function ok() {
-  if (!sourceInboundId.value) {
-    message.error(t('pages.client.copySelectSourceFirst'));
-    return;
-  }
-  if (!props.dbInbound) return;
-  saving.value = true;
-  try {
-    const payload = {
-      sourceInboundId: sourceInboundId.value,
-      clientEmails: selectedEmails.value,
-    };
-    if (showFlow.value && flow.value) payload.flow = flow.value;
-    const msg = await HttpUtil.post(
-      `/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
-      payload,
-    );
-    if (!msg?.success) return;
-    const obj = msg.obj || {};
-    const addedCount = (obj.added || []).length;
-    const errorList = obj.errors || [];
-    if (addedCount > 0) {
-      message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
-    } else {
-      message.warning(t('pages.client.copyResultNone'));
-    }
-    if (errorList.length > 0) {
-      message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
-    }
-    emit('saved');
-    emit('update:open', false);
-  } finally {
-    saving.value = false;
-  }
-}
-
-function close() {
-  if (saving.value) return;
-  emit('update:open', false);
-}
-</script>
-
-<template>
-  <a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
-    <a-space direction="vertical" :style="{ width: '100%' }">
-      <div>
-        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
-        <a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
-          <a-select-option v-for="item in sources" :key="item.id" :value="item.id">
-            {{ item.label }}
-          </a-select-option>
-        </a-select>
-      </div>
-
-      <div v-if="sourceInboundId">
-        <a-space :style="{ marginBottom: '8px' }">
-          <a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
-          <a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
-        </a-space>
-        <a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
-          :row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
-      </div>
-
-      <div v-if="showFlow">
-        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
-        <a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-        <div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
-          {{ t('pages.client.copyFlowHint') }}
-        </div>
-      </div>
-    </a-space>
-  </a-modal>
-</template>

File diff suppressed because it is too large
+ 577 - 353
frontend/src/pages/inbounds/InboundFormModal.vue


+ 121 - 29
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -19,41 +19,20 @@ import { useDatepicker } from '@/composables/useDatepicker.js';
 const { t } = useI18n();
 const { t } = useI18n();
 const { datepicker } = useDatepicker();
 const { datepicker } = useDatepicker();
 
 
-// One modal handles every protocol's info / share view because the
-// legacy template did the same. The big v-if forks at the top decide
-// which sub-block of the body renders:
-//   • multi-user inbound (VMess/VLess/Trojan/SS-multi/Hysteria) → per-
-//     client row + share links
-//   • SS single-user → connection details + share link
-//   • WireGuard → secret/peers + per-peer config download
-//   • Mixed/HTTP/Tunnel → connection details only
-//
-// We display links via QrPanel — each link gets its own QR + copy +
-// (for WireGuard configs) download button.
-
 const props = defineProps({
 const props = defineProps({
   open: { type: Boolean, default: false },
   open: { type: Boolean, default: false },
-  // Result of inbounds-page checkFallback() so the link-gen sees the
-  // root inbound's listen/port/security when the dbInbound is a
-  // domain-socket fallback (`@<name>`).
   dbInbound: { type: Object, default: null },
   dbInbound: { type: Object, default: null },
-  // Index into inbound.clients to focus on for multi-user inbounds.
   clientIndex: { type: Number, default: 0 },
   clientIndex: { type: Number, default: 0 },
-  // Sidecar config the legacy panel keyed off `app.*`.
   remarkModel: { type: String, default: '-ieo' },
   remarkModel: { type: String, default: '-ieo' },
   expireDiff: { type: Number, default: 0 },
   expireDiff: { type: Number, default: 0 },
   trafficDiff: { type: Number, default: 0 },
   trafficDiff: { type: Number, default: 0 },
   ipLimitEnable: { type: Boolean, default: false },
   ipLimitEnable: { type: Boolean, default: false },
   tgBotEnable: { type: Boolean, default: false },
   tgBotEnable: { type: Boolean, default: false },
-  // Address of the node hosting this inbound; '' for local. Wired
-  // through to share/QR link generation so node-managed inbounds
-  // produce links that connect to the node, not the central panel.
   nodeAddress: { type: String, default: '' },
   nodeAddress: { type: String, default: '' },
   subSettings: {
   subSettings: {
     type: Object,
     type: Object,
     default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
     default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
   },
   },
-  // Email -> ts (last-online unix-ms) map fetched at the page level.
   lastOnlineMap: { type: Object, default: () => ({}) },
   lastOnlineMap: { type: Object, default: () => ({}) },
 });
 });
 
 
@@ -137,7 +116,7 @@ async function loadClientIps() {
   if (!clientStats.value?.email) return;
   if (!clientStats.value?.email) return;
   refreshing.value = true;
   refreshing.value = true;
   try {
   try {
-    const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${clientStats.value.email}`);
+    const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.value.email}`);
     if (!msg?.success) {
     if (!msg?.success) {
       clientIpsText.value = msg?.obj || 'No IP record';
       clientIpsText.value = msg?.obj || 'No IP record';
       clientIpsArray.value = [];
       clientIpsArray.value = [];
@@ -164,7 +143,7 @@ async function loadClientIps() {
 
 
 async function clearClientIps() {
 async function clearClientIps() {
   if (!clientStats.value?.email) return;
   if (!clientStats.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
+  const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.value.email}`);
   if (msg?.success) {
   if (msg?.success) {
     clientIpsArray.value = [];
     clientIpsArray.value = [];
     clientIpsText.value = t('tgbot.noIpRecord');
     clientIpsText.value = t('tgbot.noIpRecord');
@@ -598,7 +577,8 @@ const showSubscriptionTab = computed(
             <div v-if="inbound.settings.gateway?.length" class="info-row">
             <div v-if="inbound.settings.gateway?.length" class="info-row">
               <dt>Gateway</dt>
               <dt>Gateway</dt>
               <dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
               <dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
-                  class="value-tag">{{ ip }}</a-tag></dd>
+                  class="value-tag">{{
+                  ip }}</a-tag></dd>
             </div>
             </div>
             <div v-if="inbound.settings.dns?.length" class="info-row">
             <div v-if="inbound.settings.dns?.length" class="info-row">
               <dt>DNS</dt>
               <dt>DNS</dt>
@@ -612,7 +592,8 @@ const showSubscriptionTab = computed(
             <div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
             <div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
               <dt>Auto system routes</dt>
               <dt>Auto system routes</dt>
               <dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
               <dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
-                  color="green">{{ cidr }}</a-tag></dd>
+                  color="green">{{
+                  cidr }}</a-tag></dd>
             </div>
             </div>
           </dl>
           </dl>
 
 
@@ -670,12 +651,101 @@ const showSubscriptionTab = computed(
                   <span class="account-sep">:</span>
                   <span class="account-sep">:</span>
                   <a-tag class="value-tag">{{ account.pass }}</a-tag>
                   <a-tag class="value-tag">{{ account.pass }}</a-tag>
                   <a-tooltip :title="t('copy')">
                   <a-tooltip :title="t('copy')">
-                    <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
-                      <template #icon>
-                        <CopyOutlined />
-                      </template>
+                    <a-button size="small" type="text"
+                      @click="copyText(`${account.user}:${account.pass}`)">
+                      <template #icon><CopyOutlined /></template>
                     </a-button>
                     </a-button>
                   </a-tooltip>
                   </a-tooltip>
+                  <a-space :size="4" wrap class="share-buttons share-desktop">
+                    <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
+                      <a-button size="small"
+                        @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
+                        SOCKS5
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
+                      <a-button size="small"
+                        @click="copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
+                        HTTP
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
+                      <a-button size="small"
+                        @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`)">
+                        Telegram
+                      </a-button>
+                    </a-tooltip>
+                  </a-space>
+                  <a-dropdown :trigger="['click']" class="share-mobile">
+                    <a-button size="small">
+                      <template #icon><CopyOutlined /></template>
+                      {{ t('copy') }}
+                    </a-button>
+                    <template #overlay>
+                      <a-menu @click="({ key }) => {
+                        const h = dbInbound.address;
+                        const port = dbInbound.port;
+                        if (key === 'telegram') {
+                          copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`);
+                        } else {
+                          copyText(`${key}://${h}:${port}@${account.user}:${account.pass}`);
+                        }
+                      }">
+                        <a-menu-item key="socks5">SOCKS5</a-menu-item>
+                        <a-menu-item key="http">HTTP</a-menu-item>
+                        <a-menu-item key="telegram">Telegram</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </dd>
+              </div>
+            </template>
+
+            <template v-if="inbound.settings.auth === 'noauth'">
+              <div class="info-row">
+                <dt>{{ t('copy') }}</dt>
+                <dd>
+                  <a-space :size="4" wrap class="share-buttons share-desktop">
+                    <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}`">
+                      <a-button size="small"
+                        @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}`)">
+                        SOCKS5
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}`">
+                      <a-button size="small"
+                        @click="copyText(`http://${dbInbound.address}:${dbInbound.port}`)">
+                        HTTP
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip title="https://t.me/socks?server=...&port=...">
+                      <a-button size="small"
+                        @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`)">
+                        Telegram
+                      </a-button>
+                    </a-tooltip>
+                  </a-space>
+                  <a-dropdown :trigger="['click']" class="share-mobile">
+                    <a-button size="small">
+                      <template #icon><CopyOutlined /></template>
+                      {{ t('copy') }}
+                    </a-button>
+                    <template #overlay>
+                      <a-menu @click="({ key }) => {
+                        const h = dbInbound.address;
+                        const port = dbInbound.port;
+                        if (key === 'telegram') {
+                          copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}`);
+                        } else {
+                          copyText(`${key}://${h}:${port}`);
+                        }
+                      }">
+                        <a-menu-item key="socks5">SOCKS5</a-menu-item>
+                        <a-menu-item key="http">HTTP</a-menu-item>
+                        <a-menu-item key="telegram">Telegram</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
                 </dd>
                 </dd>
               </div>
               </div>
             </template>
             </template>
@@ -897,6 +967,7 @@ const showSubscriptionTab = computed(
   white-space: normal;
   white-space: normal;
   word-break: break-all;
   word-break: break-all;
   display: inline-block;
   display: inline-block;
+  margin-right: 0;
 }
 }
 
 
 .value-block {
 .value-block {
@@ -927,6 +998,27 @@ const showSubscriptionTab = computed(
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
 
 
+.share-buttons,
+.share-mobile {
+  margin-inline-start: 4px;
+  padding-inline-start: 8px;
+  border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
+}
+
+.share-mobile {
+  display: none;
+}
+
+@media (max-width: 600px) {
+  .share-desktop {
+    display: none !important;
+  }
+  .share-mobile {
+    display: inline-flex;
+    align-items: center;
+  }
+}
+
 .security-line {
 .security-line {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 23 - 325
frontend/src/pages/inbounds/InboundList.vue

@@ -1,34 +1,24 @@
 <script setup>
 <script setup>
-import { computed, ref, watch } from 'vue';
+import { computed, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import {
 import {
   PlusOutlined,
   PlusOutlined,
   MenuOutlined,
   MenuOutlined,
-  SearchOutlined,
-  FilterOutlined,
   MoreOutlined,
   MoreOutlined,
   EditOutlined,
   EditOutlined,
   QrcodeOutlined,
   QrcodeOutlined,
-  UserAddOutlined,
-  UsergroupAddOutlined,
   CopyOutlined,
   CopyOutlined,
-  FileDoneOutlined,
   ExportOutlined,
   ExportOutlined,
   ImportOutlined,
   ImportOutlined,
   ReloadOutlined,
   ReloadOutlined,
-  RestOutlined,
   RetweetOutlined,
   RetweetOutlined,
   BlockOutlined,
   BlockOutlined,
   DeleteOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
   InfoCircleOutlined,
-  RightOutlined,
 } from '@ant-design/icons-vue';
 } from '@ant-design/icons-vue';
 
 
-import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
-import { DBInbound } from '@/models/dbinbound.js';
-import { Inbound } from '@/models/inbound.js';
+import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
 import InfinityIcon from '@/components/InfinityIcon.vue';
 import InfinityIcon from '@/components/InfinityIcon.vue';
-import ClientRowTable from './ClientRowTable.vue';
 import { useDatepicker } from '@/composables/useDatepicker.js';
 import { useDatepicker } from '@/composables/useDatepicker.js';
 
 
 const { datepicker } = useDatepicker();
 const { datepicker } = useDatepicker();
@@ -58,117 +48,8 @@ const emit = defineEmits([
   'add-inbound',
   'add-inbound',
   'general-action',
   'general-action',
   'row-action',
   'row-action',
-  // Per-client events surfaced from the expand-row table.
-  'edit-client',
-  'qrcode-client',
-  'info-client',
-  'reset-traffic-client',
-  'delete-client',
-  'delete-clients',
-  'toggle-enable-client',
 ]);
 ]);
 
 
-// ============ Toolbar / search & filter =============================
-const FILTER_STATE_KEY = 'inboundsFilterState';
-const savedFilterState = (() => {
-  try {
-    return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
-  } catch (_e) {
-    return {};
-  }
-})();
-const enableFilter = ref(!!savedFilterState.enableFilter);
-const searchKey = ref(savedFilterState.searchKey || '');
-const filterBy = ref(savedFilterState.filterBy || '');
-const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
-const nodeFilter = ref(savedFilterState.nodeFilter || '');
-
-watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
-  localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
-    enableFilter: enableFilter.value,
-    searchKey: searchKey.value,
-    filterBy: filterBy.value,
-    protocolFilter: protocolFilter.value,
-    nodeFilter: nodeFilter.value,
-  }));
-});
-
-// Toggle the filter mode — flip cleans the other input.
-function onToggleFilter() {
-  if (enableFilter.value) searchKey.value = '';
-  else filterBy.value = '';
-}
-
-const protocolOptions = computed(() => {
-  const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
-  return [...values].sort();
-});
-
-const nodeOptions = computed(() => {
-  const values = new Map();
-  if (props.dbInbounds.some((i) => i.nodeId == null)) {
-    values.set('local', t('pages.inbounds.localPanel'));
-  }
-  for (const dbInbound of props.dbInbounds) {
-    if (dbInbound.nodeId == null) continue;
-    const node = props.nodesById.get(dbInbound.nodeId);
-    values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
-  }
-  return [...values.entries()].map(([value, label]) => ({ value, label }));
-});
-
-function applySecondaryFilters(rows) {
-  return rows.filter((dbInbound) => {
-    if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
-    if (nodeFilter.value) {
-      const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
-      if (nodeValue !== nodeFilter.value) return false;
-    }
-    return true;
-  });
-}
-
-// ============ Search / filter projection =============================
-// Mirrors the legacy logic: when searching, keep inbounds that match
-// anywhere (deep search); when filtering, keep inbounds that have at
-// least one client in the requested bucket and reduce their settings
-// to that bucket.
-function projectInbound(dbInbound, predicate) {
-  const next = new DBInbound(dbInbound);
-  let settings;
-  try {
-    settings = JSON.parse(dbInbound.settings || '{}');
-  } catch (_e) {
-    settings = {};
-  }
-  if (!Array.isArray(settings.clients)) return next;
-  const filtered = settings.clients.filter(predicate);
-  next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
-  next.invalidateCache();
-  return next;
-}
-
-const visibleInbounds = computed(() => {
-  if (enableFilter.value) {
-    if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
-    const out = [];
-    for (const dbInbound of props.dbInbounds) {
-      const c = props.clientCount[dbInbound.id];
-      if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
-      const list = c[filterBy.value];
-      out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
-    }
-    return applySecondaryFilters(out);
-  }
-  if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
-  const out = [];
-  for (const dbInbound of props.dbInbounds) {
-    if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
-    out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
-  }
-  return applySecondaryFilters(out);
-});
-
 // ============ Sorting =================================================
 // ============ Sorting =================================================
 const sortState = ref({ column: null, order: null });
 const sortState = ref({ column: null, order: null });
 
 
@@ -189,7 +70,6 @@ const sortFns = {
   port: (a, b) => a.port - b.port,
   port: (a, b) => a.port - b.port,
   protocol: (a, b) => a.protocol.localeCompare(b.protocol),
   protocol: (a, b) => a.protocol.localeCompare(b.protocol),
   traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
   traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
-  allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
   expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
   expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
   node: (a, b) => {
   node: (a, b) => {
     const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
     const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
@@ -201,10 +81,10 @@ const sortFns = {
 
 
 const sortedInbounds = computed(() => {
 const sortedInbounds = computed(() => {
   const { column, order } = sortState.value;
   const { column, order } = sortState.value;
-  if (!column || !order) return visibleInbounds.value;
+  if (!column || !order) return props.dbInbounds;
   const fn = sortFns[column];
   const fn = sortFns[column];
-  if (!fn) return visibleInbounds.value;
-  const sorted = [...visibleInbounds.value].sort(fn);
+  if (!fn) return props.dbInbounds;
+  const sorted = [...props.dbInbounds].sort(fn);
   return order === 'descend' ? sorted.reverse() : sorted;
   return order === 'descend' ? sorted.reverse() : sorted;
 });
 });
 
 
@@ -215,10 +95,6 @@ function onTableChange(_pag, _filters, sorter) {
   };
   };
 }
 }
 
 
-watch([searchKey, filterBy], () => {
-  sortState.value = { column: null, order: null };
-});
-
 // ============ Columns =================================================
 // ============ Columns =================================================
 // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
 // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
 // `responsive` array still works on column defs. Computed so column
 // `responsive` array still works on column defs. Computed so column
@@ -244,26 +120,12 @@ const desktopColumns = computed(() => {
     sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
     sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
     sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
     sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
     sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
     sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
-    sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
     sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
     sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
   );
   );
   return cols;
   return cols;
 });
 });
 const columns = computed(() => desktopColumns.value);
 const columns = computed(() => desktopColumns.value);
 
 
-// Mobile expansion state — replaces a-table's expandable() since the
-// mobile branch renders a hand-rolled card list rather than a table.
-const expandedIds = ref(new Set());
-function toggleExpanded(id) {
-  const next = new Set(expandedIds.value);
-  if (next.has(id)) next.delete(id);
-  else next.add(id);
-  expandedIds.value = next;
-}
-function isExpanded(id) {
-  return expandedIds.value.has(id);
-}
-
 const statsRecord = ref(null);
 const statsRecord = ref(null);
 function openStats(record) {
 function openStats(record) {
   statsRecord.value = record;
   statsRecord.value = record;
@@ -344,12 +206,6 @@ function showQrCodeMenu(dbInbound) {
               <a-menu-item key="resetInbounds">
               <a-menu-item key="resetInbounds">
                 <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
                 <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
               </a-menu-item>
               </a-menu-item>
-              <a-menu-item key="resetClients">
-                <FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
-              </a-menu-item>
-              <a-menu-item key="delDepletedClients" class="danger-item">
-                <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-              </a-menu-item>
             </a-menu>
             </a-menu>
           </template>
           </template>
         </a-dropdown>
         </a-dropdown>
@@ -357,50 +213,13 @@ function showQrCodeMenu(dbInbound) {
     </template>
     </template>
 
 
     <a-space direction="vertical" :style="{ width: '100%' }">
     <a-space direction="vertical" :style="{ width: '100%' }">
-      <!-- Search / filter toolbar -->
-      <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
-        <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
-          <template #checkedChildren>
-            <SearchOutlined />
-          </template>
-          <template #unCheckedChildren>
-            <FilterOutlined />
-          </template>
-        </a-switch>
-        <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
-          :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
-        <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
-          :size="isMobile ? 'small' : 'middle'">
-          <a-radio-button value="">{{ t('none') }}</a-radio-button>
-          <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
-          <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
-          <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
-          <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
-          <a-radio-button value="online">{{ t('online') }}</a-radio-button>
-        </a-radio-group>
-        <a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
-          :size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
-          <a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
-            {{ protocol }}
-          </a-select-option>
-        </a-select>
-        <a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
-          :placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
-          <a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
-            {{ node.label }}
-          </a-select-option>
-        </a-select>
-      </div>
-
       <!-- ====================== Mobile: card list ======================= -->
       <!-- ====================== Mobile: card list ======================= -->
       <div v-if="isMobile" class="inbound-cards">
       <div v-if="isMobile" class="inbound-cards">
-        <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
+        <div v-if="sortedInbounds.length === 0" class="card-empty">—</div>
 
 
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
-          <!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
-          <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
-            <RightOutlined v-if="record.isMultiUser()" class="card-expand"
-              :class="{ 'is-expanded': isExpanded(record.id) }" />
+          <!-- Header: id + remark + info + enable + actions -->
+          <div class="card-head">
             <span class="card-id">#{{ record.id }}</span>
             <span class="card-id">#{{ record.id }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <div class="card-actions" @click.stop>
             <div class="card-actions" @click.stop>
@@ -419,27 +238,12 @@ function showQrCodeMenu(dbInbound) {
                       <QrcodeOutlined /> {{ t('qrCode') }}
                       <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
                     </a-menu-item>
                     <template v-if="record.isMultiUser()">
                     <template v-if="record.isMultiUser()">
-                      <a-menu-item key="addClient">
-                        <UserAddOutlined /> {{ t('pages.client.add') }}
-                      </a-menu-item>
-                      <a-menu-item key="addBulkClient">
-                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                      </a-menu-item>
-                      <a-menu-item key="copyClients">
-                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                      </a-menu-item>
-                      <a-menu-item key="resetClients">
-                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
-                      </a-menu-item>
                       <a-menu-item key="export">
                       <a-menu-item key="export">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                       </a-menu-item>
                       </a-menu-item>
                       <a-menu-item v-if="subEnable" key="subs">
                       <a-menu-item v-if="subEnable" key="subs">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                       </a-menu-item>
                       </a-menu-item>
-                      <a-menu-item key="delDepletedClients" class="danger-item">
-                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-                      </a-menu-item>
                     </template>
                     </template>
                     <template v-else>
                     <template v-else>
                       <a-menu-item key="showInfo">
                       <a-menu-item key="showInfo">
@@ -463,20 +267,6 @@ function showQrCodeMenu(dbInbound) {
               </a-dropdown>
               </a-dropdown>
             </div>
             </div>
           </div>
           </div>
-
-          <!-- Expanded client list (multi-user only) -->
-          <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
-            <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
-              :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
-              :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
-              :stats-version="statsVersion"
-              @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
-              @info-client="(p) => emit('info-client', p)"
-              @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
-              @delete-client="(p) => emit('delete-client', p)"
-              @delete-clients="(p) => emit('delete-clients', p)"
-              @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
-          </div>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -517,10 +307,6 @@ function showQrCodeMenu(dbInbound) {
               <InfinityIcon v-else />
               <InfinityIcon v-else />
             </a-tag>
             </a-tag>
           </div>
           </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-            <a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
-          </div>
           <div v-if="clientCount[statsRecord.id]" class="stat-row">
           <div v-if="clientCount[statsRecord.id]" class="stat-row">
             <span class="stat-label">{{ t('clients') }}</span>
             <span class="stat-label">{{ t('clients') }}</span>
             <a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
             <a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
@@ -550,29 +336,12 @@ function showQrCodeMenu(dbInbound) {
       <!-- ====================== Desktop: a-table ======================== -->
       <!-- ====================== Desktop: a-table ======================== -->
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
-        :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
-        <!-- Per-inbound client list, expanded by clicking the row's
-             default expand chevron. Hidden via row-class-name for
-             non-multi-user inbounds (matches legacy behavior). -->
-        <template #expandedRowRender="{ record }">
-          <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
-            :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
-            :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
-            :total-client-count="clientCount[record.id]?.clients || 0"
-            :stats-version="statsVersion"
-            @edit-client="(p) => emit('edit-client', p)"
-            @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
-            @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
-            @delete-client="(p) => emit('delete-client', p)"
-            @delete-clients="(p) => emit('delete-clients', p)"
-            @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
-        </template>
-
+        @change="onTableChange">
         <template #bodyCell="{ column, record }">
         <template #bodyCell="{ column, record }">
           <!-- ============== Action dropdown ============== -->
           <!-- ============== Action dropdown ============== -->
           <template v-if="column.key === 'action'">
           <template v-if="column.key === 'action'">
             <div class="action-buttons">
             <div class="action-buttons">
-              <a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
+              <a-button type="text" size="small" @click.prevent="emit('row-action', { key: 'edit', dbInbound: record })">
                 <template #icon>
                 <template #icon>
                   <EditOutlined />
                   <EditOutlined />
                 </template>
                 </template>
@@ -590,27 +359,12 @@ function showQrCodeMenu(dbInbound) {
                       <QrcodeOutlined /> {{ t('qrCode') }}
                       <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
                     </a-menu-item>
                     <template v-if="record.isMultiUser()">
                     <template v-if="record.isMultiUser()">
-                      <a-menu-item key="addClient">
-                        <UserAddOutlined /> {{ t('pages.client.add') }}
-                      </a-menu-item>
-                      <a-menu-item key="addBulkClient">
-                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                      </a-menu-item>
-                      <a-menu-item key="copyClients">
-                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                      </a-menu-item>
-                      <a-menu-item key="resetClients">
-                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
-                      </a-menu-item>
                       <a-menu-item key="export">
                       <a-menu-item key="export">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                       </a-menu-item>
                       </a-menu-item>
                       <a-menu-item v-if="subEnable" key="subs">
                       <a-menu-item v-if="subEnable" key="subs">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                       </a-menu-item>
                       </a-menu-item>
-                      <a-menu-item key="delDepletedClients" class="danger-item">
-                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-                      </a-menu-item>
                     </template>
                     </template>
                     <template v-else>
                     <template v-else>
                       <a-menu-item key="showInfo">
                       <a-menu-item key="showInfo">
@@ -671,14 +425,17 @@ function showQrCodeMenu(dbInbound) {
           <!-- ============== Clients tag + popovers ============== -->
           <!-- ============== Clients tag + popovers ============== -->
           <template v-else-if="column.key === 'clients'">
           <template v-else-if="column.key === 'clients'">
             <template v-if="clientCount[record.id]">
             <template v-if="clientCount[record.id]">
-              <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
+              <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                clientCount[record.id].clients }}</a-tag>
               <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
               <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
                 <template #content>
                 <template #content>
                   <div class="client-email-list">
                   <div class="client-email-list">
                     <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
                     <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
                   </div>
                   </div>
                 </template>
                 </template>
-                <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
+                <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].deactive.length
+                  }}</a-tag>
               </a-popover>
               </a-popover>
               <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
               <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
                 <template #content>
                 <template #content>
@@ -686,8 +443,9 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
                     <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
                   </div>
                   </div>
                 </template>
                 </template>
-                <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
-                }}</a-tag>
+                <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].depleted.length
+                  }}</a-tag>
               </a-popover>
               </a-popover>
               <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
               <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
                 <template #content>
                 <template #content>
@@ -695,8 +453,9 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
                     <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
                   </div>
                   </div>
                 </template>
                 </template>
-                <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
-                }}</a-tag>
+                <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].expiring.length
+                  }}</a-tag>
               </a-popover>
               </a-popover>
               <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
               <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
                 <template #content>
                 <template #content>
@@ -704,7 +463,8 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
                     <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
                   </div>
                   </div>
                 </template>
                 </template>
-                <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
+                <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].online.length }}</a-tag>
               </a-popover>
               </a-popover>
             </template>
             </template>
           </template>
           </template>
@@ -734,11 +494,6 @@ function showQrCodeMenu(dbInbound) {
             </a-popover>
             </a-popover>
           </template>
           </template>
 
 
-          <!-- ============== All-time inbound traffic ============== -->
-          <template v-else-if="column.key === 'allTimeInbound'">
-            <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
-          </template>
-
           <!-- ============== Expiry ============== -->
           <!-- ============== Expiry ============== -->
           <template v-else-if="column.key === 'expiryTime'">
           <template v-else-if="column.key === 'expiryTime'">
             <a-popover v-if="record.expiryTime > 0">
             <a-popover v-if="record.expiryTime > 0">
@@ -759,20 +514,6 @@ function showQrCodeMenu(dbInbound) {
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
-.filter-bar {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.filter-bar.mobile {
-  display: block;
-}
-
-.filter-bar.mobile>* {
-    margin-bottom: 4px;
-}
-
 .action-buttons {
 .action-buttons {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -799,23 +540,6 @@ function showQrCodeMenu(dbInbound) {
   color: #ff4d4f;
   color: #ff4d4f;
 }
 }
 
 
-/* Hide the expand chevron on rows whose inbound has no client list
- * (HTTP/Mixed/Tunnel/WireGuard single-config). */
-:deep(.hide-expand-icon .ant-table-row-expand-icon) {
-  visibility: hidden;
-}
-
-/* Push the expand chevron away from the table's left edge so it has
- * a little breathing room instead of being flush against the corner. */
-:deep(.ant-table-tbody .ant-table-cell-with-append) {
-  padding-left: 12px;
-}
-
-:deep(.ant-table-row-expand-icon) {
-  margin-inline-end: 10px;
-  margin-inline-start: 4px;
-}
-
 /* Round the table's outer corners — AD-Vue gives .ant-table the radius
 /* Round the table's outer corners — AD-Vue gives .ant-table the radius
  * token, but the inner header strip and footer touch the edges, so clip
  * token, but the inner header strip and footer touch the edges, so clip
  * them here. */
  * them here. */
@@ -900,17 +624,6 @@ function showQrCodeMenu(dbInbound) {
   flex-shrink: 0;
   flex-shrink: 0;
 }
 }
 
 
-.card-expand {
-  font-size: 12px;
-  opacity: 0.6;
-  transition: transform 150ms ease;
-  flex-shrink: 0;
-}
-
-.card-expand.is-expanded {
-  transform: rotate(90deg);
-}
-
 .card-stats {
 .card-stats {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
@@ -937,11 +650,6 @@ function showQrCodeMenu(dbInbound) {
   margin: 0;
   margin: 0;
 }
 }
 
 
-.card-clients {
-  margin-top: 4px;
-  padding-top: 8px;
-  border-top: 1px solid rgba(128, 128, 128, 0.15);
-}
 
 
 .card-empty {
 .card-empty {
   text-align: center;
   text-align: center;
@@ -964,16 +672,6 @@ function showQrCodeMenu(dbInbound) {
     padding: 8px;
     padding: 8px;
   }
   }
 
 
-  .filter-bar.mobile {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 6px;
-  }
-
-  .filter-bar.mobile>* {
-    margin-bottom: 0;
-  }
-
   .row-action-trigger {
   .row-action-trigger {
     font-size: 22px;
     font-size: 22px;
     padding: 4px;
     padding: 4px;

+ 55 - 250
frontend/src/pages/inbounds/InboundsPage.vue

@@ -5,13 +5,12 @@ import { Modal, message } from 'ant-design-vue';
 import {
 import {
   SwapOutlined,
   SwapOutlined,
   PieChartOutlined,
   PieChartOutlined,
-  HistoryOutlined,
   BarsOutlined,
   BarsOutlined,
-  TeamOutlined,
 } from '@ant-design/icons-vue';
 } from '@ant-design/icons-vue';
 
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { Inbound } from '@/models/inbound.js';
 import { Inbound } from '@/models/inbound.js';
+import { coerceInboundJsonField } from '@/models/dbinbound.js';
 import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
 import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
 import { useMediaQuery } from '@/composables/useMediaQuery.js';
 import { useMediaQuery } from '@/composables/useMediaQuery.js';
 import AppSidebar from '@/components/AppSidebar.vue';
 import AppSidebar from '@/components/AppSidebar.vue';
@@ -19,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
 import { useNodeList } from '@/composables/useNodeList.js';
 import { useNodeList } from '@/composables/useNodeList.js';
 import InboundList from './InboundList.vue';
 import InboundList from './InboundList.vue';
 import InboundFormModal from './InboundFormModal.vue';
 import InboundFormModal from './InboundFormModal.vue';
-import ClientFormModal from './ClientFormModal.vue';
-import ClientBulkModal from './ClientBulkModal.vue';
-import CopyClientsModal from './CopyClientsModal.vue';
 import InboundInfoModal from './InboundInfoModal.vue';
 import InboundInfoModal from './InboundInfoModal.vue';
 import QrCodeModal from './QrCodeModal.vue';
 import QrCodeModal from './QrCodeModal.vue';
 import TextModal from '@/components/TextModal.vue';
 import TextModal from '@/components/TextModal.vue';
@@ -65,9 +61,11 @@ useWebSocket({
   inbounds: applyInboundsEvent,
   inbounds: applyInboundsEvent,
 });
 });
 const { isMobile } = useMediaQuery();
 const { isMobile } = useMediaQuery();
-// Node list lives on the central panel; the Inbounds page consumes
-// the id→node map for the new "Node" column. Fetched once on mount.
 const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
 const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
+const hasNodeAttachedInbound = computed(() =>
+  (dbInbounds.value || []).some((ib) => ib?.nodeId != null),
+);
+const showNodeInfo = computed(() => hasNodeAttachedInbound.value || hasActiveNode.value);
 
 
 const basePath = window.X_UI_BASE_PATH || '';
 const basePath = window.X_UI_BASE_PATH || '';
 const requestUri = window.location.pathname;
 const requestUri = window.location.pathname;
@@ -82,17 +80,6 @@ const formOpen = ref(false);
 const formMode = ref('add');
 const formMode = ref('add');
 const formDbInbound = ref(null);
 const formDbInbound = ref(null);
 
 
-// === Client modal (single + bulk) =====================================
-const clientOpen = ref(false);
-const clientMode = ref('add');
-const clientDbInbound = ref(null);
-const clientIndex = ref(null);
-
-const bulkOpen = ref(false);
-const bulkDbInbound = ref(null);
-const copyOpen = ref(false);
-const copyDbInbound = ref(null);
-
 // === Info / QR-code modals ===========================================
 // === Info / QR-code modals ===========================================
 const infoOpen = ref(false);
 const infoOpen = ref(false);
 const infoDbInbound = ref(null);
 const infoDbInbound = ref(null);
@@ -191,7 +178,8 @@ function exportInboundSubs(dbInbound) {
 function exportAllLinks() {
 function exportAllLinks() {
   const out = [];
   const out = [];
   for (const ib of dbInbounds.value) {
   for (const ib of dbInbounds.value) {
-    out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
+    const projected = checkFallback(ib);
+    out.push(projected.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
   }
   }
   openText({
   openText({
     title: 'Export all inbound links',
     title: 'Export all inbound links',
@@ -240,8 +228,18 @@ function importInbound() {
 // the root inbound that owns the listen address so QRs/links carry
 // the root inbound that owns the listen address so QRs/links carry
 // the externally-reachable host:port and the right TLS state.
 // the externally-reachable host:port and the right TLS state.
 function checkFallback(dbInbound) {
 function checkFallback(dbInbound) {
-  // We don't keep parsed Inbounds in state right now (the page works
-  // off DBInbounds); compute on the fly.
+  // Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
+  // The backend annotates each child inbound with fallbackParent so the
+  // child's client-share link advertises the master's reachable endpoint
+  // and inherits its TLS / Reality state.
+  const parent = dbInbound.fallbackParent;
+  if (parent?.masterId) {
+    const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
+    if (master) return projectChildThroughMaster(dbInbound, master);
+  }
+  // Path 2: legacy unix-socket convention (`@vless-ws` etc.) — walk the
+  // VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
+  // references this child's listen address.
   if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
   if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
   for (const candidate of dbInbounds.value) {
   for (const candidate of dbInbounds.value) {
     if (candidate.id === dbInbound.id) continue;
     if (candidate.id === dbInbound.id) continue;
@@ -250,23 +248,30 @@ function checkFallback(dbInbound) {
     if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
     if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
     const fallbacks = parsed.settings.fallbacks || [];
     const fallbacks = parsed.settings.fallbacks || [];
     if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
     if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
-    // Build a one-off DBInbound copy with the parent's listen/port +
-    // copied stream so the link gen sees the public endpoint.
-    const projected = JSON.parse(JSON.stringify(dbInbound));
-    projected.listen = candidate.listen;
-    projected.port = candidate.port;
-    const inheritedStream = parsed.stream;
-    const ownInbound = dbInbound.toInbound();
-    ownInbound.stream.security = inheritedStream.security;
-    ownInbound.stream.tls = inheritedStream.tls;
-    ownInbound.stream.externalProxy = inheritedStream.externalProxy;
-    projected.streamSettings = ownInbound.stream.toString();
-    // Re-wrap so callers get the same DBInbound shape they had.
-    return new dbInbound.constructor(projected);
+    return projectChildThroughMaster(dbInbound, candidate);
   }
   }
   return dbInbound;
   return dbInbound;
 }
 }
 
 
+// projectChildThroughMaster returns a one-off DBInbound copy whose
+// listen/port + TLS/Reality state come from the master, while the
+// protocol/transport/clients stay the child's. This is what makes a
+// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
+// for a child VLESS-WS bound to 127.0.0.1.
+function projectChildThroughMaster(child, master) {
+  const projected = JSON.parse(JSON.stringify(child));
+  projected.listen = master.listen;
+  projected.port = master.port;
+  const masterStream = master.toInbound().stream;
+  const childInbound = child.toInbound();
+  childInbound.stream.security = masterStream.security;
+  childInbound.stream.tls = masterStream.tls;
+  childInbound.stream.reality = masterStream.reality;
+  childInbound.stream.externalProxy = masterStream.externalProxy;
+  projected.streamSettings = childInbound.stream.toString();
+  return new child.constructor(projected);
+}
+
 function findClientIndex(dbInbound, client) {
 function findClientIndex(dbInbound, client) {
   if (!client) return 0;
   if (!client) return 0;
   const inbound = dbInbound.toInbound();
   const inbound = dbInbound.toInbound();
@@ -284,73 +289,6 @@ function findClientIndex(dbInbound, client) {
   return idx >= 0 ? idx : 0;
   return idx >= 0 ? idx : 0;
 }
 }
 
 
-function getClientId(protocol, client) {
-  switch (protocol) {
-    case 'trojan': return client.password;
-    case 'shadowsocks': return client.email;
-    case 'hysteria': return client.auth;
-    default: return client.id;
-  }
-}
-
-// === Per-client handlers (called from the expand-row table) =========
-function onEditClient({ dbInbound, client }) {
-  clientMode.value = 'edit';
-  clientDbInbound.value = dbInbound;
-  clientIndex.value = findClientIndex(dbInbound, client);
-  clientOpen.value = true;
-}
-
-function onQrcodeClient({ dbInbound, client }) {
-  qrDbInbound.value = checkFallback(dbInbound);
-  qrClient.value = client || null;
-  qrOpen.value = true;
-}
-
-function onInfoClient({ dbInbound, client }) {
-  infoDbInbound.value = checkFallback(dbInbound);
-  infoClientIndex.value = findClientIndex(dbInbound, client);
-  infoOpen.value = true;
-}
-
-async function onResetTrafficClient({ dbInbound, client }) {
-  const msg = await HttpUtil.post(
-    `/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
-  );
-  if (msg?.success) await refresh();
-}
-
-async function onDeleteClient({ dbInbound, client }) {
-  const clientId = getClientId(dbInbound.protocol, client);
-  const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
-  if (msg?.success) await refresh();
-}
-
-async function onDeleteClients({ dbInbound, clients }) {
-  for (const client of clients) {
-    const clientId = getClientId(dbInbound.protocol, client);
-    await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
-  }
-  await refresh();
-}
-
-async function onToggleEnableClient({ dbInbound, client, next }) {
-  // Mirror legacy: clone the parsed inbound, flip enable on the matching
-  // client, and post the whole client back through updateClient. This
-  // keeps the wire shape identical to the modal save path.
-  const inbound = dbInbound.toInbound();
-  const clients = inbound?.clients || [];
-  const idx = findClientIndex(dbInbound, client);
-  if (idx < 0 || !clients[idx]) return;
-  clients[idx].enable = next;
-  const clientId = getClientId(dbInbound.protocol, clients[idx]);
-  const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
-    id: dbInbound.id,
-    settings: `{"clients": [${clients[idx].toString()}]}`,
-  });
-  if (msg?.success) await refresh();
-}
-
 function onAddInbound() {
 function onAddInbound() {
   formMode.value = 'add';
   formMode.value = 'add';
   formDbInbound.value = null;
   formDbInbound.value = null;
@@ -363,18 +301,6 @@ function openEdit(dbInbound) {
   formOpen.value = true;
   formOpen.value = true;
 }
 }
 
 
-function openAddClient(dbInbound) {
-  clientMode.value = 'add';
-  clientDbInbound.value = dbInbound;
-  clientIndex.value = null;
-  clientOpen.value = true;
-}
-
-function openAddBulkClient(dbInbound) {
-  bulkDbInbound.value = dbInbound;
-  bulkOpen.value = true;
-}
-
 // Per-row destructive actions go through Modal.confirm (matches legacy).
 // Per-row destructive actions go through Modal.confirm (matches legacy).
 function confirmDelete(dbInbound) {
 function confirmDelete(dbInbound) {
   Modal.confirm({
   Modal.confirm({
@@ -403,20 +329,6 @@ function confirmResetTraffic(dbInbound) {
   });
   });
 }
 }
 
 
-function confirmDelDepleted(dbInboundId) {
-  Modal.confirm({
-    title: 'Delete depleted clients?',
-    content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
-    okText: 'Delete',
-    okType: 'danger',
-    cancelText: 'Cancel',
-    onOk: async () => {
-      const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
-      if (msg?.success) await refresh();
-    },
-  });
-}
-
 // Clone — adds a new inbound with the same protocol+stream+sniffing
 // Clone — adds a new inbound with the same protocol+stream+sniffing
 // but a fresh remark/port and an empty client list.
 // but a fresh remark/port and an empty client list.
 function confirmClone(dbInbound) {
 function confirmClone(dbInbound) {
@@ -427,6 +339,14 @@ function confirmClone(dbInbound) {
     cancelText: 'Cancel',
     cancelText: 'Cancel',
     onOk: async () => {
     onOk: async () => {
       const baseInbound = dbInbound.toInbound();
       const baseInbound = dbInbound.toInbound();
+      let clonedSettings;
+      try {
+        const raw = coerceInboundJsonField(dbInbound.settings);
+        raw.clients = [];
+        clonedSettings = JSON.stringify(raw);
+      } catch (_e) {
+        clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
+      }
       const data = {
       const data = {
         up: 0,
         up: 0,
         down: 0,
         down: 0,
@@ -437,7 +357,7 @@ function confirmClone(dbInbound) {
         listen: '',
         listen: '',
         port: RandomUtil.randomInteger(10000, 60000),
         port: RandomUtil.randomInteger(10000, 60000),
         protocol: baseInbound.protocol,
         protocol: baseInbound.protocol,
-        settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
+        settings: clonedSettings,
         streamSettings: baseInbound.stream.toString(),
         streamSettings: baseInbound.stream.toString(),
         sniffing: baseInbound.sniffing.toString(),
         sniffing: baseInbound.sniffing.toString(),
       };
       };
@@ -469,20 +389,6 @@ function onGeneralAction(key) {
         },
         },
       });
       });
       break;
       break;
-    case 'resetClients':
-      Modal.confirm({
-        title: 'Reset all client traffic across all inbounds?',
-        okText: 'Reset',
-        cancelText: 'Cancel',
-        onOk: async () => {
-          const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
-          if (msg?.success) await refresh();
-        },
-      });
-      break;
-    case 'delDepletedClients':
-      confirmDelDepleted(-1);
-      break;
     default:
     default:
       message.info(`General action "${key}" — coming in a later 5f subphase`);
       message.info(`General action "${key}" — coming in a later 5f subphase`);
   }
   }
@@ -493,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
     case 'edit':
     case 'edit':
       openEdit(dbInbound);
       openEdit(dbInbound);
       break;
       break;
-    case 'addClient':
-      openAddClient(dbInbound);
-      break;
-    case 'addBulkClient':
-      openAddBulkClient(dbInbound);
-      break;
     case 'showInfo':
     case 'showInfo':
       infoDbInbound.value = checkFallback(dbInbound);
       infoDbInbound.value = checkFallback(dbInbound);
       infoClientIndex.value = findClientIndex(dbInbound, null);
       infoClientIndex.value = findClientIndex(dbInbound, null);
@@ -518,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
     case 'clipboard':
     case 'clipboard':
       exportInboundClipboard(dbInbound);
       exportInboundClipboard(dbInbound);
       break;
       break;
-    case 'copyClients':
-      copyDbInbound.value = dbInbound;
-      copyOpen.value = true;
-      break;
     case 'delete':
     case 'delete':
       confirmDelete(dbInbound);
       confirmDelete(dbInbound);
       break;
       break;
@@ -531,20 +427,6 @@ function onRowAction({ key, dbInbound }) {
     case 'clone':
     case 'clone':
       confirmClone(dbInbound);
       confirmClone(dbInbound);
       break;
       break;
-    case 'resetClients':
-      Modal.confirm({
-        title: `Reset client traffic on "${dbInbound.remark}"?`,
-        okText: 'Reset',
-        cancelText: 'Cancel',
-        onOk: async () => {
-          const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
-          if (msg?.success) await refresh();
-        },
-      });
-      break;
-    case 'delDepletedClients':
-      confirmDelDepleted(dbInbound.id);
-      break;
     default:
     default:
       message.info(`Action "${key}" — coming in a later 5f subphase`);
       message.info(`Action "${key}" — coming in a later 5f subphase`);
   }
   }
@@ -566,7 +448,7 @@ function onRowAction({ key, dbInbound }) {
               <a-col :span="24">
               <a-col :span="24">
                 <a-card size="small" hoverable class="summary-card">
                 <a-card size="small" hoverable class="summary-card">
                   <a-row :gutter="[16, 12]">
                   <a-row :gutter="[16, 12]">
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
                       <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
                         :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
                         :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
                         <template #prefix>
                         <template #prefix>
@@ -574,7 +456,7 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.totalUsage')"
                       <CustomStatistic :title="t('pages.inbounds.totalUsage')"
                         :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
                         :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
                         <template #prefix>
                         <template #prefix>
@@ -582,63 +464,13 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
-                      <CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
-                        :value="SizeFormatter.sizeFormat(totals.allTime)">
-                        <template #prefix>
-                          <HistoryOutlined />
-                        </template>
-                      </CustomStatistic>
-                    </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="24" :sm="24" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
                       <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
                         <template #prefix>
                         <template #prefix>
                           <BarsOutlined />
                           <BarsOutlined />
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :xs="24" :sm="24" :md="4">
-                      <CustomStatistic :title="t('clients')" value=" ">
-                        <template #prefix>
-                          <a-space direction="horizontal">
-                            <TeamOutlined />
-                            <a-tag color="green">{{ totals.clients }}</a-tag>
-                            <a-popover v-if="totals.deactive.length" :title="t('disabled')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.deactive" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag>{{ totals.deactive.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.depleted.length" :title="t('depleted')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.depleted" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="red">{{ totals.depleted.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.expiring" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="orange">{{ totals.expiring.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.online.length" :title="t('online')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.online" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="blue">{{ totals.online.length }}</a-tag>
-                            </a-popover>
-                          </a-space>
-                        </template>
-                      </CustomStatistic>
-                    </a-col>
                   </a-row>
                   </a-row>
                 </a-card>
                 </a-card>
               </a-col>
               </a-col>
@@ -648,26 +480,16 @@ function onRowAction({ key, dbInbound }) {
                 <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
                 <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
-                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
-                  :stats-version="statsVersion"
-                  @refresh="refresh"
-                  @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
-                  @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
-                  @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
-                  @delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
+                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="showNodeInfo"
+                  :stats-version="statsVersion" @refresh="refresh" @add-inbound="onAddInbound"
+                  @general-action="onGeneralAction" @row-action="onRowAction" />
               </a-col>
               </a-col>
             </a-row>
             </a-row>
           </a-spin>
           </a-spin>
         </a-layout-content>
         </a-layout-content>
       </a-layout>
       </a-layout>
 
 
-      <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
-      <ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
-        :client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
-        :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
-      <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
-        :tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
-      <CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
+      <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" :db-inbounds="dbInbounds"
         @saved="refresh" />
         @saved="refresh" />
       <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
       <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
         :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
         :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
@@ -735,20 +557,3 @@ function onRowAction({ key, dbInbound }) {
   }
   }
 }
 }
 </style>
 </style>
-
-<style>
-/* AD-Vue popovers teleport their content to <body>, so scoped styles
-   don't reach them — this block has to be unscoped. */
-.client-email-list {
-  max-height: 280px;
-  min-width: 160px;
-  overflow-y: auto;
-  padding-right: 4px;
-}
-
-.client-email-list > div {
-  padding: 2px 0;
-  font-size: 12px;
-  white-space: nowrap;
-}
-</style>

+ 11 - 23
frontend/src/pages/inbounds/useInbounds.js

@@ -55,7 +55,14 @@ export function useInbounds() {
   // (HTTP, MIXED, WireGuard) since their settings have no client list.
   // (HTTP, MIXED, WireGuard) since their settings have no client list.
   function rollupClients(dbInbound, inbound) {
   function rollupClients(dbInbound, inbound) {
     const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
     const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
-    const clients = inbound?.clients || [];
+    const allClients = inbound?.clients || [];
+    const statsEmails = new Set();
+    for (const s of clientStats) {
+      if (s && s.email) statsEmails.add(s.email);
+    }
+    const clients = clientStats.length > 0
+      ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
+      : allClients;
     const active = [];
     const active = [];
     const deactive = [];
     const deactive = [];
     const depleted = [];
     const depleted = [];
@@ -126,12 +133,12 @@ export function useInbounds() {
   }
   }
 
 
   async function fetchOnlineUsers() {
   async function fetchOnlineUsers() {
-    const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
+    const msg = await HttpUtil.post('/panel/api/clients/onlines');
     if (msg?.success) onlineClients.value = msg.obj || [];
     if (msg?.success) onlineClients.value = msg.obj || [];
   }
   }
 
 
   async function fetchLastOnlineMap() {
   async function fetchLastOnlineMap() {
-    const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
+    const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
     if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
     if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
   }
   }
 
 
@@ -195,7 +202,6 @@ export function useInbounds() {
         if (!upd) continue;
         if (!upd) continue;
         if (typeof upd.up === 'number') ib.up = upd.up;
         if (typeof upd.up === 'number') ib.up = upd.up;
         if (typeof upd.down === 'number') ib.down = upd.down;
         if (typeof upd.down === 'number') ib.down = upd.down;
-        if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
         if (typeof upd.total === 'number') ib.total = upd.total;
         if (typeof upd.total === 'number') ib.total = upd.total;
         if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
         if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
         touched = true;
         touched = true;
@@ -216,7 +222,6 @@ export function useInbounds() {
           if (typeof upd.up === 'number') stat.up = upd.up;
           if (typeof upd.up === 'number') stat.up = upd.up;
           if (typeof upd.down === 'number') stat.down = upd.down;
           if (typeof upd.down === 'number') stat.down = upd.down;
           if (typeof upd.total === 'number') stat.total = upd.total;
           if (typeof upd.total === 'number') stat.total = upd.total;
-          if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
           if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
           if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
           if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
           if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
           touched = true;
           touched = true;
@@ -283,31 +288,14 @@ export function useInbounds() {
     }
     }
   }
   }
 
 
-  // Aggregate totals shown in the dashboard summary card. allTime falls
-  // back to up+down when the per-inbound counter isn't populated yet.
   const totals = computed(() => {
   const totals = computed(() => {
     let up = 0;
     let up = 0;
     let down = 0;
     let down = 0;
-    let allTime = 0;
-    let clients = 0;
-    const deactive = [];
-    const depleted = [];
-    const expiring = [];
-    const online = [];
     for (const ib of dbInbounds.value) {
     for (const ib of dbInbounds.value) {
       up += ib.up || 0;
       up += ib.up || 0;
       down += ib.down || 0;
       down += ib.down || 0;
-      allTime += ib.allTime || (ib.up + ib.down) || 0;
-      const c = clientCount.value[ib.id];
-      if (c) {
-        clients += c.clients;
-        deactive.push(...c.deactive);
-        depleted.push(...c.depleted);
-        expiring.push(...c.expiring);
-        online.push(...c.online);
-      }
     }
     }
-    return { up, down, allTime, clients, deactive, depleted, expiring, online };
+    return { up, down };
   });
   });
 
 
   // ObjectUtil reference is wired at module load — keeping a no-op import
   // ObjectUtil reference is wired at module load — keeping a no-op import

+ 34 - 0
frontend/src/pages/nodes/NodeList.vue

@@ -184,6 +184,10 @@ function isExpanded(id) {
           <span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
           <span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
           <a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
           <a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
         </div>
         </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
+          <a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
+        </div>
         <div class="stat-row">
         <div class="stat-row">
           <span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
           <span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
           <a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
           <a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
@@ -195,6 +199,16 @@ function isExpanded(id) {
             <template v-else>-</template>
             <template v-else>-</template>
           </a-tag>
           </a-tag>
         </div>
         </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('clients') }}</span>
+          <a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
+          <a-tag v-if="statsNode.onlineCount" color="blue">
+            {{ statsNode.onlineCount }} {{ t('online') }}
+          </a-tag>
+          <a-tag v-if="statsNode.depletedCount" color="red">
+            {{ statsNode.depletedCount }} {{ t('depleted') }}
+          </a-tag>
+        </div>
         <div class="stat-row">
         <div class="stat-row">
           <span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
           <span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
           <a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
           <a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
@@ -260,10 +274,30 @@ function isExpanded(id) {
         </template>
         </template>
       </a-table-column>
       </a-table-column>
 
 
+      <a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
+        <template #default="{ record }">
+          {{ record.panelVersion || '-' }}
+        </template>
+      </a-table-column>
+
       <a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
       <a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
         <template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
         <template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
       </a-table-column>
       </a-table-column>
 
 
+      <a-table-column :title="t('clients')" align="center" :width="160">
+        <template #default="{ record }">
+          <a-space :size="4">
+            <a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
+            <a-tag v-if="record.onlineCount" color="blue">
+              {{ record.onlineCount }} {{ t('online') }}
+            </a-tag>
+            <a-tag v-if="record.depletedCount" color="red">
+              {{ record.depletedCount }} {{ t('depleted') }}
+            </a-tag>
+          </a-space>
+        </template>
+      </a-table-column>
+
       <a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
       <a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
         <template #default="{ record }">
         <template #default="{ record }">
           <span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
           <span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>

+ 12 - 2
frontend/src/pages/nodes/useNodes.js

@@ -71,15 +71,21 @@ export function useNodes() {
     return msg;
     return msg;
   }
   }
 
 
-  // Aggregate cards on the dashboard. Computed off the live list so a
-  // refresh (or a WS push) picks up new totals automatically.
   const totals = computed(() => {
   const totals = computed(() => {
     const list = nodes.value;
     const list = nodes.value;
     let online = 0;
     let online = 0;
     let offline = 0;
     let offline = 0;
     let latencySum = 0;
     let latencySum = 0;
     let latencyCount = 0;
     let latencyCount = 0;
+    let inbounds = 0;
+    let clients = 0;
+    let onlineClients = 0;
+    let depleted = 0;
     for (const n of list) {
     for (const n of list) {
+      inbounds += n.inboundCount || 0;
+      clients += n.clientCount || 0;
+      onlineClients += n.onlineCount || 0;
+      depleted += n.depletedCount || 0;
       if (!n.enable) continue;
       if (!n.enable) continue;
       if (n.status === 'online') {
       if (n.status === 'online') {
         online += 1;
         online += 1;
@@ -96,6 +102,10 @@ export function useNodes() {
       online,
       online,
       offline,
       offline,
       avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
       avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
+      inbounds,
+      clients,
+      onlineClients,
+      depleted,
     };
     };
   });
   });
 
 

+ 3 - 14
frontend/src/pages/xray/BalancerFormModal.vue

@@ -61,16 +61,6 @@ const isValid = computed(
   () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
   () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
 );
 );
 
 
-const fallbackSupported = computed(
-  () => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
-);
-
-watch(() => form.strategy, (next) => {
-  if (next !== 'leastPing' && next !== 'leastLoad') {
-    form.fallbackTag = '';
-  }
-});
-
 const tagValidateStatus = computed(() => {
 const tagValidateStatus = computed(() => {
   if (tagEmpty.value) return 'error';
   if (tagEmpty.value) return 'error';
   if (duplicateTag.value) return 'warning';
   if (duplicateTag.value) return 'warning';
@@ -97,7 +87,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Balancers')}`,
     : `+ ${t('pages.xray.Balancers')}`,
 );
 );
 const okText = computed(() =>
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 );
 </script>
 </script>
 
 
@@ -121,9 +111,8 @@ const okText = computed(() =>
         </a-select>
         </a-select>
       </a-form-item>
       </a-form-item>
 
 
-      <a-form-item label="Fallback"
-        :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
-        <a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
+      <a-form-item label="Fallback">
+        <a-select v-model:value="form.fallbackTag" allow-clear>
           <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
           <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
             {{ tag || `(${t('none')})` }}
             {{ tag || `(${t('none')})` }}
           </a-select-option>
           </a-select-option>

+ 12 - 10
frontend/src/pages/xray/BalancersTab.vue

@@ -133,23 +133,25 @@ function syncObservatories() {
     delete t.observatory;
     delete t.observatory;
   }
   }
 
 
-  const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
-  if (leastLoads.length > 0) {
+  const burstFeeders = balancers.filter((b) => {
+    const type = b.strategy?.type || 'random';
+    return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
+  });
+  if (burstFeeders.length > 0) {
     if (!t.burstObservatory) {
     if (!t.burstObservatory) {
       t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
       t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
     }
     }
-    t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
+    t.burstObservatory.subjectSelector = collectSelectors(burstFeeders);
   } else {
   } else {
     delete t.burstObservatory;
     delete t.burstObservatory;
   }
   }
 }
 }
 
 
 function buildWireBalancer(form) {
 function buildWireBalancer(form) {
-  const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
   const out = {
   const out = {
     tag: form.tag,
     tag: form.tag,
     selector: [...form.selector],
     selector: [...form.selector],
-    fallbackTag: supportsFallback ? form.fallbackTag : '',
+    fallbackTag: form.fallbackTag || '',
   };
   };
   if (form.strategy && form.strategy !== 'random') {
   if (form.strategy && form.strategy !== 'random') {
     out.strategy = { type: form.strategy };
     out.strategy = { type: form.strategy };
@@ -218,11 +220,11 @@ const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory
 
 
 const obsView = ref('observatory');
 const obsView = ref('observatory');
 
 
-// Keep the radio selection valid as observatories appear/disappear —
-// e.g. deleting the last leastPing balancer should flip the editor to
-// the burstObservatory pane instead of leaving it pointing at the
-// (now-removed) observatory key.
-watch(showObsEditor, () => {
+// Watch each flag individually — watching showObsEditor (OR of the two)
+// misses the case where one observatory swaps for the other in the same
+// tick, leaving obsView pointing at a now-deleted key and JsonEditor
+// trying to parse an empty string.
+watch([hasObservatory, hasBurstObservatory], () => {
   if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
   if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
     obsView.value = 'burstObservatory';
     obsView.value = 'burstObservatory';
   } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
   } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {

+ 2 - 3
frontend/src/pages/xray/BasicsTab.vue

@@ -340,7 +340,6 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.accessLogDesc') }}</template>
         <template #description>{{ t('pages.xray.accessLogDesc') }}</template>
         <template #control>
         <template #control>
           <a-select v-model:value="accessLog" :style="{ width: '100%' }">
           <a-select v-model:value="accessLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
             <a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
             <a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
           </a-select>
         </template>
         </template>
@@ -351,7 +350,7 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.errorLogDesc') }}</template>
         <template #description>{{ t('pages.xray.errorLogDesc') }}</template>
         <template #control>
         <template #control>
           <a-select v-model:value="errorLog" :style="{ width: '100%' }">
           <a-select v-model:value="errorLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option value="">{{ t('empty') }}</a-select-option>
             <a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
             <a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
           </a-select>
         </template>
         </template>
@@ -362,7 +361,7 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
         <template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
         <template #control>
         <template #control>
           <a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
           <a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option value="">{{ t('empty') }}</a-select-option>
             <a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
             <a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
           </a-select>
         </template>
         </template>

+ 43 - 3
frontend/src/pages/xray/OutboundFormModal.vue

@@ -210,7 +210,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Outbounds')}`,
     : `+ ${t('pages.xray.Outbounds')}`,
 );
 );
 const okText = computed(() =>
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 );
 
 
 // Helper getters / shortcuts used by the template.
 // Helper getters / shortcuts used by the template.
@@ -328,6 +328,47 @@ function regenerateWgKeys() {
                 </a-select>
                 </a-select>
               </a-form-item>
               </a-form-item>
             </template>
             </template>
+
+            <a-form-item label="Final Rules">
+              <a-button size="small" type="primary" @click="outbound.settings.addFinalRule('allow')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+              <span class="ml-8" style="opacity: 0.6;">
+                Override Xray's default private-IP block (needed for LAN access through proxy)
+              </span>
+            </a-form-item>
+            <template v-for="(rule, index) in outbound.settings.finalRules || []" :key="`fr-${index}`">
+              <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
+                <div class="item-heading">
+                  <span>Rule {{ index + 1 }}</span>
+                  <DeleteOutlined class="danger-icon" @click="outbound.settings.delFinalRule(index)" />
+                </div>
+              </a-form-item>
+              <a-form-item label="Action">
+                <a-select v-model:value="rule.action">
+                  <a-select-option v-for="x in ['allow', 'block']" :key="x" :value="x">{{ x }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Network">
+                <a-select v-model:value="rule.network" allow-clear placeholder="(any)">
+                  <a-select-option value="tcp">tcp</a-select-option>
+                  <a-select-option value="udp">udp</a-select-option>
+                  <a-select-option value="tcp,udp">tcp,udp</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Port">
+                <a-input v-model:value="rule.port" placeholder="e.g. 80,443 or 1000-2000" />
+              </a-form-item>
+              <a-form-item label="IP / CIDR / geoip">
+                <a-select v-model:value="rule.ip" mode="tags" :token-separators="[',', ' ']"
+                  placeholder="e.g. 10.0.0.0/8, geoip:private, ext:cn.dat:cn" />
+              </a-form-item>
+              <a-form-item v-if="rule.action === 'block'" label="Block delay (ms)">
+                <a-input v-model:value="rule.blockDelay" placeholder="optional: 5000-10000" />
+              </a-form-item>
+            </template>
           </template>
           </template>
 
 
           <!-- ============== Blackhole ============== -->
           <!-- ============== Blackhole ============== -->
@@ -343,8 +384,7 @@ function regenerateWgKeys() {
           <!-- ============== Loopback ============== -->
           <!-- ============== Loopback ============== -->
           <template v-if="isLoopback">
           <template v-if="isLoopback">
             <a-form-item label="Inbound tag">
             <a-form-item label="Inbound tag">
-              <a-input v-model:value="outbound.settings.inboundTag"
-                placeholder="inbound tag using in routing rules" />
+              <a-input v-model:value="outbound.settings.inboundTag" placeholder="inbound tag using in routing rules" />
             </a-form-item>
             </a-form-item>
           </template>
           </template>
 
 

+ 3 - 3
frontend/src/pages/xray/RuleFormModal.vue

@@ -129,7 +129,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Routings')}`,
     : `+ ${t('pages.xray.Routings')}`,
 );
 );
 const okText = computed(() =>
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 );
 
 
 const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
 const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
@@ -248,7 +248,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
       <a-form-item label="Outbound tag">
       <a-form-item label="Outbound tag">
         <a-select v-model:value="form.outboundTag">
         <a-select v-model:value="form.outboundTag">
           <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
           <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
-            }}</a-select-option>
+          }}</a-select-option>
         </a-select>
         </a-select>
       </a-form-item>
       </a-form-item>
 
 
@@ -261,7 +261,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
         </template>
         </template>
         <a-select v-model:value="form.balancerTag">
         <a-select v-model:value="form.balancerTag">
           <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
           <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
-            }}</a-select-option>
+          }}</a-select-option>
         </a-select>
         </a-select>
       </a-form-item>
       </a-form-item>
     </a-form>
     </a-form>

+ 8 - 6
frontend/src/utils/index.js

@@ -33,29 +33,31 @@ export class HttpUtil {
     }
     }
 
 
     static async get(url, params, options = {}) {
     static async get(url, params, options = {}) {
+        const { silent, ...axiosOpts } = options;
         try {
         try {
-            const resp = await axios.get(url, { params, ...options });
+            const resp = await axios.get(url, { params, ...axiosOpts });
             const msg = this._respToMsg(resp);
             const msg = this._respToMsg(resp);
-            this._handleMsg(msg);
+            if (!silent) this._handleMsg(msg);
             return msg;
             return msg;
         } catch (error) {
         } catch (error) {
             console.error('GET request failed:', error);
             console.error('GET request failed:', error);
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
-            this._handleMsg(errorMsg);
+            if (!silent) this._handleMsg(errorMsg);
             return errorMsg;
             return errorMsg;
         }
         }
     }
     }
 
 
     static async post(url, data, options = {}) {
     static async post(url, data, options = {}) {
+        const { silent, ...axiosOpts } = options;
         try {
         try {
-            const resp = await axios.post(url, data, options);
+            const resp = await axios.post(url, data, axiosOpts);
             const msg = this._respToMsg(resp);
             const msg = this._respToMsg(resp);
-            this._handleMsg(msg);
+            if (!silent) this._handleMsg(msg);
             return msg;
             return msg;
         } catch (error) {
         } catch (error) {
             console.error('POST request failed:', error);
             console.error('POST request failed:', error);
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
-            this._handleMsg(errorMsg);
+            if (!silent) this._handleMsg(errorMsg);
             return errorMsg;
             return errorMsg;
         }
         }
     }
     }

+ 15 - 10
frontend/vite.config.js

@@ -9,7 +9,14 @@ const BACKEND_TARGET = 'http://localhost:2053';
 
 
 function resolveDBPath() {
 function resolveDBPath() {
   const envFolder = process.env.XUI_DB_FOLDER;
   const envFolder = process.env.XUI_DB_FOLDER;
-  if (envFolder) return path.join(envFolder, 'x-ui.db');
+  if (envFolder) {
+    const abs = path.isAbsolute(envFolder)
+      ? envFolder
+      : path.resolve(__dirname, '..', envFolder);
+    return path.join(abs, 'x-ui.db');
+  }
+  const repoSubDB = path.resolve(__dirname, '..', 'x-ui', 'x-ui.db');
+  if (fs.existsSync(repoSubDB)) return repoSubDB;
   const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
   const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
   if (fs.existsSync(repoDB)) return repoDB;
   if (fs.existsSync(repoDB)) return repoDB;
   return '/etc/x-ui/x-ui.db';
   return '/etc/x-ui/x-ui.db';
@@ -22,6 +29,8 @@ const BASE_MIGRATED_ROUTES = {
   'panel/settings/': '/settings.html',
   'panel/settings/': '/settings.html',
   'panel/inbounds': '/inbounds.html',
   'panel/inbounds': '/inbounds.html',
   'panel/inbounds/': '/inbounds.html',
   'panel/inbounds/': '/inbounds.html',
+  'panel/clients': '/clients.html',
+  'panel/clients/': '/clients.html',
   'panel/xray': '/xray.html',
   'panel/xray': '/xray.html',
   'panel/xray/': '/xray.html',
   'panel/xray/': '/xray.html',
   'panel/nodes': '/nodes.html',
   'panel/nodes': '/nodes.html',
@@ -76,19 +85,14 @@ function injectBasePathPlugin() {
 function bypassMigratedRoute(req) {
 function bypassMigratedRoute(req) {
   if (req.method !== 'GET') return undefined;
   if (req.method !== 'GET') return undefined;
   const url = req.url.split('?')[0];
   const url = req.url.split('?')[0];
+  const basePath = refreshBasePath();
 
 
-  for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
-    if (url === '/' + key) return value;
-  }
+  if (url === basePath) return '/login.html';
 
 
-  const m = url.match(/^\/[^/]+\/(.+)$/);
-  if (m) {
-    const stripped = m[1];
+  if (url.startsWith(basePath)) {
+    const stripped = url.slice(basePath.length);
     if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
     if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
   }
   }
-
-  if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
-
   return undefined;
   return undefined;
 }
 }
 
 
@@ -150,6 +154,7 @@ export default defineConfig({
         login: path.resolve(__dirname, 'login.html'),
         login: path.resolve(__dirname, 'login.html'),
         settings: path.resolve(__dirname, 'settings.html'),
         settings: path.resolve(__dirname, 'settings.html'),
         inbounds: path.resolve(__dirname, 'inbounds.html'),
         inbounds: path.resolve(__dirname, 'inbounds.html'),
+        clients: path.resolve(__dirname, 'clients.html'),
         xray: path.resolve(__dirname, 'xray.html'),
         xray: path.resolve(__dirname, 'xray.html'),
         nodes: path.resolve(__dirname, 'nodes.html'),
         nodes: path.resolve(__dirname, 'nodes.html'),
         apiDocs: path.resolve(__dirname, 'api-docs.html'),
         apiDocs: path.resolve(__dirname, 'api-docs.html'),

+ 7 - 2
go.mod

@@ -12,7 +12,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/joho/godotenv v1.5.1
-	github.com/mymmrac/telego v1.8.0
+	github.com/mymmrac/telego v1.9.0
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/robfig/cron/v3 v3.0.1
@@ -25,8 +25,9 @@ require (
 	golang.org/x/crypto v0.51.0
 	golang.org/x/crypto v0.51.0
 	golang.org/x/sys v0.44.0
 	golang.org/x/sys v0.44.0
 	golang.org/x/text v0.37.0
 	golang.org/x/text v0.37.0
-	google.golang.org/grpc v1.81.0
+	google.golang.org/grpc v1.81.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
+	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.1
 	gorm.io/gorm v1.31.1
 )
 )
@@ -53,6 +54,10 @@ require (
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.9.2 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect

+ 15 - 4
go.sum

@@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
 github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
 github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
 github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
 github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -130,8 +138,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
-github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
+github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
+github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
 github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
 github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
@@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -258,8 +267,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
-google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -272,6 +281,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
 gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=

+ 31 - 1
main.go

@@ -73,7 +73,13 @@ func runWebServer() {
 
 
 	sigCh := make(chan os.Signal, 1)
 	sigCh := make(chan os.Signal, 1)
 	// Trap shutdown signals
 	// Trap shutdown signals
-	signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
+	signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt)
+	global.SetRestartHook(func() {
+		select {
+		case sigCh <- syscall.SIGHUP:
+		default:
+		}
+	})
 	for {
 	for {
 		sig := <-sigCh
 		sig := <-sigCh
 
 
@@ -439,6 +445,12 @@ func main() {
 
 
 	runCmd := flag.NewFlagSet("run", flag.ExitOnError)
 	runCmd := flag.NewFlagSet("run", flag.ExitOnError)
 
 
+	migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
+	var migrateDsn string
+	var migrateSrc string
+	migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
+	migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
+
 	settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
 	settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
 	var port int
 	var port int
 	var username string
 	var username string
@@ -482,6 +494,7 @@ func main() {
 		fmt.Println("Commands:")
 		fmt.Println("Commands:")
 		fmt.Println("    run            run web panel")
 		fmt.Println("    run            run web panel")
 		fmt.Println("    migrate        migrate form other/old x-ui")
 		fmt.Println("    migrate        migrate form other/old x-ui")
+		fmt.Println("    migrate-db     copy data from the SQLite file into a PostgreSQL database")
 		fmt.Println("    setting        set settings")
 		fmt.Println("    setting        set settings")
 	}
 	}
 
 
@@ -501,6 +514,23 @@ func main() {
 		runWebServer()
 		runWebServer()
 	case "migrate":
 	case "migrate":
 		migrateDb()
 		migrateDb()
+	case "migrate-db":
+		if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
+			fmt.Println(err)
+			return
+		}
+		src := migrateSrc
+		if src == "" {
+			src = config.GetDBPath()
+		}
+		if migrateDsn == "" {
+			fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
+			return
+		}
+		if err := database.MigrateData(src, migrateDsn); err != nil {
+			fmt.Println("migration failed:", err)
+			os.Exit(1)
+		}
 	case "setting":
 	case "setting":
 		err := settingCmd.Parse(os.Args[2:])
 		err := settingCmd.Parse(os.Args[2:])
 		if err != nil {
 		if err != nil {

+ 1 - 0
sub/links.go

@@ -41,6 +41,7 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
 
 
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 	svc := p.build(host)
 	svc := p.build(host)
+	svc.projectThroughFallbackMaster(inbound)
 	return splitLinkLines(svc.GetLink(inbound, email))
 	return splitLinkLines(svc.GetLink(inbound, email))
 }
 }
 
 

+ 40 - 0
sub/links_test.go

@@ -0,0 +1,40 @@
+package sub
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestSplitLinkLines(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want []string
+	}{
+		{"single_line", "vless://abc", []string{"vless://abc"}},
+		{"two_lines", "vless://abc\nvmess://xyz", []string{"vless://abc", "vmess://xyz"}},
+		{"trims_each_line", "  vless://abc  \n\tvmess://xyz\t", []string{"vless://abc", "vmess://xyz"}},
+		{"skips_blank_lines", "vless://abc\n\n\nvmess://xyz\n", []string{"vless://abc", "vmess://xyz"}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := splitLinkLines(c.in)
+			if !reflect.DeepEqual(got, c.want) {
+				t.Fatalf("splitLinkLines(%q) = %#v, want %#v", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestSplitLinkLines_EmptyInputIsNil(t *testing.T) {
+	if got := splitLinkLines(""); got != nil {
+		t.Fatalf("splitLinkLines(\"\") = %#v, want nil", got)
+	}
+}
+
+func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
+	got := splitLinkLines("   \n\t  \n")
+	if len(got) != 0 {
+		t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
+	}
+}

+ 3 - 3
sub/sub.go

@@ -207,9 +207,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 				path := c.Request.URL.Path
 				path := c.Request.URL.Path
 				pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
 				pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
 				if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
 				if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
-					assetsIndex := strings.Index(path, "/assets/")
-					if assetsIndex != -1 {
-						assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
+					_, after, ok := strings.Cut(path, "/assets/")
+					if ok {
+						assetPath := after // +8 to skip "/assets/"
 						if assetPath != "" {
 						if assetPath != "" {
 							c.FileFromFS(assetPath, assetsFS)
 							c.FileFromFS(assetPath, assetsFS)
 							c.Abort()
 							c.Abort()

+ 3 - 11
sub/subClashService.go

@@ -2,6 +2,7 @@ package sub
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"maps"
 	"strings"
 	"strings"
 
 
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
@@ -49,14 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		if clients == nil {
 		if clients == nil {
 			continue
 			continue
 		}
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.SubService.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
 		for _, client := range clients {
 			if client.SubID == subId {
 			if client.SubID == subId {
 				_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
 				_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
@@ -471,8 +465,6 @@ func cloneMap(src map[string]any) map[string]any {
 		return nil
 		return nil
 	}
 	}
 	dst := make(map[string]any, len(src))
 	dst := make(map[string]any, len(src))
-	for k, v := range src {
-		dst[k] = v
-	}
+	maps.Copy(dst, src)
 	return dst
 	return dst
 }
 }

+ 15 - 3
sub/subController.go

@@ -15,6 +15,18 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
 )
 )
 
 
+// writeSubError translates a service-layer result into an HTTP response.
+// A nil error with no rows means the subId doesn't match anything (deleted
+// client, never-existed id) and becomes 404. A real error becomes 500. No
+// body — VPN clients only look at the status.
+func writeSubError(c *gin.Context, err error) {
+	if err == nil {
+		c.Status(http.StatusNotFound)
+		return
+	}
+	c.Status(http.StatusInternalServerError)
+}
+
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 type SUBController struct {
 type SUBController struct {
 	subTitle         string
 	subTitle         string
@@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) {
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
 	subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
 	subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
 	if err != nil || len(subs) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 	} else {
 		result := ""
 		result := ""
 		for _, sub := range subs {
 		for _, sub := range subs {
@@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	if err != nil || len(jsonSub) == 0 {
 	if err != nil || len(jsonSub) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 	} else {
 		profileUrl := a.subProfileUrl
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {
 		if profileUrl == "" {
@@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
 	if err != nil || len(clashSub) == 0 {
 	if err != nil || len(clashSub) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 	} else {
 		profileUrl := a.subProfileUrl
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {
 		if profileUrl == "" {

+ 1 - 8
sub/subJsonService.go

@@ -110,14 +110,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 		if clients == nil {
 		if clients == nil {
 			continue
 			continue
 		}
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.SubService.projectThroughFallbackMaster(inbound)
 
 
 		for _, client := range clients {
 		for _, client := range clients {
 			if client.SubID == subId {
 			if client.SubID == subId {

+ 92 - 26
sub/subService.go

@@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 	}
 	}
 
 
 	if len(inbounds) == 0 {
 	if len(inbounds) == 0 {
-		return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
+		return nil, 0, traffic, nil
 	}
 	}
 
 
 	s.datepicker, err = s.settingService.GetDatepicker()
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -92,14 +92,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 		if clients == nil {
 		if clients == nil {
 			continue
 			continue
 		}
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
 		for _, client := range clients {
 			if client.SubID == subId {
 			if client.SubID == subId {
 				if client.Enable {
 				if client.Enable {
@@ -144,15 +137,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	var inbounds []*model.Inbound
-	// allow "hysteria2" so imports stored with the literal v2 protocol
-	// string still surface here (#4081)
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
 		SELECT DISTINCT inbounds.id
 		SELECT DISTINCT inbounds.id
-		FROM inbounds,
-			JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
+		FROM inbounds
+		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
+		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
 		WHERE
-			protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
-			AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
+			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Find(&inbounds).Error
 	)`, subId, true).Find(&inbounds).Error
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -193,16 +185,89 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
 		return "", 0, "", err
 		return "", 0, "", err
 	}
 	}
 
 
-	var stream map[string]any
-	json.Unmarshal([]byte(streamSettings), &stream)
-	var masterStream map[string]any
-	json.Unmarshal([]byte(inbound.StreamSettings), &masterStream)
-	stream["security"] = masterStream["security"]
-	stream["tlsSettings"] = masterStream["tlsSettings"]
-	stream["externalProxy"] = masterStream["externalProxy"]
-	modifiedStream, _ := json.MarshalIndent(stream, "", "  ")
+	return inbound.Listen, inbound.Port, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil
+}
+
+// projectThroughFallbackMaster mutates the inbound in place so its
+// Listen/Port/StreamSettings reflect the externally reachable master
+// when applicable. Covers both fallback mechanisms:
+//   - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id
+//   - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/
+//     Trojan inbound's settings.fallbacks references that listen address
+//
+// Returns true when a projection happened; sub services call this before
+// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
+// master's :443 + TLS state instead of its own loopback endpoint.
+func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
+	if inbound == nil {
+		return false
+	}
+	db := database.GetDB()
+	var master *model.Inbound
+
+	var rule model.InboundFallback
+	if err := db.Where("child_id = ?", inbound.Id).
+		Order("sort_order ASC, id ASC").
+		First(&rule).Error; err == nil {
+		var m model.Inbound
+		if err := db.Where("id = ?", rule.MasterId).First(&m).Error; err == nil {
+			master = &m
+		}
+	}
 
 
-	return inbound.Listen, inbound.Port, string(modifiedStream), nil
+	if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
+		var m model.Inbound
+		if err := db.Model(model.Inbound{}).
+			Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
+			Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen).
+			First(&m).Error; err == nil {
+			master = &m
+		}
+	}
+
+	if master == nil {
+		return false
+	}
+	inbound.StreamSettings = mergeStreamFromMaster(inbound.StreamSettings, master.StreamSettings)
+	inbound.Listen = master.Listen
+	inbound.Port = master.Port
+	return true
+}
+
+// mergeStreamFromMaster copies the master's security + tlsSettings +
+// realitySettings + externalProxy onto the child's stream so the child's
+// link advertises the master's TLS / Reality state. Transport (network
+// + ws/grpc/etc. settings) stays the child's.
+func mergeStreamFromMaster(childStream, masterStream string) string {
+	var stream map[string]any
+	json.Unmarshal([]byte(childStream), &stream)
+	if stream == nil {
+		stream = map[string]any{}
+	}
+	var mst map[string]any
+	json.Unmarshal([]byte(masterStream), &mst)
+	if mst == nil {
+		return childStream
+	}
+	stream["security"] = mst["security"]
+	if v, ok := mst["tlsSettings"]; ok {
+		stream["tlsSettings"] = v
+	} else {
+		delete(stream, "tlsSettings")
+	}
+	if v, ok := mst["realitySettings"]; ok {
+		stream["realitySettings"] = v
+	} else {
+		delete(stream, "realitySettings")
+	}
+	if v, ok := mst["externalProxy"]; ok {
+		stream["externalProxy"] = v
+	}
+	out, err := json.MarshalIndent(stream, "", "  ")
+	if err != nil {
+		return childStream
+	}
+	return string(out)
 }
 }
 
 
 // GetLink dispatches to the protocol-specific generator for one (inbound, client)
 // GetLink dispatches to the protocol-specific generator for one (inbound, client)
@@ -536,8 +601,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		return strings.Join(links, "\n")
 		return strings.Join(links, "\n")
 	}
 	}
 
 
-	// No external proxy configured — fall back to the request host.
-	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
+	// No external proxy configured — use the inbound's resolved address so
+	// node-managed inbounds get the node's host instead of the central panel's.
+	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port)
 	url, _ := url.Parse(link)
 	url, _ := url.Parse(link)
 	q := url.Query()
 	q := url.Query()
 	for k, v := range params {
 	for k, v := range params {

+ 480 - 0
sub/subService_test.go

@@ -0,0 +1,480 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestFindClientIndex(t *testing.T) {
+	clients := []model.Client{
+		{Email: "[email protected]"},
+		{Email: "[email protected]"},
+		{Email: "[email protected]"},
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != 1 {
+		t.Fatalf("findClientIndex middle = %d, want 1", got)
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != 0 {
+		t.Fatalf("findClientIndex first = %d, want 0", got)
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != -1 {
+		t.Fatalf("findClientIndex missing = %d, want -1", got)
+	}
+	if got := findClientIndex(nil, "x"); got != -1 {
+		t.Fatalf("findClientIndex on nil slice = %d, want -1", got)
+	}
+}
+
+func TestUnmarshalStreamSettings(t *testing.T) {
+	got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
+	if got["network"] != "ws" {
+		t.Fatalf("network = %v, want ws", got["network"])
+	}
+	ws, ok := got["wsSettings"].(map[string]any)
+	if !ok || ws["path"] != "/api" {
+		t.Fatalf("wsSettings = %v, want map with path=/api", got["wsSettings"])
+	}
+}
+
+func TestUnmarshalStreamSettings_InvalidJSON(t *testing.T) {
+	if got := unmarshalStreamSettings("not json"); got != nil {
+		t.Fatalf("invalid JSON should produce nil map, got %#v", got)
+	}
+}
+
+func TestSearchHost_StringValue(t *testing.T) {
+	headers := map[string]any{"Host": "example.com"}
+	if got := searchHost(headers); got != "example.com" {
+		t.Fatalf("searchHost = %q, want example.com", got)
+	}
+}
+
+func TestSearchHost_CaseInsensitiveKey(t *testing.T) {
+	headers := map[string]any{"host": "example.com"}
+	if got := searchHost(headers); got != "example.com" {
+		t.Fatalf("searchHost = %q, want example.com", got)
+	}
+	headers2 := map[string]any{"HOST": "example.com"}
+	if got := searchHost(headers2); got != "example.com" {
+		t.Fatalf("searchHost uppercase = %q, want example.com", got)
+	}
+}
+
+func TestSearchHost_ArrayValue(t *testing.T) {
+	headers := map[string]any{"Host": []any{"first.example.com", "second.example.com"}}
+	if got := searchHost(headers); got != "first.example.com" {
+		t.Fatalf("searchHost array = %q, want first.example.com", got)
+	}
+}
+
+func TestSearchHost_EmptyArray(t *testing.T) {
+	headers := map[string]any{"Host": []any{}}
+	if got := searchHost(headers); got != "" {
+		t.Fatalf("searchHost empty array = %q, want empty", got)
+	}
+}
+
+func TestSearchHost_NoHostKey(t *testing.T) {
+	headers := map[string]any{"X-Other": "value"}
+	if got := searchHost(headers); got != "" {
+		t.Fatalf("searchHost no host = %q, want empty", got)
+	}
+}
+
+func TestSearchHost_NotAMap(t *testing.T) {
+	if got := searchHost("not a map"); got != "" {
+		t.Fatalf("searchHost non-map = %q, want empty", got)
+	}
+	if got := searchHost(nil); got != "" {
+		t.Fatalf("searchHost nil = %q, want empty", got)
+	}
+}
+
+func TestSearchKey_FoundAtTopLevel(t *testing.T) {
+	data := map[string]any{"foo": 42, "bar": "x"}
+	got, ok := searchKey(data, "foo")
+	if !ok {
+		t.Fatal("expected to find foo")
+	}
+	if got != 42 {
+		t.Fatalf("got %v, want 42", got)
+	}
+}
+
+func TestSearchKey_FoundInNested(t *testing.T) {
+	data := map[string]any{
+		"outer": map[string]any{
+			"inner": map[string]any{
+				"target": "hit",
+			},
+		},
+	}
+	got, ok := searchKey(data, "target")
+	if !ok {
+		t.Fatal("expected to find target in nested map")
+	}
+	if got != "hit" {
+		t.Fatalf("got %v, want hit", got)
+	}
+}
+
+func TestSearchKey_FoundInsideArray(t *testing.T) {
+	data := map[string]any{
+		"list": []any{
+			map[string]any{"other": 1},
+			map[string]any{"needle": "found"},
+		},
+	}
+	got, ok := searchKey(data, "needle")
+	if !ok {
+		t.Fatal("expected to find needle in array element")
+	}
+	if got != "found" {
+		t.Fatalf("got %v, want found", got)
+	}
+}
+
+func TestSearchKey_NotFound(t *testing.T) {
+	data := map[string]any{"foo": "bar"}
+	if _, ok := searchKey(data, "missing"); ok {
+		t.Fatal("expected ok=false for missing key")
+	}
+}
+
+func TestSearchKey_OnScalar(t *testing.T) {
+	if _, ok := searchKey(42, "anything"); ok {
+		t.Fatal("expected ok=false searching on a scalar")
+	}
+}
+
+func TestCloneStringMap(t *testing.T) {
+	src := map[string]string{"a": "1", "b": "2"}
+	dst := cloneStringMap(src)
+	if len(dst) != len(src) {
+		t.Fatalf("clone length = %d, want %d", len(dst), len(src))
+	}
+	for k, v := range src {
+		if dst[k] != v {
+			t.Fatalf("clone[%q] = %q, want %q", k, dst[k], v)
+		}
+	}
+	dst["a"] = "changed"
+	if src["a"] == "changed" {
+		t.Fatal("modifying clone leaked into source")
+	}
+}
+
+func TestCloneStringMap_Empty(t *testing.T) {
+	dst := cloneStringMap(map[string]string{})
+	if dst == nil {
+		t.Fatal("clone of empty map should not be nil")
+	}
+	if len(dst) != 0 {
+		t.Fatalf("clone of empty map should be empty, got %v", dst)
+	}
+}
+
+func TestGetHostFromXFH_HostOnly(t *testing.T) {
+	got, err := getHostFromXFH("example.com")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "example.com" {
+		t.Fatalf("got %q, want example.com", got)
+	}
+}
+
+func TestGetHostFromXFH_HostWithPort(t *testing.T) {
+	got, err := getHostFromXFH("example.com:8443")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "example.com" {
+		t.Fatalf("got %q, want example.com", got)
+	}
+}
+
+func TestGetHostFromXFH_IPv6WithPort(t *testing.T) {
+	got, err := getHostFromXFH("[2606:4700::1111]:443")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "2606:4700::1111" {
+		t.Fatalf("got %q, want 2606:4700::1111", got)
+	}
+}
+
+func TestGetHostFromXFH_BadHostPort(t *testing.T) {
+	if _, err := getHostFromXFH("example.com:8443:9999"); err == nil {
+		t.Fatal("expected error for malformed host:port")
+	}
+}
+
+func TestReadPositiveInt(t *testing.T) {
+	cases := []struct {
+		name    string
+		in      any
+		wantVal int
+		wantOk  bool
+	}{
+		{"int_positive", int(5), 5, true},
+		{"int_zero", int(0), 0, false},
+		{"int_negative", int(-3), -3, false},
+		{"int32_positive", int32(7), 7, true},
+		{"int64_positive", int64(99), 99, true},
+		{"float64_positive", float64(12), 12, true},
+		{"float64_zero", float64(0.0), 0, false},
+		{"float64_negative", float64(-1.5), -1, false},
+		{"float32_positive", float32(3), 3, true},
+		{"string", "not a number", 0, false},
+		{"nil", nil, 0, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			gotVal, gotOk := readPositiveInt(c.in)
+			if gotVal != c.wantVal || gotOk != c.wantOk {
+				t.Fatalf("readPositiveInt(%v) = (%d, %v), want (%d, %v)", c.in, gotVal, gotOk, c.wantVal, c.wantOk)
+			}
+		})
+	}
+}
+
+func TestSetStringParam(t *testing.T) {
+	p := map[string]string{"existing": "value"}
+
+	setStringParam(p, "new", "hello")
+	if p["new"] != "hello" {
+		t.Fatalf("missing key after set: %v", p)
+	}
+
+	setStringParam(p, "existing", "")
+	if _, ok := p["existing"]; ok {
+		t.Fatalf("empty value should delete the key, got %v", p)
+	}
+}
+
+func TestSetIntParam(t *testing.T) {
+	p := map[string]string{"existing": "10"}
+
+	setIntParam(p, "n", 42)
+	if p["n"] != "42" {
+		t.Fatalf("set positive int: got %v", p)
+	}
+
+	setIntParam(p, "existing", 0)
+	if _, ok := p["existing"]; ok {
+		t.Fatalf("zero value should delete the key, got %v", p)
+	}
+
+	p["other"] = "5"
+	setIntParam(p, "other", -1)
+	if _, ok := p["other"]; ok {
+		t.Fatalf("negative value should delete the key, got %v", p)
+	}
+}
+
+func TestSetStringField(t *testing.T) {
+	f := map[string]any{"existing": "value"}
+
+	setStringField(f, "new", "hello")
+	if f["new"] != "hello" {
+		t.Fatalf("missing key after set: %v", f)
+	}
+
+	setStringField(f, "existing", "")
+	if _, ok := f["existing"]; ok {
+		t.Fatalf("empty value should delete the key, got %v", f)
+	}
+}
+
+func TestSetIntField(t *testing.T) {
+	f := map[string]any{"existing": 10}
+
+	setIntField(f, "n", 7)
+	if f["n"] != 7 {
+		t.Fatalf("set positive int: got %v", f)
+	}
+
+	setIntField(f, "existing", 0)
+	if _, ok := f["existing"]; ok {
+		t.Fatalf("zero value should delete the key, got %v", f)
+	}
+}
+
+func TestBuildVmessLink(t *testing.T) {
+	obj := map[string]any{
+		"v":    "2",
+		"ps":   "remark",
+		"add":  "example.com",
+		"port": 443,
+		"net":  "tcp",
+	}
+	link := buildVmessLink(obj)
+	if !strings.HasPrefix(link, "vmess://") {
+		t.Fatalf("missing vmess:// prefix: %q", link)
+	}
+	payload := strings.TrimPrefix(link, "vmess://")
+	decoded, err := base64.StdEncoding.DecodeString(payload)
+	if err != nil {
+		t.Fatalf("base64 decode failed: %v", err)
+	}
+	var roundTrip map[string]any
+	if err := json.Unmarshal(decoded, &roundTrip); err != nil {
+		t.Fatalf("decoded payload is not JSON: %v\n%s", err, decoded)
+	}
+	if roundTrip["add"] != "example.com" {
+		t.Fatalf("round-trip add = %v, want example.com", roundTrip["add"])
+	}
+	if roundTrip["ps"] != "remark" {
+		t.Fatalf("round-trip ps = %v, want remark", roundTrip["ps"])
+	}
+}
+
+func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) {
+	base := map[string]any{
+		"v":    "2",
+		"sni":  "example.com",
+		"alpn": "h2",
+		"fp":   "chrome",
+		"net":  "tcp",
+	}
+	out := cloneVmessShareObj(base, "tls")
+	for _, key := range []string{"sni", "alpn", "fp", "net", "v"} {
+		if _, ok := out[key]; !ok {
+			t.Fatalf("expected key %q to be preserved when security=tls, got %v", key, out)
+		}
+	}
+}
+
+func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
+	base := map[string]any{
+		"v":    "2",
+		"sni":  "example.com",
+		"alpn": "h2",
+		"fp":   "chrome",
+		"net":  "tcp",
+	}
+	out := cloneVmessShareObj(base, "none")
+	for _, key := range []string{"sni", "alpn", "fp"} {
+		if _, ok := out[key]; ok {
+			t.Fatalf("security=none should strip %q, got %v", key, out)
+		}
+	}
+	if out["v"] != "2" || out["net"] != "tcp" {
+		t.Fatalf("non-TLS keys should remain, got %v", out)
+	}
+}
+
+func TestExtractKcpShareFields_Defaults(t *testing.T) {
+	stream := map[string]any{}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "none" {
+		t.Fatalf("default headerType = %q, want none", got.headerType)
+	}
+	if got.seed != "" || got.mtu != 0 || got.tti != 0 {
+		t.Fatalf("default kcpShareFields should be zero except headerType, got %+v", got)
+	}
+}
+
+func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
+	stream := map[string]any{
+		"kcpSettings": map[string]any{
+			"header": map[string]any{"type": "wechat-video"},
+			"seed":   "secret-seed",
+			"mtu":    float64(1350),
+			"tti":    float64(50),
+		},
+	}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "wechat-video" {
+		t.Fatalf("headerType = %q, want wechat-video", got.headerType)
+	}
+	if got.seed != "secret-seed" {
+		t.Fatalf("seed = %q, want secret-seed", got.seed)
+	}
+	if got.mtu != 1350 {
+		t.Fatalf("mtu = %d, want 1350", got.mtu)
+	}
+	if got.tti != 50 {
+		t.Fatalf("tti = %d, want 50", got.tti)
+	}
+}
+
+func TestKcpShareFields_ApplyToParams(t *testing.T) {
+	params := map[string]string{}
+	kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)
+	if params["headerType"] != "wechat-video" {
+		t.Fatalf("headerType param = %q", params["headerType"])
+	}
+	if params["seed"] != "s" {
+		t.Fatalf("seed param = %q", params["seed"])
+	}
+	if params["mtu"] != "1350" {
+		t.Fatalf("mtu param = %q", params["mtu"])
+	}
+	if params["tti"] != "50" {
+		t.Fatalf("tti param = %q", params["tti"])
+	}
+}
+
+func TestKcpShareFields_ApplyToParams_NoneHeaderNotAdded(t *testing.T) {
+	params := map[string]string{}
+	kcpShareFields{headerType: "none"}.applyToParams(params)
+	if _, ok := params["headerType"]; ok {
+		t.Fatalf("headerType=none should not be added, got %v", params)
+	}
+}
+
+func TestMarshalFinalMask_EmptyReturnsFalse(t *testing.T) {
+	if _, ok := marshalFinalMask(map[string]any{}); ok {
+		t.Fatal("expected ok=false for empty finalmask")
+	}
+	if _, ok := marshalFinalMask(nil); ok {
+		t.Fatal("expected ok=false for nil finalmask")
+	}
+}
+
+func TestMarshalFinalMask_WithContent(t *testing.T) {
+	fm := map[string]any{
+		"tcp": []any{
+			map[string]any{"type": "fragment"},
+		},
+	}
+	out, ok := marshalFinalMask(fm)
+	if !ok {
+		t.Fatal("expected ok=true for finalmask with valid tcp mask")
+	}
+	if !strings.Contains(out, `"tcp"`) {
+		t.Fatalf("marshaled finalmask missing tcp key: %s", out)
+	}
+	if !strings.Contains(out, "fragment") {
+		t.Fatalf("marshaled finalmask missing mask type: %s", out)
+	}
+}
+
+func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
+	fm := map[string]any{
+		"tcp": []any{
+			map[string]any{"type": "not-a-real-mask"},
+		},
+	}
+	if _, ok := marshalFinalMask(fm); ok {
+		t.Fatal("unknown mask types should be dropped, leaving nothing to marshal")
+	}
+}
+
+func TestHasFinalMaskContent(t *testing.T) {
+	if hasFinalMaskContent(nil) {
+		t.Fatal("nil should not count as content")
+	}
+	if hasFinalMaskContent(map[string]any{}) {
+		t.Fatal("empty map should not count as content")
+	}
+	if !hasFinalMaskContent(map[string]any{"x": 1}) {
+		t.Fatal("non-empty map should count as content")
+	}
+}

+ 28 - 0
util/common/format_test.go

@@ -0,0 +1,28 @@
+package common
+
+import "testing"
+
+func TestFormatTraffic(t *testing.T) {
+	cases := []struct {
+		name  string
+		bytes int64
+		want  string
+	}{
+		{"zero", 0, "0.00B"},
+		{"under_one_kb", 512, "512.00B"},
+		{"exactly_one_kb", 1024, "1.00KB"},
+		{"one_and_a_half_kb", 1536, "1.50KB"},
+		{"one_mb", 1024 * 1024, "1.00MB"},
+		{"one_gb", 1024 * 1024 * 1024, "1.00GB"},
+		{"one_tb", 1024 * 1024 * 1024 * 1024, "1.00TB"},
+		{"one_pb", 1024 * 1024 * 1024 * 1024 * 1024, "1.00PB"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := FormatTraffic(c.bytes)
+			if got != c.want {
+				t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
+			}
+		})
+	}
+}

+ 44 - 0
util/common/multi_error_test.go

@@ -0,0 +1,44 @@
+package common
+
+import (
+	"errors"
+	"strings"
+	"testing"
+)
+
+func TestCombine_AllNilReturnsNil(t *testing.T) {
+	if err := Combine(); err != nil {
+		t.Fatalf("Combine() with no args = %v, want nil", err)
+	}
+	if err := Combine(nil, nil, nil); err != nil {
+		t.Fatalf("Combine(nil, nil, nil) = %v, want nil", err)
+	}
+}
+
+func TestCombine_SkipsNilErrors(t *testing.T) {
+	e1 := errors.New("boom one")
+	e2 := errors.New("boom two")
+
+	err := Combine(nil, e1, nil, e2, nil)
+	if err == nil {
+		t.Fatal("expected non-nil combined error")
+	}
+	msg := err.Error()
+	if !strings.Contains(msg, "boom one") || !strings.Contains(msg, "boom two") {
+		t.Fatalf("combined error %q does not contain both underlying messages", msg)
+	}
+	if !strings.HasPrefix(msg, "multierr: ") {
+		t.Fatalf("combined error %q missing %q prefix", msg, "multierr: ")
+	}
+}
+
+func TestCombine_SingleErrorStillWrapped(t *testing.T) {
+	e := errors.New("only one")
+	err := Combine(e)
+	if err == nil {
+		t.Fatal("expected non-nil error")
+	}
+	if !strings.Contains(err.Error(), "only one") {
+		t.Fatalf("combined error %q missing underlying message", err.Error())
+	}
+}

+ 69 - 0
util/crypto/crypto_test.go

@@ -0,0 +1,69 @@
+package crypto
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestHashPasswordAsBcrypt_RoundTrip(t *testing.T) {
+	password := "correct horse battery staple"
+
+	hash, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
+	}
+	if hash == "" {
+		t.Fatal("expected non-empty hash")
+	}
+	if hash == password {
+		t.Fatal("hash must not equal the plaintext password")
+	}
+	if !strings.HasPrefix(hash, "$2") {
+		t.Fatalf("expected bcrypt prefix $2..., got %q", hash[:min(4, len(hash))])
+	}
+
+	if !CheckPasswordHash(hash, password) {
+		t.Fatal("CheckPasswordHash returned false for the matching password")
+	}
+}
+
+func TestCheckPasswordHash_WrongPassword(t *testing.T) {
+	hash, err := HashPasswordAsBcrypt("right-password")
+	if err != nil {
+		t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
+	}
+
+	if CheckPasswordHash(hash, "wrong-password") {
+		t.Fatal("CheckPasswordHash returned true for a wrong password")
+	}
+	if CheckPasswordHash(hash, "") {
+		t.Fatal("CheckPasswordHash returned true for an empty password")
+	}
+}
+
+func TestCheckPasswordHash_InvalidHash(t *testing.T) {
+	if CheckPasswordHash("", "anything") {
+		t.Fatal("empty hash must not validate")
+	}
+	if CheckPasswordHash("not-a-bcrypt-hash", "anything") {
+		t.Fatal("malformed hash must not validate")
+	}
+}
+
+func TestHashPasswordAsBcrypt_DifferentHashesForSamePassword(t *testing.T) {
+	password := "same-password"
+	h1, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("first hash failed: %v", err)
+	}
+	h2, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("second hash failed: %v", err)
+	}
+	if h1 == h2 {
+		t.Fatal("expected bcrypt to produce different hashes (random salt) for the same password")
+	}
+	if !CheckPasswordHash(h1, password) || !CheckPasswordHash(h2, password) {
+		t.Fatal("both hashes should still validate the original password")
+	}
+}

+ 76 - 0
util/json_util/json_test.go

@@ -0,0 +1,76 @@
+package json_util
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+)
+
+func TestRawMessage_MarshalEmptyIsNull(t *testing.T) {
+	var m RawMessage
+	out, err := m.MarshalJSON()
+	if err != nil {
+		t.Fatalf("MarshalJSON on empty returned error: %v", err)
+	}
+	if !bytes.Equal(out, []byte("null")) {
+		t.Fatalf("empty RawMessage marshaled to %q, want %q", out, "null")
+	}
+}
+
+func TestRawMessage_MarshalPassthrough(t *testing.T) {
+	payload := []byte(`{"a":1}`)
+	m := RawMessage(payload)
+	out, err := m.MarshalJSON()
+	if err != nil {
+		t.Fatalf("MarshalJSON returned error: %v", err)
+	}
+	if !bytes.Equal(out, payload) {
+		t.Fatalf("MarshalJSON = %q, want %q", out, payload)
+	}
+}
+
+func TestRawMessage_UnmarshalCopiesData(t *testing.T) {
+	var m RawMessage
+	src := []byte(`{"k":"v"}`)
+	if err := m.UnmarshalJSON(src); err != nil {
+		t.Fatalf("UnmarshalJSON returned error: %v", err)
+	}
+	if !bytes.Equal(m, src) {
+		t.Fatalf("UnmarshalJSON stored %q, want %q", []byte(m), src)
+	}
+
+	src[0] = 'X'
+	if m[0] == 'X' {
+		t.Fatal("UnmarshalJSON kept a reference to the caller's buffer; expected a copy")
+	}
+}
+
+func TestRawMessage_UnmarshalNilReceiverErrors(t *testing.T) {
+	var m *RawMessage
+	if err := m.UnmarshalJSON([]byte("123")); err == nil {
+		t.Fatal("expected error for nil receiver")
+	}
+}
+
+func TestRawMessage_RoundTripInsideStruct(t *testing.T) {
+	type wrapper struct {
+		Body RawMessage `json:"body"`
+	}
+	in := wrapper{Body: RawMessage(`{"x":42}`)}
+	encoded, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("json.Marshal returned error: %v", err)
+	}
+	want := `{"body":{"x":42}}`
+	if string(encoded) != want {
+		t.Fatalf("Marshal = %s, want %s", encoded, want)
+	}
+
+	var out wrapper
+	if err := json.Unmarshal(encoded, &out); err != nil {
+		t.Fatalf("json.Unmarshal returned error: %v", err)
+	}
+	if string(out.Body) != `{"x":42}` {
+		t.Fatalf("round-trip Body = %s, want %s", out.Body, `{"x":42}`)
+	}
+}

+ 2 - 7
util/ldap/ldap.go

@@ -3,6 +3,7 @@ package ldaputil
 import (
 import (
 	"crypto/tls"
 	"crypto/tls"
 	"fmt"
 	"fmt"
+	"slices"
 
 
 	"github.com/go-ldap/ldap/v3"
 	"github.com/go-ldap/ldap/v3"
 )
 )
@@ -82,13 +83,7 @@ func FetchVlessFlags(cfg Config) (map[string]bool, error) {
 			continue
 			continue
 		}
 		}
 		val := e.GetAttributeValue(cfg.FlagField)
 		val := e.GetAttributeValue(cfg.FlagField)
-		enabled := false
-		for _, t := range cfg.TruthyVals {
-			if val == t {
-				enabled = true
-				break
-			}
-		}
+		enabled := slices.Contains(cfg.TruthyVals, val)
 		if cfg.Invert {
 		if cfg.Invert {
 			enabled = !enabled
 			enabled = !enabled
 		}
 		}

+ 127 - 0
util/netsafe/netsafe_test.go

@@ -0,0 +1,127 @@
+package netsafe
+
+import (
+	"context"
+	"net"
+	"strings"
+	"testing"
+)
+
+func TestIsBlockedIP(t *testing.T) {
+	cases := []struct {
+		ip   string
+		want bool
+	}{
+		{"127.0.0.1", true},
+		{"::1", true},
+		{"10.0.0.5", true},
+		{"172.16.0.1", true},
+		{"192.168.1.1", true},
+		{"169.254.0.1", true},
+		{"0.0.0.0", true},
+		{"::", true},
+		{"8.8.8.8", false},
+		{"1.1.1.1", false},
+		{"2606:4700:4700::1111", false},
+	}
+	for _, c := range cases {
+		t.Run(c.ip, func(t *testing.T) {
+			ip := net.ParseIP(c.ip)
+			if ip == nil {
+				t.Fatalf("could not parse %q", c.ip)
+			}
+			if got := IsBlockedIP(ip); got != c.want {
+				t.Fatalf("IsBlockedIP(%s) = %v, want %v", c.ip, got, c.want)
+			}
+		})
+	}
+}
+
+func TestAllowPrivateFromContext_Default(t *testing.T) {
+	if AllowPrivateFromContext(context.Background()) {
+		t.Fatal("default context should report AllowPrivate=false")
+	}
+}
+
+func TestAllowPrivateFromContext_RoundTrip(t *testing.T) {
+	ctx := ContextWithAllowPrivate(context.Background(), true)
+	if !AllowPrivateFromContext(ctx) {
+		t.Fatal("expected AllowPrivate=true after ContextWithAllowPrivate(true)")
+	}
+	ctx = ContextWithAllowPrivate(ctx, false)
+	if AllowPrivateFromContext(ctx) {
+		t.Fatal("expected AllowPrivate=false after overriding with false")
+	}
+}
+
+func TestNormalizeHost_Valid(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"example.com", "example.com"},
+		{"  example.com  ", "example.com"},
+		{"a.b.c.example.com", "a.b.c.example.com"},
+		{"10.0.0.1", "10.0.0.1"},
+		{"[2606:4700:4700::1111]", "2606:4700:4700::1111"},
+		{"2606:4700:4700::1111", "2606:4700:4700::1111"},
+	}
+	for _, c := range cases {
+		t.Run(c.in, func(t *testing.T) {
+			got, err := NormalizeHost(c.in)
+			if err != nil {
+				t.Fatalf("NormalizeHost(%q) returned error: %v", c.in, err)
+			}
+			if !strings.EqualFold(got, c.want) {
+				t.Fatalf("NormalizeHost(%q) = %q, want %q", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestNormalizeHost_Invalid(t *testing.T) {
+	cases := []string{
+		"",
+		"   ",
+		"-leading-dash.com",
+		"trailing-dash-.com",
+		"bad host with spaces",
+		"under_score.example.com",
+		"exa$mple.com",
+		strings.Repeat("a", 254),
+	}
+	for _, in := range cases {
+		t.Run(in, func(t *testing.T) {
+			if _, err := NormalizeHost(in); err == nil {
+				t.Fatalf("NormalizeHost(%q) expected error, got nil", in)
+			}
+		})
+	}
+}
+
+func TestSSRFGuardedDialContext_BlocksLiteralPrivateIP(t *testing.T) {
+	_, err := SSRFGuardedDialContext(context.Background(), "tcp", "127.0.0.1:1")
+	if err == nil {
+		t.Fatal("expected dial to 127.0.0.1 to be blocked")
+	}
+	if !strings.Contains(err.Error(), "blocked") {
+		t.Fatalf("expected 'blocked' in error, got: %v", err)
+	}
+}
+
+func TestSSRFGuardedDialContext_AllowPrivateBypassesGuard(t *testing.T) {
+	ctx := ContextWithAllowPrivate(context.Background(), true)
+	_, err := SSRFGuardedDialContext(ctx, "tcp", "127.0.0.1:1")
+	if err == nil {
+		t.Fatal("dial to a closed loopback port should still fail at the connect step")
+	}
+	if strings.Contains(err.Error(), "blocked private/internal address") {
+		t.Fatalf("expected guard to be bypassed when AllowPrivate=true, got: %v", err)
+	}
+}
+
+func TestSSRFGuardedDialContext_BadAddress(t *testing.T) {
+	if _, err := SSRFGuardedDialContext(context.Background(), "tcp", "no-port"); err == nil {
+		t.Fatal("expected error for address without port")
+	}
+}

+ 12 - 0
util/random/random.go

@@ -3,6 +3,7 @@ package random
 
 
 import (
 import (
 	"crypto/rand"
 	"crypto/rand"
+	"encoding/base64"
 	"math/big"
 	"math/big"
 )
 )
 
 
@@ -59,3 +60,14 @@ func Num(n int) int {
 	}
 	}
 	return int(r.Int64())
 	return int(r.Int64())
 }
 }
+
+// Base64Bytes returns n cryptographically-random bytes encoded as standard
+// base64 (with padding). Used for ss2022 keys, which xray expects as a
+// base64-encoded key of a specific byte length per cipher.
+func Base64Bytes(n int) string {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		panic("crypto/rand failed: " + err.Error())
+	}
+	return base64.StdEncoding.EncodeToString(b)
+}

+ 63 - 0
util/random/random_test.go

@@ -0,0 +1,63 @@
+package random
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func TestSeq_LengthAndAlphabet(t *testing.T) {
+	for _, n := range []int{0, 1, 8, 64, 256} {
+		s := Seq(n)
+		if len(s) != n {
+			t.Fatalf("Seq(%d) returned length %d", n, len(s))
+		}
+		for i, r := range s {
+			isDigit := r >= '0' && r <= '9'
+			isLower := r >= 'a' && r <= 'z'
+			isUpper := r >= 'A' && r <= 'Z'
+			if !(isDigit || isLower || isUpper) {
+				t.Fatalf("Seq(%d) byte %d = %q is not alphanumeric", n, i, r)
+			}
+		}
+	}
+}
+
+func TestSeq_NotConstant(t *testing.T) {
+	a := Seq(32)
+	b := Seq(32)
+	if a == b {
+		t.Fatalf("two consecutive Seq(32) calls produced identical output: %q", a)
+	}
+}
+
+func TestNum_InRange(t *testing.T) {
+	for _, upper := range []int{1, 2, 10, 1000} {
+		for range 200 {
+			v := Num(upper)
+			if v < 0 || v >= upper {
+				t.Fatalf("Num(%d) returned %d, out of [0, %d)", upper, v, upper)
+			}
+		}
+	}
+}
+
+func TestBase64Bytes_DecodesToRequestedSize(t *testing.T) {
+	for _, n := range []int{1, 16, 32, 64} {
+		out := Base64Bytes(n)
+		decoded, err := base64.StdEncoding.DecodeString(out)
+		if err != nil {
+			t.Fatalf("Base64Bytes(%d) produced invalid base64 %q: %v", n, out, err)
+		}
+		if len(decoded) != n {
+			t.Fatalf("Base64Bytes(%d) decoded to %d bytes", n, len(decoded))
+		}
+	}
+}
+
+func TestBase64Bytes_Random(t *testing.T) {
+	a := Base64Bytes(32)
+	b := Base64Bytes(32)
+	if a == b {
+		t.Fatalf("two consecutive Base64Bytes(32) calls produced identical output: %q", a)
+	}
+}

+ 11 - 18
util/sys/sys_darwin.go

@@ -1,5 +1,4 @@
 //go:build darwin
 //go:build darwin
-// +build darwin
 
 
 package sys
 package sys
 
 
@@ -33,8 +32,9 @@ func GetUDPCount() (int, error) {
 
 
 // --- CPU Utilization (macOS native) ---
 // --- CPU Utilization (macOS native) ---
 
 
-// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
-// We compute utilization deltas without cgo.
+// sysctl kern.cp_time returns 5 longs in the BSD CPUSTATES order:
+// user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4). gopsutil reads the
+// same layout in cpu_darwin_nocgo.go.
 var (
 var (
 	cpuMu       sync.Mutex
 	cpuMu       sync.Mutex
 	lastTotals  [5]uint64
 	lastTotals  [5]uint64
@@ -61,13 +61,6 @@ func CPUPercentRaw() (float64, error) {
 		return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
 		return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
 	}
 	}
 
 
-	// user, nice, sys, idle, intr
-	user := out[0]
-	nice := out[1]
-	sysv := out[2]
-	idle := out[3]
-	intr := out[4]
-
 	cpuMu.Lock()
 	cpuMu.Lock()
 	defer cpuMu.Unlock()
 	defer cpuMu.Unlock()
 
 
@@ -77,19 +70,19 @@ func CPUPercentRaw() (float64, error) {
 		return 0, nil
 		return 0, nil
 	}
 	}
 
 
-	dUser := user - lastTotals[0]
-	dNice := nice - lastTotals[1]
-	dSys := sysv - lastTotals[2]
-	dIdle := idle - lastTotals[3]
-	dIntr := intr - lastTotals[4]
-
+	var deltas [5]uint64
+	var totald uint64
+	for i := range 5 {
+		deltas[i] = out[i] - lastTotals[i]
+		totald += deltas[i]
+	}
 	lastTotals = out
 	lastTotals = out
 
 
-	totald := dUser + dNice + dSys + dIdle + dIntr
 	if totald == 0 {
 	if totald == 0 {
 		return 0, nil
 		return 0, nil
 	}
 	}
-	busy := totald - dIdle
+	idleDelta := deltas[4]
+	busy := totald - idleDelta
 	pct := float64(busy) / float64(totald) * 100.0
 	pct := float64(busy) / float64(totald) * 100.0
 	if pct > 100 {
 	if pct > 100 {
 		pct = 100
 		pct = 100

+ 38 - 75
util/sys/sys_linux.go

@@ -1,11 +1,9 @@
 //go:build linux
 //go:build linux
-// +build linux
 
 
 package sys
 package sys
 
 
 import (
 import (
 	"bufio"
 	"bufio"
-	"bytes"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"os"
 	"os"
@@ -17,80 +15,63 @@ import (
 
 
 var SIGUSR1 = syscall.SIGUSR1
 var SIGUSR1 = syscall.SIGUSR1
 
 
-func getLinesNum(filename string) (int, error) {
-	file, err := os.Open(filename)
+// countConnections returns the number of entries in a /proc/net/{tcp,udp}[6]
+// file. Returns 0 if the file is absent (e.g. /proc/net/tcp6 when IPv6 is
+// disabled) and excludes the column header line.
+func countConnections(path string) (int, error) {
+	f, err := os.Open(path)
+	if os.IsNotExist(err) {
+		return 0, nil
+	}
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-	defer file.Close()
-
-	sum := 0
-	buf := make([]byte, 8192)
-	for {
-		n, err := file.Read(buf)
-
-		var buffPosition int
-		for {
-			i := bytes.IndexByte(buf[buffPosition:n], '\n')
-			if i < 0 {
-				break
-			}
-			buffPosition += i + 1
-			sum++
-		}
+	defer f.Close()
 
 
-		if err == io.EOF {
-			break
-		} else if err != nil {
-			return 0, err
-		}
+	sc := bufio.NewScanner(f)
+	n := 0
+	for sc.Scan() {
+		n++
 	}
 	}
-	return sum, nil
+	if err := sc.Err(); err != nil {
+		return 0, err
+	}
+	if n > 0 {
+		n-- // first line is the column header
+	}
+	return n, nil
 }
 }
 
 
 // GetTCPCount returns the number of active TCP connections by reading
 // GetTCPCount returns the number of active TCP connections by reading
 // /proc/net/tcp and /proc/net/tcp6 when available.
 // /proc/net/tcp and /proc/net/tcp6 when available.
 func GetTCPCount() (int, error) {
 func GetTCPCount() (int, error) {
 	root := HostProc()
 	root := HostProc()
-
-	tcp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp", root))
+	tcp4, err := countConnections(root + "/net/tcp")
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-	tcp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp6", root))
+	tcp6, err := countConnections(root + "/net/tcp6")
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-
 	return tcp4 + tcp6, nil
 	return tcp4 + tcp6, nil
 }
 }
 
 
+// GetUDPCount returns the number of active UDP connections by reading
+// /proc/net/udp and /proc/net/udp6 when available.
 func GetUDPCount() (int, error) {
 func GetUDPCount() (int, error) {
 	root := HostProc()
 	root := HostProc()
-
-	udp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp", root))
+	udp4, err := countConnections(root + "/net/udp")
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-	udp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp6", root))
+	udp6, err := countConnections(root + "/net/udp6")
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-
 	return udp4 + udp6, nil
 	return udp4 + udp6, nil
 }
 }
 
 
-// safeGetLinesNum returns 0 if the file does not exist, otherwise forwards
-// to getLinesNum to count the number of lines.
-func safeGetLinesNum(path string) (int, error) {
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		return 0, nil
-	} else if err != nil {
-		return 0, err
-	}
-	return getLinesNum(path)
-}
-
 // --- CPU Utilization (Linux native) ---
 // --- CPU Utilization (Linux native) ---
 
 
 var (
 var (
@@ -100,10 +81,11 @@ var (
 	hasLast     bool
 	hasLast     bool
 )
 )
 
 
-// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
-// First call initializes and returns 0; subsequent calls return busy/total * 100.
+// CPUPercentRaw returns instantaneous total CPU utilization by reading
+// /proc/stat. First call initializes and returns 0; subsequent calls return
+// busy/total * 100. Uses HostProc so HOST_PROC overrides (containers) apply.
 func CPUPercentRaw() (float64, error) {
 func CPUPercentRaw() (float64, error) {
-	f, err := os.Open("/proc/stat")
+	f, err := os.Open(HostProc("stat"))
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
@@ -114,13 +96,13 @@ func CPUPercentRaw() (float64, error) {
 	if err != nil && err != io.EOF {
 	if err != nil && err != io.EOF {
 		return 0, err
 		return 0, err
 	}
 	}
-	// Expect line like: cpu  user nice system idle iowait irq softirq steal guest guest_nice
+	// Expect: cpu  user nice system idle iowait irq softirq steal guest guest_nice
 	fields := strings.Fields(line)
 	fields := strings.Fields(line)
 	if len(fields) < 5 || fields[0] != "cpu" {
 	if len(fields) < 5 || fields[0] != "cpu" {
 		return 0, fmt.Errorf("unexpected /proc/stat format")
 		return 0, fmt.Errorf("unexpected /proc/stat format")
 	}
 	}
 
 
-	var nums []uint64
+	nums := make([]uint64, 0, len(fields)-1)
 	for i := 1; i < len(fields); i++ {
 	for i := 1; i < len(fields); i++ {
 		v, err := strconv.ParseUint(fields[i], 10, 64)
 		v, err := strconv.ParseUint(fields[i], 10, 64)
 		if err != nil {
 		if err != nil {
@@ -128,35 +110,16 @@ func CPUPercentRaw() (float64, error) {
 		}
 		}
 		nums = append(nums, v)
 		nums = append(nums, v)
 	}
 	}
-	if len(nums) < 4 { // need at least user,nice,system,idle
+	if len(nums) < 4 {
 		return 0, fmt.Errorf("insufficient cpu fields")
 		return 0, fmt.Errorf("insufficient cpu fields")
 	}
 	}
-
-	// Conform with standard Linux CPU accounting
-	var user, nice, system, idle, iowait, irq, softirq, steal uint64
-	user = nums[0]
-	if len(nums) > 1 {
-		nice = nums[1]
-	}
-	if len(nums) > 2 {
-		system = nums[2]
-	}
-	if len(nums) > 3 {
-		idle = nums[3]
-	}
-	if len(nums) > 4 {
-		iowait = nums[4]
-	}
-	if len(nums) > 5 {
-		irq = nums[5]
-	}
-	if len(nums) > 6 {
-		softirq = nums[6]
-	}
-	if len(nums) > 7 {
-		steal = nums[7]
+	for len(nums) < 8 {
+		nums = append(nums, 0)
 	}
 	}
 
 
+	user, nice, system, idle := nums[0], nums[1], nums[2], nums[3]
+	iowait, irq, softirq, steal := nums[4], nums[5], nums[6], nums[7]
+
 	idleAll := idle + iowait
 	idleAll := idle + iowait
 	nonIdle := user + nice + system + irq + softirq + steal
 	nonIdle := user + nice + system + irq + softirq + steal
 	total := idleAll + nonIdle
 	total := idleAll + nonIdle

+ 14 - 22
util/sys/sys_windows.go

@@ -1,5 +1,4 @@
 //go:build windows
 //go:build windows
-// +build windows
 
 
 package sys
 package sys
 
 
@@ -10,6 +9,7 @@ import (
 	"unsafe"
 	"unsafe"
 
 
 	"github.com/shirou/gopsutil/v4/net"
 	"github.com/shirou/gopsutil/v4/net"
+	"golang.org/x/sys/windows"
 )
 )
 
 
 var SIGUSR1 = syscall.Signal(0)
 var SIGUSR1 = syscall.Signal(0)
@@ -19,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) {
 	if proto != "tcp" && proto != "udp" {
 	if proto != "tcp" && proto != "udp" {
 		return 0, errors.New("invalid protocol")
 		return 0, errors.New("invalid protocol")
 	}
 	}
-
 	stats, err := net.Connections(proto)
 	stats, err := net.Connections(proto)
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
@@ -40,7 +39,9 @@ func GetUDPCount() (int, error) {
 // --- CPU Utilization (Windows native) ---
 // --- CPU Utilization (Windows native) ---
 
 
 var (
 var (
-	modKernel32        = syscall.NewLazyDLL("kernel32.dll")
+	// NewLazySystemDLL forces the load from %SystemRoot%\System32 so a
+	// kernel32.dll planted next to the binary can't hijack the call.
+	modKernel32        = windows.NewLazySystemDLL("kernel32.dll")
 	procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
 	procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
 
 
 	cpuMu      sync.Mutex
 	cpuMu      sync.Mutex
@@ -50,32 +51,25 @@ var (
 	hasLast    bool
 	hasLast    bool
 )
 )
 
 
-type filetime struct {
-	LowDateTime  uint32
-	HighDateTime uint32
-}
-
-// ftToUint64 converts a Windows FILETIME-like struct to a uint64 for
-// arithmetic and delta calculations used by CPUPercentRaw.
-func ftToUint64(ft filetime) uint64 {
+func ftToUint64(ft windows.Filetime) uint64 {
 	return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
 	return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
 }
 }
 
 
-// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
-// Windows GetSystemTimes across all logical processors. The first call returns 0
-// as it initializes the baseline. Subsequent calls compute deltas.
+// CPUPercentRaw returns instantaneous total CPU utilization across all
+// logical processors via Windows GetSystemTimes. The first call returns 0
+// while it initializes the baseline; subsequent calls compute deltas.
 func CPUPercentRaw() (float64, error) {
 func CPUPercentRaw() (float64, error) {
-	var idleFT, kernelFT, userFT filetime
+	var idleFT, kernelFT, userFT windows.Filetime
 	r1, _, e1 := procGetSystemTimes.Call(
 	r1, _, e1 := procGetSystemTimes.Call(
 		uintptr(unsafe.Pointer(&idleFT)),
 		uintptr(unsafe.Pointer(&idleFT)),
 		uintptr(unsafe.Pointer(&kernelFT)),
 		uintptr(unsafe.Pointer(&kernelFT)),
 		uintptr(unsafe.Pointer(&userFT)),
 		uintptr(unsafe.Pointer(&userFT)),
 	)
 	)
-	if r1 == 0 { // failure
-		if e1 != nil {
-			return 0, e1
+	if r1 == 0 {
+		if errno, _ := e1.(syscall.Errno); errno != 0 {
+			return 0, errno
 		}
 		}
-		return 0, syscall.GetLastError()
+		return 0, errors.New("GetSystemTimes failed")
 	}
 	}
 
 
 	idle := ftToUint64(idleFT)
 	idle := ftToUint64(idleFT)
@@ -97,7 +91,6 @@ func CPUPercentRaw() (float64, error) {
 	kernelDelta := kernel - lastKernel
 	kernelDelta := kernel - lastKernel
 	userDelta := user - lastUser
 	userDelta := user - lastUser
 
 
-	// Update for next call
 	lastIdle = idle
 	lastIdle = idle
 	lastKernel = kernel
 	lastKernel = kernel
 	lastUser = user
 	lastUser = user
@@ -106,11 +99,10 @@ func CPUPercentRaw() (float64, error) {
 	if total == 0 {
 	if total == 0 {
 		return 0, nil
 		return 0, nil
 	}
 	}
-	// On Windows, kernel time includes idle time; busy = total - idle
+	// kernel time includes idle on Windows; busy = total - idle
 	busy := total - idleDelta
 	busy := total - idleDelta
 
 
 	pct := float64(busy) / float64(total) * 100.0
 	pct := float64(busy) / float64(total) * 100.0
-	// lower bound not needed; ratios of uint64 are non-negative
 	if pct > 100 {
 	if pct > 100 {
 		pct = 100
 		pct = 100
 	}
 	}

+ 5 - 2
web/controller/api.go

@@ -32,8 +32,8 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
 
 
 func (a *APIController) checkAPIAuth(c *gin.Context) {
 func (a *APIController) checkAPIAuth(c *gin.Context) {
 	auth := c.GetHeader("Authorization")
 	auth := c.GetHeader("Authorization")
-	if strings.HasPrefix(auth, "Bearer ") {
-		tok := strings.TrimPrefix(auth, "Bearer ")
+	if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
+		tok := after
 		if a.apiTokenService.Match(tok) {
 		if a.apiTokenService.Match(tok) {
 			if u, err := a.userService.GetFirstUser(); err == nil {
 			if u, err := a.userService.GetFirstUser(); err == nil {
 				session.SetAPIAuthUser(c, u)
 				session.SetAPIAuthUser(c, u)
@@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
 	inbounds := api.Group("/inbounds")
 	inbounds := api.Group("/inbounds")
 	a.inboundController = NewInboundController(inbounds)
 	a.inboundController = NewInboundController(inbounds)
 
 
+	clients := api.Group("/clients")
+	NewClientController(clients)
+
 	// Server API
 	// Server API
 	server := api.Group("/server")
 	server := api.Group("/server")
 	a.serverController = NewServerController(server)
 	a.serverController = NewServerController(server)

+ 4 - 1
web/controller/api_docs_test.go

@@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			basePath = "/panel/api"
 			basePath = "/panel/api"
 		case "inbound.go":
 		case "inbound.go":
 			basePath = "/panel/api/inbounds"
 			basePath = "/panel/api/inbounds"
+		case "client.go":
+			basePath = "/panel/api/clients"
 		case "server.go":
 		case "server.go":
 			basePath = "/panel/api/server"
 			basePath = "/panel/api/server"
 		case "node.go":
 		case "node.go":
@@ -127,7 +129,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 		// Skip SPA page routes (these are UI pages, not API endpoints)
 		// Skip SPA page routes (these are UI pages, not API endpoints)
 		spaPages := map[string]bool{
 		spaPages := map[string]bool{
 			"/": true, "/panel/": true, "/panel/inbounds": true,
 			"/": true, "/panel/": true, "/panel/inbounds": true,
-			"/panel/nodes": true, "/panel/settings": true,
+			"/panel/clients": true,
+			"/panel/nodes":   true, "/panel/settings": true,
 			"/panel/xray": true, "/panel/api-docs": true,
 			"/panel/xray": true, "/panel/api-docs": true,
 		}
 		}
 		if spaPages[r.Path] {
 		if spaPages[r.Path] {

+ 311 - 0
web/controller/client.go

@@ -0,0 +1,311 @@
+package controller
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+
+	"github.com/gin-gonic/gin"
+)
+
+func notifyClientsChanged() {
+	websocket.BroadcastInvalidate(websocket.MessageTypeClients)
+}
+
+type ClientController struct {
+	clientService  service.ClientService
+	inboundService service.InboundService
+	xrayService    service.XrayService
+}
+
+func NewClientController(g *gin.RouterGroup) *ClientController {
+	a := &ClientController{}
+	a.initRouter(g)
+	return a
+}
+
+func (a *ClientController) initRouter(g *gin.RouterGroup) {
+	g.GET("/list", a.list)
+	g.GET("/get/:email", a.get)
+	g.GET("/traffic/:email", a.getTrafficByEmail)
+	g.GET("/subLinks/:subId", a.getSubLinks)
+	g.GET("/links/:email", a.getClientLinks)
+
+	g.POST("/add", a.create)
+	g.POST("/update/:email", a.update)
+	g.POST("/del/:email", a.delete)
+	g.POST("/:email/attach", a.attach)
+	g.POST("/:email/detach", a.detach)
+	g.POST("/resetAllTraffics", a.resetAllTraffics)
+	g.POST("/delDepleted", a.delDepleted)
+	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
+	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
+	g.POST("/ips/:email", a.getIps)
+	g.POST("/clearIps/:email", a.clearIps)
+	g.POST("/onlines", a.onlines)
+	g.POST("/lastOnline", a.lastOnline)
+}
+
+func (a *ClientController) list(c *gin.Context) {
+	rows, err := a.clientService.List()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, rows, nil)
+}
+
+func (a *ClientController) get(c *gin.Context) {
+	email := c.Param("email")
+	rec, err := a.clientService.GetRecordByEmail(nil, email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
+}
+
+func (a *ClientController) create(c *gin.Context) {
+	var payload service.ClientCreatePayload
+	if err := c.ShouldBindJSON(&payload); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.Create(&a.inboundService, &payload)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) update(c *gin.Context) {
+	email := c.Param("email")
+	var updated model.Client
+	if err := c.ShouldBindJSON(&updated); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delete(c *gin.Context) {
+	email := c.Param("email")
+	keepTraffic := c.Query("keepTraffic") == "1"
+	needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+type attachDetachBody struct {
+	InboundIds []int `json:"inboundIds"`
+}
+
+func (a *ClientController) attach(c *gin.Context) {
+	email := c.Param("email")
+	var body attachDetachBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) resetAllTraffics(c *gin.Context) {
+	needRestart, err := a.clientService.ResetAllTraffics()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delDepleted(c *gin.Context) {
+	deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"deleted": deleted}, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+type trafficUpdateRequest struct {
+	Upload   int64 `json:"upload"`
+	Download int64 `json:"download"`
+}
+
+func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	var req trafficUpdateRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	notifyClientsChanged()
+}
+
+func (a *ClientController) getIps(c *gin.Context) {
+	email := c.Param("email")
+	ips, err := a.inboundService.GetInboundClientIps(email)
+	if err != nil || ips == "" {
+		jsonObj(c, "No IP Record", nil)
+		return
+	}
+	type ipWithTimestamp struct {
+		IP        string `json:"ip"`
+		Timestamp int64  `json:"timestamp"`
+	}
+	var ipsWithTime []ipWithTimestamp
+	if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
+		formatted := make([]string, 0, len(ipsWithTime))
+		for _, item := range ipsWithTime {
+			if item.IP == "" {
+				continue
+			}
+			if item.Timestamp > 0 {
+				ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
+				formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
+				continue
+			}
+			formatted = append(formatted, item.IP)
+		}
+		jsonObj(c, formatted, nil)
+		return
+	}
+	var oldIps []string
+	if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
+		jsonObj(c, oldIps, nil)
+		return
+	}
+	jsonObj(c, ips, nil)
+}
+
+func (a *ClientController) clearIps(c *gin.Context) {
+	email := c.Param("email")
+	if err := a.inboundService.ClearClientIps(email); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
+}
+
+func (a *ClientController) onlines(c *gin.Context) {
+	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
+}
+
+func (a *ClientController) lastOnline(c *gin.Context) {
+	data, err := a.inboundService.GetClientsLastOnline()
+	jsonObj(c, data, err)
+}
+
+func (a *ClientController) getTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	traffic, err := a.inboundService.GetClientTrafficByEmail(email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
+		return
+	}
+	jsonObj(c, traffic, nil)
+}
+
+func (a *ClientController) getSubLinks(c *gin.Context) {
+	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+func (a *ClientController) getClientLinks(c *gin.Context) {
+	links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+func (a *ClientController) detach(c *gin.Context) {
+	email := c.Param("email")
+	var body attachDetachBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}

+ 46 - 322
web/controller/inbound.go

@@ -6,7 +6,6 @@ import (
 	"net"
 	"net"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
-	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -18,8 +17,9 @@ import (
 
 
 // InboundController handles HTTP requests related to Xray inbounds management.
 // InboundController handles HTTP requests related to Xray inbounds management.
 type InboundController struct {
 type InboundController struct {
-	inboundService service.InboundService
-	xrayService    service.XrayService
+	inboundService  service.InboundService
+	xrayService     service.XrayService
+	fallbackService service.FallbackService
 }
 }
 
 
 // NewInboundController creates a new InboundController and sets up its routes.
 // NewInboundController creates a new InboundController and sets up its routes.
@@ -61,38 +61,18 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 
 
 	g.GET("/list", a.getInbounds)
 	g.GET("/list", a.getInbounds)
+	g.GET("/options", a.getInboundOptions)
 	g.GET("/get/:id", a.getInbound)
 	g.GET("/get/:id", a.getInbound)
-	g.GET("/getClientTraffics/:email", a.getClientTraffics)
-	g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
-	g.GET("/getSubLinks/:subId", a.getSubLinks)
-	g.GET("/getClientLinks/:id/:email", a.getClientLinks)
+	g.GET("/:id/fallbacks", a.getFallbacks)
 
 
 	g.POST("/add", a.addInbound)
 	g.POST("/add", a.addInbound)
 	g.POST("/del/:id", a.delInbound)
 	g.POST("/del/:id", a.delInbound)
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/setEnable/:id", a.setInboundEnable)
 	g.POST("/setEnable/:id", a.setInboundEnable)
-	g.POST("/clientIps/:email", a.getClientIps)
-	g.POST("/clearClientIps/:email", a.clearClientIps)
-	g.POST("/addClient", a.addInboundClient)
-	g.POST("/:id/copyClients", a.copyInboundClients)
-	g.POST("/:id/delClient/:clientId", a.delInboundClient)
-	g.POST("/updateClient/:clientId", a.updateInboundClient)
 	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
 	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
-	g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
-	g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
-	g.POST("/delDepletedClients/:id", a.delDepletedClients)
 	g.POST("/import", a.importInbound)
 	g.POST("/import", a.importInbound)
-	g.POST("/onlines", a.onlines)
-	g.POST("/lastOnline", a.lastOnline)
-	g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
-	g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
-}
-
-type CopyInboundClientsRequest struct {
-	SourceInboundID int      `form:"sourceInboundId" json:"sourceInboundId"`
-	ClientEmails    []string `form:"clientEmails" json:"clientEmails"`
-	Flow            string   `form:"flow" json:"flow"`
+	g.POST("/:id/fallbacks", a.setFallbacks)
 }
 }
 
 
 // getInbounds retrieves the list of inbounds for the logged-in user.
 // getInbounds retrieves the list of inbounds for the logged-in user.
@@ -106,6 +86,19 @@ func (a *InboundController) getInbounds(c *gin.Context) {
 	jsonObj(c, inbounds, nil)
 	jsonObj(c, inbounds, nil)
 }
 }
 
 
+// getInboundOptions returns a lightweight projection of the user's inbounds
+// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
+// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
+func (a *InboundController) getInboundOptions(c *gin.Context) {
+	user := session.GetLoginUser(c)
+	options, err := a.inboundService.GetInboundOptions(user.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, options, nil)
+}
+
 // getInbound retrieves a specific inbound by its ID.
 // getInbound retrieves a specific inbound by its ID.
 func (a *InboundController) getInbound(c *gin.Context) {
 func (a *InboundController) getInbound(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	id, err := strconv.Atoi(c.Param("id"))
@@ -121,28 +114,6 @@ func (a *InboundController) getInbound(c *gin.Context) {
 	jsonObj(c, inbound, nil)
 	jsonObj(c, inbound, nil)
 }
 }
 
 
-// getClientTraffics retrieves client traffic information by email.
-func (a *InboundController) getClientTraffics(c *gin.Context) {
-	email := c.Param("email")
-	clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
-		return
-	}
-	jsonObj(c, clientTraffics, nil)
-}
-
-// getClientTrafficsById retrieves client traffic information by inbound ID.
-func (a *InboundController) getClientTrafficsById(c *gin.Context) {
-	id := c.Param("id")
-	clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
-		return
-	}
-	jsonObj(c, clientTraffics, nil)
-}
-
 // addInbound creates a new inbound configuration.
 // addInbound creates a new inbound configuration.
 func (a *InboundController) addInbound(c *gin.Context) {
 func (a *InboundController) addInbound(c *gin.Context) {
 	inbound := &model.Inbound{}
 	inbound := &model.Inbound{}
@@ -274,174 +245,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
 	websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
 	websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
 }
 }
 
 
-// getClientIps retrieves the IP addresses associated with a client by email.
-func (a *InboundController) getClientIps(c *gin.Context) {
-	email := c.Param("email")
-
-	ips, err := a.inboundService.GetInboundClientIps(email)
-	if err != nil || ips == "" {
-		jsonObj(c, "No IP Record", nil)
-		return
-	}
-
-	// Prefer returning a normalized string list for consistent UI rendering
-	type ipWithTimestamp struct {
-		IP        string `json:"ip"`
-		Timestamp int64  `json:"timestamp"`
-	}
-
-	var ipsWithTime []ipWithTimestamp
-	if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
-		formatted := make([]string, 0, len(ipsWithTime))
-		for _, item := range ipsWithTime {
-			if item.IP == "" {
-				continue
-			}
-			if item.Timestamp > 0 {
-				ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
-				formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
-				continue
-			}
-			formatted = append(formatted, item.IP)
-		}
-		jsonObj(c, formatted, nil)
-		return
-	}
-
-	var oldIps []string
-	if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
-		jsonObj(c, oldIps, nil)
-		return
-	}
-
-	// If parsing fails, return as string
-	jsonObj(c, ips, nil)
-}
-
-// clearClientIps clears the IP addresses for a client by email.
-func (a *InboundController) clearClientIps(c *gin.Context) {
-	email := c.Param("email")
-
-	err := a.inboundService.ClearClientIps(email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
-}
-
-// addInboundClient adds a new client to an existing inbound.
-func (a *InboundController) addInboundClient(c *gin.Context) {
-	data := &model.Inbound{}
-	err := c.ShouldBind(data)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	needRestart, err := a.inboundService.AddInboundClient(data)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// copyInboundClients copies clients from source inbound to target inbound.
-func (a *InboundController) copyInboundClients(c *gin.Context) {
-	targetID, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-
-	req := &CopyInboundClientsRequest{}
-	err = c.ShouldBind(req)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	if req.SourceInboundID <= 0 {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
-		return
-	}
-
-	result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, result, nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// delInboundClient deletes a client from an inbound by inbound ID and client ID.
-func (a *InboundController) delInboundClient(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	clientId := c.Param("clientId")
-
-	needRestart, err := a.inboundService.DelInboundClient(id, clientId)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// updateInboundClient updates a client's configuration in an inbound.
-func (a *InboundController) updateInboundClient(c *gin.Context) {
-	clientId := c.Param("clientId")
-
-	inbound := &model.Inbound{}
-	err := c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// resetClientTraffic resets the traffic counter for a specific client in an inbound.
-func (a *InboundController) resetClientTraffic(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	email := c.Param("email")
-
-	needRestart, err := a.inboundService.ResetClientTraffic(id, email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
 // resetInboundTraffic resets traffic counters for a specific inbound.
 // resetInboundTraffic resets traffic counters for a specific inbound.
 func (a *InboundController) resetInboundTraffic(c *gin.Context) {
 func (a *InboundController) resetInboundTraffic(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	id, err := strconv.Atoi(c.Param("id"))
@@ -472,24 +275,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
 }
 }
 
 
-// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
-func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	err = a.inboundService.ResetAllClientTraffics(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	} else {
-		a.xrayService.SetToNeedRestart()
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
-}
-
 // importInbound imports an inbound configuration from provided data.
 // importInbound imports an inbound configuration from provided data.
 func (a *InboundController) importInbound(c *gin.Context) {
 func (a *InboundController) importInbound(c *gin.Context) {
 	inbound := &model.Inbound{}
 	inbound := &model.Inbound{}
@@ -522,79 +307,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
 	}
 	}
 }
 }
 
 
-// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
-func (a *InboundController) delDepletedClients(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	err = a.inboundService.DelDepletedClients(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
-}
-
-// onlines retrieves the list of currently online clients.
-func (a *InboundController) onlines(c *gin.Context) {
-	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
-}
-
-// lastOnline retrieves the last online timestamps for clients.
-func (a *InboundController) lastOnline(c *gin.Context) {
-	data, err := a.inboundService.GetClientsLastOnline()
-	jsonObj(c, data, err)
-}
-
-// updateClientTraffic updates the traffic statistics for a client by email.
-func (a *InboundController) updateClientTraffic(c *gin.Context) {
-	email := c.Param("email")
-
-	// Define the request structure for traffic update
-	type TrafficUpdateRequest struct {
-		Upload   int64 `json:"upload"`
-		Download int64 `json:"download"`
-	}
-
-	var request TrafficUpdateRequest
-	err := c.ShouldBindJSON(&request)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
-}
-
-// delInboundClientByEmail deletes a client from an inbound by email address.
-func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
-	inboundId, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, "Invalid inbound ID", err)
-		return
-	}
-
-	email := c.Param("email")
-	needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
-	if err != nil {
-		jsonMsg(c, "Failed to delete client by email", err)
-		return
-	}
-
-	jsonMsg(c, "Client deleted successfully", nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
 // resolveHost mirrors what sub.SubService.ResolveRequest does for the host
 // resolveHost mirrors what sub.SubService.ResolveRequest does for the host
 // field: prefers X-Forwarded-Host (first entry of any list, port stripped),
 // field: prefers X-Forwarded-Host (first entry of any list, port stripped),
 // then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
 // then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
@@ -621,30 +333,42 @@ func resolveHost(c *gin.Context) string {
 	return c.Request.Host
 	return c.Request.Host
 }
 }
 
 
-// getSubLinks returns every protocol URL produced for the given subscription
-// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
-func (a *InboundController) getSubLinks(c *gin.Context) {
-	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+// getFallbacks returns the fallback rules attached to the master inbound.
+func (a *InboundController) getFallbacks(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	rows, err := a.fallbackService.GetByMaster(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 		return
 	}
 	}
-	jsonObj(c, links, nil)
+	jsonObj(c, rows, nil)
 }
 }
 
 
-// getClientLinks returns the URL(s) for one client on one inbound — the same
-// string the Copy URL button copies in the panel UI. Empty array when the
-// protocol has no URL form, or when the email isn't found on the inbound.
-func (a *InboundController) getClientLinks(c *gin.Context) {
+// setFallbacks atomically replaces the master inbound's fallback list
+// and triggers an Xray restart so the new settings.fallbacks take effect.
+func (a *InboundController) setFallbacks(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
-	links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+	type body struct {
+		Fallbacks []service.FallbackInput `json:"fallbacks"`
+	}
+	var b body
+	if err := c.ShouldBindJSON(&b); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
-	jsonObj(c, links, nil)
+	a.xrayService.SetToNeedRestart()
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
 }
 }
+

+ 1 - 1
web/controller/node.go

@@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
 		return
 		return
 	}
 	}
 	bucket, err := strconv.Atoi(c.Param("bucket"))
 	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
+	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
 		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
 		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
 		return
 		return
 	}
 	}

+ 44 - 136
web/controller/server.go

@@ -27,11 +27,6 @@ type ServerController struct {
 	settingService     service.SettingService
 	settingService     service.SettingService
 	panelService       service.PanelService
 	panelService       service.PanelService
 	xrayMetricsService service.XrayMetricsService
 	xrayMetricsService service.XrayMetricsService
-
-	lastStatus *service.Status
-
-	lastVersions        []string
-	lastGetVersionsTime int64 // unix seconds
 }
 }
 
 
 // NewServerController creates a new ServerController, initializes routes, and starts background tasks.
 // NewServerController creates a new ServerController, initializes routes, and starts background tasks.
@@ -74,63 +69,43 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.POST("/getNewEchCert", a.getNewEchCert)
 	g.POST("/getNewEchCert", a.getNewEchCert)
 }
 }
 
 
-// refreshStatus updates the cached server status and collects time-series
-// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
-// SystemHistoryModal's tabs share an identical x-axis.
-func (a *ServerController) refreshStatus() {
-	a.lastStatus = a.serverService.GetStatus(a.lastStatus)
-	if a.lastStatus != nil {
-		now := time.Now()
-		a.serverService.AppendStatusSample(now, a.lastStatus)
-		a.xrayMetricsService.Sample(now)
-		// Broadcast status update via WebSocket
-		websocket.BroadcastStatus(a.lastStatus)
-	}
-}
-
-// startTask initiates background tasks for continuous status monitoring.
+// startTask registers the @2s ticker that refreshes server status, samples
+// xray metrics, and pushes the new snapshot to all websocket subscribers.
+// State + sampling live in ServerService; the controller only orchestrates
+// the cross-service side effects (xrayMetrics sample + websocket broadcast).
 func (a *ServerController) startTask() {
 func (a *ServerController) startTask() {
-	webServer := global.GetWebServer()
-	c := webServer.GetCron()
+	c := global.GetWebServer().GetCron()
 	c.AddFunc("@every 2s", func() {
 	c.AddFunc("@every 2s", func() {
-		// Always refresh to keep CPU history collected continuously.
-		// Sampling is lightweight and capped to ~6 hours in memory.
-		a.refreshStatus()
+		status := a.serverService.RefreshStatus()
+		if status == nil {
+			return
+		}
+		a.xrayMetricsService.Sample(time.Now())
+		websocket.BroadcastStatus(status)
 	})
 	})
 }
 }
 
 
 // status returns the current server status information.
 // status returns the current server status information.
-func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
-
-// allowedHistoryBuckets is the bucket-second whitelist shared by both
-// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
-// prevents callers from triggering arbitrary aggregation work and keeps
-// the front-end's bucket selector self-documenting.
-var allowedHistoryBuckets = map[int]bool{
-	2:   true, // Real-time view
-	30:  true, // 30s intervals
-	60:  true, // 1m intervals
-	120: true, // 2m intervals
-	180: true, // 3m intervals
-	300: true, // 5m intervals
+func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
+
+func parseHistoryBucket(c *gin.Context) (int, bool) {
+	bucket, err := strconv.Atoi(c.Param("bucket"))
+	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
+		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+		return 0, false
+	}
+	return bucket, true
 }
 }
 
 
 // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
 // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
 // Kept for back-compat; new callers should use /history/cpu/:bucket which
 // Kept for back-compat; new callers should use /history/cpu/:bucket which
 // returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
 // returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
 func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
 func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
-	bucketStr := c.Param("bucket")
-	bucket, err := strconv.Atoi(bucketStr)
-	if err != nil || bucket <= 0 {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 		return
 	}
 	}
-	if !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
-		return
-	}
-	points := a.serverService.AggregateCpuHistory(bucket, 60)
-	jsonObj(c, points, nil)
+	jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
 }
 }
 
 
 // getMetricHistoryBucket returns up to 60 buckets of history for a single
 // getMetricHistoryBucket returns up to 60 buckets of history for a single
@@ -142,9 +117,8 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		return
 		return
 	}
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 		return
 	}
 	}
 	jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
 	jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
@@ -160,9 +134,8 @@ func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		return
 		return
 	}
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 		return
 	}
 	}
 	jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
 	jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
@@ -178,37 +151,19 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
 		jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
 		return
 		return
 	}
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 		return
 	}
 	}
 	jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
 	jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
 }
 }
 
 
 func (a *ServerController) getXrayVersion(c *gin.Context) {
 func (a *ServerController) getXrayVersion(c *gin.Context) {
-	const cacheTTLSeconds = 15 * 60
-
-	now := time.Now().Unix()
-	if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
-		jsonObj(c, a.lastVersions, nil)
-		return
-	}
-
-	versions, err := a.serverService.GetXrayVersions()
+	versions, err := a.serverService.GetXrayVersionsCached()
 	if err != nil {
 	if err != nil {
-		if a.lastVersions != nil {
-			logger.Warning("getXrayVersion failed; serving cached list:", err)
-			jsonObj(c, a.lastVersions, nil)
-			return
-		}
 		jsonMsg(c, I18nWeb(c, "getVersion"), err)
 		jsonMsg(c, I18nWeb(c, "getVersion"), err)
 		return
 		return
 	}
 	}
-
-	a.lastVersions = versions
-	a.lastGetVersionsTime = now
-
 	jsonObj(c, versions, nil)
 	jsonObj(c, versions, nil)
 }
 }
 
 
@@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
 func (a *ServerController) updateGeofile(c *gin.Context) {
 func (a *ServerController) updateGeofile(c *gin.Context) {
 	fileName := c.Param("fileName")
 	fileName := c.Param("fileName")
 
 
-	// Validate the filename for security (prevent path traversal attacks)
 	if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
 	if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
 		jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
 		jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
 			fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
 			fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
@@ -287,55 +241,22 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
 
 
 // getLogs retrieves the application logs based on count, level, and syslog filters.
 // getLogs retrieves the application logs based on count, level, and syslog filters.
 func (a *ServerController) getLogs(c *gin.Context) {
 func (a *ServerController) getLogs(c *gin.Context) {
-	count := c.Param("count")
-	level := c.PostForm("level")
-	syslog := c.PostForm("syslog")
-	logs := a.serverService.GetLogs(count, level, syslog)
+	logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
 	jsonObj(c, logs, nil)
 	jsonObj(c, logs, nil)
 }
 }
 
 
 // getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
 // getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
 func (a *ServerController) getXrayLogs(c *gin.Context) {
 func (a *ServerController) getXrayLogs(c *gin.Context) {
-	count := c.Param("count")
-	filter := c.PostForm("filter")
-	showDirect := c.PostForm("showDirect")
-	showBlocked := c.PostForm("showBlocked")
-	showProxy := c.PostForm("showProxy")
-
-	var freedoms []string
-	var blackholes []string
-
-	//getting tags for freedom and blackhole outbounds
-	config, err := a.settingService.GetDefaultXrayConfig()
-	if err == nil && config != nil {
-		if cfgMap, ok := config.(map[string]any); ok {
-			if outbounds, ok := cfgMap["outbounds"].([]any); ok {
-				for _, outbound := range outbounds {
-					if obMap, ok := outbound.(map[string]any); ok {
-						switch obMap["protocol"] {
-						case "freedom":
-							if tag, ok := obMap["tag"].(string); ok {
-								freedoms = append(freedoms, tag)
-							}
-						case "blackhole":
-							if tag, ok := obMap["tag"].(string); ok {
-								blackholes = append(blackholes, tag)
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-	if len(freedoms) == 0 {
-		freedoms = []string{"direct"}
-	}
-	if len(blackholes) == 0 {
-		blackholes = []string{"blocked"}
-	}
-
-	logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
+	freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
+	logs := a.serverService.GetXrayLogs(
+		c.Param("count"),
+		c.PostForm("filter"),
+		c.PostForm("showDirect"),
+		c.PostForm("showBlocked"),
+		c.PostForm("showProxy"),
+		freedoms,
+		blackholes,
+	)
 	jsonObj(c, logs, nil)
 	jsonObj(c, logs, nil)
 }
 }
 
 
@@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
 	}
 	}
 
 
 	filename := "x-ui.db"
 	filename := "x-ui.db"
-
-	if !isValidFilename(filename) {
+	if !filenameRegex.MatchString(filename) {
 		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return
 		return
 	}
 	}
 
 
-	// Set the headers for the response
 	c.Header("Content-Type", "application/octet-stream")
 	c.Header("Content-Type", "application/octet-stream")
 	c.Header("Content-Disposition", "attachment; filename="+filename)
 	c.Header("Content-Disposition", "attachment; filename="+filename)
-
-	// Write the file contents to the response
 	c.Writer.Write(db)
 	c.Writer.Write(db)
 }
 }
 
 
-func isValidFilename(filename string) bool {
-	// Validate that the filename only contains allowed characters
-	return filenameRegex.MatchString(filename)
-}
-
 // importDB imports a database file and restarts the Xray service.
 // importDB imports a database file and restarts the Xray service.
 func (a *ServerController) importDB(c *gin.Context) {
 func (a *ServerController) importDB(c *gin.Context) {
-	// Get the file from the request body
 	file, _, err := c.Request.FormFile("db")
 	file, _, err := c.Request.FormFile("db")
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
 		jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
 		return
 		return
 	}
 	}
 	defer file.Close()
 	defer file.Close()
-	err = a.serverService.ImportDB(file)
-	if err != nil {
+	if err := a.serverService.ImportDB(file); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		return
 		return
 	}
 	}
@@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
 
 
 // getNewEchCert generates a new ECH certificate for the given SNI.
 // getNewEchCert generates a new ECH certificate for the given SNI.
 func (a *ServerController) getNewEchCert(c *gin.Context) {
 func (a *ServerController) getNewEchCert(c *gin.Context) {
-	sni := c.PostForm("sni")
-	cert, err := a.serverService.GetNewEchCert(sni)
+	cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, "get ech certificate", err)
 		jsonMsg(c, "get ech certificate", err)
 		return
 		return
@@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
 		jsonMsg(c, "Failed to generate UUID", err)
 		jsonMsg(c, "Failed to generate UUID", err)
 		return
 		return
 	}
 	}
-
 	jsonObj(c, uuidResp, nil)
 	jsonObj(c, uuidResp, nil)
 }
 }
 
 

+ 2 - 2
web/controller/util.go

@@ -27,7 +27,7 @@ func getRemoteIp(c *gin.Context) string {
 		}
 		}
 
 
 		if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
 		if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
-			for _, part := range strings.Split(xff, ",") {
+			for part := range strings.SplitSeq(xff, ",") {
 				if ip, ok := extractTrustedIP(part); ok {
 				if ip, ok := extractTrustedIP(part); ok {
 					return ip
 					return ip
 				}
 				}
@@ -50,7 +50,7 @@ func isTrustedProxy(ip string) bool {
 	}
 	}
 
 
 	trusted := trustedProxyCIDRs()
 	trusted := trustedProxyCIDRs()
-	for _, value := range strings.Split(trusted, ",") {
+	for value := range strings.SplitSeq(trusted, ",") {
 		value = strings.TrimSpace(value)
 		value = strings.TrimSpace(value)
 		if value == "" {
 		if value == "" {
 			continue
 			continue

+ 5 - 0
web/controller/xui.go

@@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 
 
 	g.GET("/", a.index)
 	g.GET("/", a.index)
 	g.GET("/inbounds", a.inbounds)
 	g.GET("/inbounds", a.inbounds)
+	g.GET("/clients", a.clients)
 	g.GET("/nodes", a.nodes)
 	g.GET("/nodes", a.nodes)
 	g.GET("/settings", a.settings)
 	g.GET("/settings", a.settings)
 	g.GET("/xray", a.xraySettings)
 	g.GET("/xray", a.xraySettings)
@@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
 	serveDistPage(c, "inbounds.html")
 	serveDistPage(c, "inbounds.html")
 }
 }
 
 
+func (a *XUIController) clients(c *gin.Context) {
+	serveDistPage(c, "clients.html")
+}
+
 // nodes renders the multi-panel nodes management page.
 // nodes renders the multi-panel nodes management page.
 func (a *XUIController) nodes(c *gin.Context) {
 func (a *XUIController) nodes(c *gin.Context) {
 	serveDistPage(c, "nodes.html")
 	serveDistPage(c, "nodes.html")

+ 1 - 1
web/entity/entity.go

@@ -195,7 +195,7 @@ func (s *AllSetting) CheckValid() error {
 		s.SubClashPath += "/"
 		s.SubClashPath += "/"
 	}
 	}
 
 
-	for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
+	for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
 		cidr = strings.TrimSpace(cidr)
 		cidr = strings.TrimSpace(cidr)
 		if cidr == "" {
 		if cidr == "" {
 			continue
 			continue

+ 25 - 0
web/global/global.go

@@ -3,6 +3,7 @@ package global
 
 
 import (
 import (
 	"context"
 	"context"
+	"sync"
 	_ "unsafe"
 	_ "unsafe"
 
 
 	"github.com/robfig/cron/v3"
 	"github.com/robfig/cron/v3"
@@ -11,6 +12,9 @@ import (
 var (
 var (
 	webServer WebServer
 	webServer WebServer
 	subServer SubServer
 	subServer SubServer
+
+	restartHookMu sync.RWMutex
+	restartHook   func()
 )
 )
 
 
 // WebServer interface defines methods for accessing the web server instance.
 // WebServer interface defines methods for accessing the web server instance.
@@ -44,3 +48,24 @@ func SetSubServer(s SubServer) {
 func GetSubServer() SubServer {
 func GetSubServer() SubServer {
 	return subServer
 	return subServer
 }
 }
+
+// SetRestartHook registers a callback that triggers an in-process panel
+// restart. main.go sets this up to push SIGHUP into its own signal channel
+// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported).
+func SetRestartHook(fn func()) {
+	restartHookMu.Lock()
+	defer restartHookMu.Unlock()
+	restartHook = fn
+}
+
+// TriggerRestart fires the registered restart hook. Returns false if none is set.
+func TriggerRestart() bool {
+	restartHookMu.RLock()
+	fn := restartHook
+	restartHookMu.RUnlock()
+	if fn == nil {
+		return false
+	}
+	fn()
+	return true
+}

+ 46 - 102
web/job/ldap_sync_job.go

@@ -1,18 +1,15 @@
 package job
 package job
 
 
 import (
 import (
+	"strings"
 	"time"
 	"time"
 
 
-	"strings"
+	"github.com/google/uuid"
 
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
-
-	"strconv"
-
-	"github.com/google/uuid"
 )
 )
 
 
 var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
 var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
@@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
 type LdapSyncJob struct {
 type LdapSyncJob struct {
 	settingService service.SettingService
 	settingService service.SettingService
 	inboundService service.InboundService
 	inboundService service.InboundService
+	clientService  service.ClientService
 	xrayService    service.XrayService
 	xrayService    service.XrayService
 }
 }
 
 
@@ -135,18 +133,29 @@ func (j *LdapSyncJob) Run() {
 		}
 		}
 	}
 	}
 
 
-	// --- Execute batch create ---
 	for tag, newClients := range clientsToCreate {
 	for tag, newClients := range clientsToCreate {
 		if len(newClients) == 0 {
 		if len(newClients) == 0 {
 			continue
 			continue
 		}
 		}
-		payload := &model.Inbound{Id: inboundMap[tag].Id}
-		payload.Settings = j.clientsToJSON(newClients)
-		if _, err := j.inboundService.AddInboundClient(payload); err != nil {
-			logger.Warningf("Failed to add clients for tag %s: %v", tag, err)
-		} else {
-			logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag)
-			j.xrayService.SetToNeedRestart()
+		ib := inboundMap[tag]
+		created := 0
+		restartNeeded := false
+		for _, c := range newClients {
+			nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
+			if err != nil {
+				logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
+				continue
+			}
+			created++
+			if nr {
+				restartNeeded = true
+			}
+		}
+		if created > 0 {
+			logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
+			if restartNeeded {
+				j.xrayService.SetToNeedRestart()
+			}
 		}
 		}
 	}
 	}
 
 
@@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
 	return c
 	return c
 }
 }
 
 
-// batchSetEnable enables/disables clients in batch through a single call
 func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
 func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
 	if len(emails) == 0 {
 	if len(emails) == 0 {
 		return
 		return
 	}
 	}
-
-	// Prepare JSON for mass update
-	clients := make([]model.Client, 0, len(emails))
+	restartNeeded := false
+	changed := 0
 	for _, email := range emails {
 	for _, email := range emails {
-		clients = append(clients, model.Client{
-			Email:  email,
-			Enable: enable,
-		})
+		ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
+		if err != nil {
+			logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
+			continue
+		}
+		if ok {
+			changed++
+		}
+		if needRestart {
+			restartNeeded = true
+		}
 	}
 	}
-
-	payload := &model.Inbound{
-		Id:       ib.Id,
-		Settings: j.clientsToJSON(clients),
+	if changed > 0 {
+		logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
 	}
 	}
-
-	// Use a single AddInboundClient call to update enable
-	if _, err := j.inboundService.AddInboundClient(payload); err != nil {
-		logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
-		return
+	if restartNeeded {
+		j.xrayService.SetToNeedRestart()
 	}
 	}
-
-	logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag)
-	j.xrayService.SetToNeedRestart()
 }
 }
 
 
 // deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
 // deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
@@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 			continue
 			continue
 		}
 		}
 
 
-		// Delete in batches
 		for i := 0; i < len(toDelete); i += batchSize {
 		for i := 0; i < len(toDelete); i += batchSize {
 			end := min(i+batchSize, len(toDelete))
 			end := min(i+batchSize, len(toDelete))
 			batch := toDelete[i:end]
 			batch := toDelete[i:end]
 
 
 			for _, c := range batch {
 			for _, c := range batch {
-				var clientKey string
-				switch ib.Protocol {
-				case model.Trojan:
-					clientKey = c.Password
-				case model.Shadowsocks:
-					clientKey = c.Email
-				default: // vless/vmess
-					clientKey = c.ID
-				}
-
-				if _, err := j.inboundService.DelInboundClient(ib.Id, clientKey); err != nil {
+				nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
+				if err != nil {
 					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
 					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
 						c.Email, ib.Id, ib.Tag, err)
 						c.Email, ib.Id, ib.Tag, err)
-				} else {
-					logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
-						c.Email, ib.Id, ib.Tag)
-					// do not restart here
+					continue
+				}
+				logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
+					c.Email, ib.Id, ib.Tag)
+				if nr {
 					restartNeeded = true
 					restartNeeded = true
 				}
 				}
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	// One time after all batches
 	if restartNeeded {
 	if restartNeeded {
 		j.xrayService.SetToNeedRestart()
 		j.xrayService.SetToNeedRestart()
 		logger.Info("Xray restart scheduled after batch deletion")
 		logger.Info("Xray restart scheduled after batch deletion")
 	}
 	}
 }
 }
-
-// clientsToJSON serializes an array of clients to JSON
-func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
-	b := strings.Builder{}
-	b.WriteString("{\"clients\":[")
-	for i, c := range clients {
-		if i > 0 {
-			b.WriteString(",")
-		}
-		b.WriteString(j.clientToJSON(c))
-	}
-	b.WriteString("]}")
-	return b.String()
-}
-
-// clientToJSON serializes minimal client fields to JSON object string without extra deps
-func (j *LdapSyncJob) clientToJSON(c model.Client) string {
-	// construct minimal JSON manually to avoid importing json for simple case
-	b := strings.Builder{}
-	b.WriteString("{")
-	if c.ID != "" {
-		b.WriteString("\"id\":\"")
-		b.WriteString(c.ID)
-		b.WriteString("\",")
-	}
-	if c.Password != "" {
-		b.WriteString("\"password\":\"")
-		b.WriteString(c.Password)
-		b.WriteString("\",")
-	}
-	b.WriteString("\"email\":\"")
-	b.WriteString(c.Email)
-	b.WriteString("\",")
-	b.WriteString("\"enable\":")
-	if c.Enable {
-		b.WriteString("true")
-	} else {
-		b.WriteString("false")
-	}
-	b.WriteString(",")
-	b.WriteString("\"limitIp\":")
-	b.WriteString(strconv.Itoa(c.LimitIP))
-	b.WriteString(",")
-	b.WriteString("\"totalGB\":")
-	b.WriteString(strconv.FormatInt(c.TotalGB, 10))
-	if c.ExpiryTime > 0 {
-		b.WriteString(",\"expiryTime\":")
-		b.WriteString(strconv.FormatInt(c.ExpiryTime, 10))
-	}
-	b.WriteString("}")
-	return b.String()
-}

+ 19 - 0
web/job/node_traffic_sync_job.go

@@ -20,6 +20,8 @@ const (
 type NodeTrafficSyncJob struct {
 type NodeTrafficSyncJob struct {
 	nodeService    service.NodeService
 	nodeService    service.NodeService
 	inboundService service.InboundService
 	inboundService service.InboundService
+	settingService service.SettingService
+	xrayService    service.XrayService
 	running        sync.Mutex
 	running        sync.Mutex
 	structural     atomicBool
 	structural     atomicBool
 }
 }
@@ -83,6 +85,22 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 	}
 	wg.Wait()
 	wg.Wait()
 
 
+	_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
+	if err != nil {
+		logger.Warning("node traffic sync: depletion check failed:", err)
+	}
+	if clientsDisabled {
+		if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
+			if err := j.xrayService.RestartXray(true); err != nil {
+				logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
+				j.xrayService.SetToNeedRestart()
+			}
+		} else if settingErr != nil {
+			logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
+		}
+		j.structural.set()
+	}
+
 	if !websocket.HasClients() {
 	if !websocket.HasClients() {
 		return
 		return
 	}
 	}
@@ -123,6 +141,7 @@ func (j *NodeTrafficSyncJob) Run() {
 
 
 	if j.structural.takeAndReset() {
 	if j.structural.takeAndReset() {
 		websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
 		websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
+		websocket.BroadcastInvalidate(websocket.MessageTypeClients)
 	}
 	}
 }
 }
 
 

+ 69 - 0
web/job/node_traffic_sync_job_test.go

@@ -0,0 +1,69 @@
+package job
+
+import (
+	"sync"
+	"testing"
+)
+
+func TestAtomicBool_DefaultIsFalse(t *testing.T) {
+	var a atomicBool
+	if a.takeAndReset() {
+		t.Fatal("default atomicBool should report false")
+	}
+}
+
+func TestAtomicBool_SetThenTakeReturnsTrueOnce(t *testing.T) {
+	var a atomicBool
+	a.set()
+	if !a.takeAndReset() {
+		t.Fatal("takeAndReset after set should return true")
+	}
+	if a.takeAndReset() {
+		t.Fatal("second takeAndReset should return false (state was reset)")
+	}
+}
+
+func TestAtomicBool_SetIsIdempotent(t *testing.T) {
+	var a atomicBool
+	a.set()
+	a.set()
+	a.set()
+	if !a.takeAndReset() {
+		t.Fatal("repeated set should still leave the flag true")
+	}
+	if a.takeAndReset() {
+		t.Fatal("flag should be cleared after the first take")
+	}
+}
+
+func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
+	var a atomicBool
+	const setters = 100
+	const readers = 20
+
+	var wg sync.WaitGroup
+	for range setters {
+		wg.Go(func() {
+			a.set()
+		})
+	}
+	wg.Wait()
+
+	trueCount := 0
+	var rwg sync.WaitGroup
+	var mu sync.Mutex
+	for range readers {
+		rwg.Go(func() {
+			if a.takeAndReset() {
+				mu.Lock()
+				trueCount++
+				mu.Unlock()
+			}
+		})
+	}
+	rwg.Wait()
+
+	if trueCount != 1 {
+		t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
+	}
+}

+ 2 - 1
web/job/periodic_traffic_reset_job.go

@@ -11,6 +11,7 @@ type Period string
 // PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
 // PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
 type PeriodicTrafficResetJob struct {
 type PeriodicTrafficResetJob struct {
 	inboundService service.InboundService
 	inboundService service.InboundService
+	clientService  service.ClientService
 	period         Period
 	period         Period
 }
 }
 
 
@@ -42,7 +43,7 @@ func (j *PeriodicTrafficResetJob) Run() {
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
 		}
 		}
 
 
-		resetClientErr := j.inboundService.ResetAllClientTraffics(inbound.Id)
+		resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
 		if resetClientErr != nil {
 		if resetClientErr != nil {
 			logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
 			logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
 		}
 		}

+ 49 - 4
web/runtime/local.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
+	"strings"
 	"sync"
 	"sync"
 
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -78,6 +79,54 @@ func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) e
 	})
 	})
 }
 }
 
 
+func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
+	if !client.Enable {
+		return nil
+	}
+	user := map[string]any{
+		"email":    client.Email,
+		"id":       client.ID,
+		"security": client.Security,
+		"flow":     client.Flow,
+		"auth":     client.Auth,
+		"password": client.Password,
+	}
+	return l.AddUser(ctx, ib, user)
+}
+
+func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
+	if email == "" {
+		return nil
+	}
+	if err := l.RemoveUser(ctx, ib, email); err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			return nil
+		}
+		return err
+	}
+	return nil
+}
+
+func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
+	if oldEmail != "" {
+		if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
+			return err
+		}
+	}
+	if !payload.Enable {
+		return nil
+	}
+	user := map[string]any{
+		"email":    payload.Email,
+		"id":       payload.ID,
+		"security": payload.Security,
+		"flow":     payload.Flow,
+		"auth":     payload.Auth,
+		"password": payload.Password,
+	}
+	return l.AddUser(ctx, ib, user)
+}
+
 func (l *Local) RestartXray(_ context.Context) error {
 func (l *Local) RestartXray(_ context.Context) error {
 	if l.deps.SetNeedRestart != nil {
 	if l.deps.SetNeedRestart != nil {
 		l.deps.SetNeedRestart()
 		l.deps.SetNeedRestart()
@@ -89,10 +138,6 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
 	return nil
 	return nil
 }
 }
 
 
-func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error {
-	return nil
-}
-
 func (l *Local) ResetAllTraffics(_ context.Context) error {
 func (l *Local) ResetAllTraffics(_ context.Context) error {
 	return nil
 	return nil
 }
 }

+ 46 - 19
web/runtime/remote.go

@@ -257,31 +257,58 @@ func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) er
 	return r.UpdateInbound(ctx, ib, ib)
 	return r.UpdateInbound(ctx, ib, ib)
 }
 }
 
 
-func (r *Remote) RestartXray(ctx context.Context) error {
-	_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
-	return err
-}
-
-func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
+func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
 	id, err := r.resolveRemoteID(ctx, ib.Tag)
 	id, err := r.resolveRemoteID(ctx, ib.Tag)
 	if err != nil {
 	if err != nil {
-		logger.Warning("remote ResetClientTraffic: tag", ib.Tag, "not found on", r.node.Name)
+		return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
+	}
+	payload := map[string]any{
+		"client":     client,
+		"inboundIds": []int{id},
+	}
+	if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+// DeleteUser is idempotent: master's per-inbound Delete loop may call it
+// multiple times for the same node, and "not found" on the follow-ups is
+// the expected success path.
+func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+	if email == "" {
+		return nil
+	}
+	_, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/del/"+url.PathEscape(email), nil)
+	if err == nil {
+		return nil
+	}
+	if strings.Contains(strings.ToLower(err.Error()), "not found") {
 		return nil
 		return nil
 	}
 	}
-	_, err = r.do(ctx, http.MethodPost,
-		fmt.Sprintf("panel/api/inbounds/%d/resetClientTraffic/%s", id, url.PathEscape(email)),
-		nil)
 	return err
 	return err
 }
 }
 
 
-func (r *Remote) ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error {
-	id, err := r.resolveRemoteID(ctx, ib.Tag)
-	if err != nil {
-		logger.Warning("remote ResetInboundClientTraffics: tag", ib.Tag, "not found on", r.node.Name)
-		return nil
+func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+	if oldEmail == "" {
+		oldEmail = payload.Email
 	}
 	}
-	_, err = r.do(ctx, http.MethodPost,
-		fmt.Sprintf("panel/api/inbounds/resetAllClientTraffics/%d", id), nil)
+	if _, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (r *Remote) RestartXray(ctx context.Context) error {
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
+	return err
+}
+
+func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
+	_, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
 	return err
 	return err
 }
 }
 
 
@@ -307,14 +334,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
 		return nil, fmt.Errorf("decode inbound list: %w", err)
 		return nil, fmt.Errorf("decode inbound list: %w", err)
 	}
 	}
 
 
-	envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/onlines", nil)
+	envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
 	if err != nil {
 	if err != nil {
 		logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
 		logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
 	} else if len(envOnlines.Obj) > 0 {
 	} else if len(envOnlines.Obj) > 0 {
 		_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
 		_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
 	}
 	}
 
 
-	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/lastOnline", nil)
+	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
 	if err != nil {
 	if err != nil {
 		logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
 		logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
 	} else if len(envLastOnline.Obj) > 0 {
 	} else if len(envLastOnline.Obj) > 0 {

+ 3 - 3
web/runtime/remote_test.go

@@ -7,8 +7,8 @@ import (
 
 
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
-		name     string
-		input    string
+		name  string
+		input string
 		// wantCertFile / wantKeyFile: expected presence after sanitize
 		// wantCertFile / wantKeyFile: expected presence after sanitize
 		wantCertFile bool
 		wantCertFile bool
 		wantKeyFile  bool
 		wantKeyFile  bool
@@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 			wantKeyFile:  false,
 			wantKeyFile:  false,
 		},
 		},
 		{
 		{
-			name: "empty stream settings",
+			name:  "empty stream settings",
 			input: "",
 			input: "",
 			// empty input returns empty, nothing to check
 			// empty input returns empty, nothing to check
 		},
 		},

+ 7 - 1
web/runtime/runtime.go

@@ -16,9 +16,15 @@ type Runtime interface {
 	AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
 	AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
 	RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
 	RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
 
 
+	// Per-client operations that route through the node's clients API on
+	// Remote (instead of pushing the whole inbound) so the node applies
+	// per-user xray API calls without a DelInbound+AddInbound cycle.
+	UpdateUser(ctx context.Context, ib *model.Inbound, email string, payload model.Client) error
+	DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
+	AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
+
 	RestartXray(ctx context.Context) error
 	RestartXray(ctx context.Context) error
 
 
 	ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
 	ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
-	ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error
 	ResetAllTraffics(ctx context.Context) error
 	ResetAllTraffics(ctx context.Context) error
 }
 }

+ 1963 - 0
web/service/client.go

@@ -0,0 +1,1963 @@
+package service
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/gorm"
+)
+
+type ClientWithAttachments struct {
+	model.ClientRecord
+	InboundIds []int               `json:"inboundIds"`
+	Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
+}
+
+// MarshalJSON is required because model.ClientRecord defines its own
+// MarshalJSON. Go promotes the embedded method to the outer struct, so without
+// this the encoder would call ClientRecord.MarshalJSON for the whole value and
+// silently drop InboundIds and Traffic from the API response.
+func (c ClientWithAttachments) MarshalJSON() ([]byte, error) {
+	rec, err := json.Marshal(c.ClientRecord)
+	if err != nil {
+		return nil, err
+	}
+	extras := struct {
+		InboundIds []int               `json:"inboundIds"`
+		Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
+	}{InboundIds: c.InboundIds, Traffic: c.Traffic}
+	extra, err := json.Marshal(extras)
+	if err != nil {
+		return nil, err
+	}
+	if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 {
+		return rec, nil
+	}
+	const maxMarshalSize = 256 << 20
+	if len(rec) > maxMarshalSize || len(extra) > maxMarshalSize {
+		return rec, nil
+	}
+	out := make([]byte, 0, len(rec)+len(extra))
+	out = append(out, rec[:len(rec)-1]...)
+	if len(rec) > 2 {
+		out = append(out, ',')
+	}
+	out = append(out, extra[1:]...)
+	return out, nil
+}
+
+func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
+	if rec == nil {
+		return ""
+	}
+	switch p {
+	case model.Trojan:
+		return rec.Password
+	case model.Shadowsocks:
+		return rec.Email
+	case model.Hysteria, model.Hysteria2:
+		return rec.Auth
+	default:
+		return rec.UUID
+	}
+}
+
+type ClientService struct{}
+
+// Short-lived tombstone of just-deleted client emails so that a node snapshot
+// arriving between delete and node-side processing doesn't resurrect them.
+var (
+	recentlyDeletedMu sync.Mutex
+	recentlyDeleted   = map[string]time.Time{}
+)
+
+const deleteTombstoneTTL = 90 * time.Second
+
+var (
+	inboundMutationLocksMu sync.Mutex
+	inboundMutationLocks   = map[int]*sync.Mutex{}
+)
+
+func lockInbound(inboundId int) *sync.Mutex {
+	inboundMutationLocksMu.Lock()
+	defer inboundMutationLocksMu.Unlock()
+	m, ok := inboundMutationLocks[inboundId]
+	if !ok {
+		m = &sync.Mutex{}
+		inboundMutationLocks[inboundId] = m
+	}
+	m.Lock()
+	return m
+}
+
+func compactOrphans(db *gorm.DB, clients []any) []any {
+	if len(clients) == 0 {
+		return clients
+	}
+	emails := make([]string, 0, len(clients))
+	for _, c := range clients {
+		cm, ok := c.(map[string]any)
+		if !ok {
+			continue
+		}
+		if e, _ := cm["email"].(string); e != "" {
+			emails = append(emails, e)
+		}
+	}
+	if len(emails) == 0 {
+		return clients
+	}
+	var existingEmails []string
+	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
+		logger.Warning("compactOrphans pluck:", err)
+		return clients
+	}
+	if len(existingEmails) == len(emails) {
+		return clients
+	}
+	existing := make(map[string]struct{}, len(existingEmails))
+	for _, e := range existingEmails {
+		existing[e] = struct{}{}
+	}
+	out := make([]any, 0, len(existingEmails))
+	for _, c := range clients {
+		cm, ok := c.(map[string]any)
+		if !ok {
+			out = append(out, c)
+			continue
+		}
+		e, _ := cm["email"].(string)
+		if e == "" {
+			out = append(out, c)
+			continue
+		}
+		if _, ok := existing[e]; ok {
+			out = append(out, c)
+		}
+	}
+	return out
+}
+
+func tombstoneClientEmail(email string) {
+	if email == "" {
+		return
+	}
+	recentlyDeletedMu.Lock()
+	defer recentlyDeletedMu.Unlock()
+	recentlyDeleted[email] = time.Now()
+	cutoff := time.Now().Add(-deleteTombstoneTTL)
+	for e, ts := range recentlyDeleted {
+		if ts.Before(cutoff) {
+			delete(recentlyDeleted, e)
+		}
+	}
+}
+
+func isClientEmailTombstoned(email string) bool {
+	if email == "" {
+		return false
+	}
+	recentlyDeletedMu.Lock()
+	defer recentlyDeletedMu.Unlock()
+	ts, ok := recentlyDeleted[email]
+	if !ok {
+		return false
+	}
+	if time.Since(ts) > deleteTombstoneTTL {
+		delete(recentlyDeleted, email)
+		return false
+	}
+	return true
+}
+
+func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+
+	if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
+		return err
+	}
+
+	for i := range clients {
+		c := clients[i]
+		email := strings.TrimSpace(c.Email)
+		if email == "" {
+			continue
+		}
+
+		incoming := c.ToRecord()
+		row := &model.ClientRecord{}
+		err := tx.Where("email = ?", email).First(row).Error
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			return err
+		}
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			if isClientEmailTombstoned(email) {
+				continue
+			}
+			if err := tx.Create(incoming).Error; err != nil {
+				return err
+			}
+			row = incoming
+		} else {
+			row.UUID = incoming.UUID
+			row.Password = incoming.Password
+			row.Auth = incoming.Auth
+			row.Flow = incoming.Flow
+			row.Security = incoming.Security
+			row.Reverse = incoming.Reverse
+			row.SubID = incoming.SubID
+			row.LimitIP = incoming.LimitIP
+			row.TotalGB = incoming.TotalGB
+			row.ExpiryTime = incoming.ExpiryTime
+			row.Enable = incoming.Enable
+			row.TgID = incoming.TgID
+			row.Comment = incoming.Comment
+			row.Reset = incoming.Reset
+			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+				row.CreatedAt = incoming.CreatedAt
+			}
+			if incoming.UpdatedAt > row.UpdatedAt {
+				row.UpdatedAt = incoming.UpdatedAt
+			}
+			if err := tx.Save(row).Error; err != nil {
+				return err
+			}
+		}
+
+		link := model.ClientInbound{
+			ClientId:     row.Id,
+			InboundId:    inboundId,
+			FlowOverride: c.Flow,
+		}
+		if err := tx.Create(&link).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
+}
+
+func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	type joinedRow struct {
+		model.ClientRecord
+		FlowOverride string
+	}
+	var rows []joinedRow
+	err := tx.Table("clients").
+		Select("clients.*, client_inbounds.flow_override AS flow_override").
+		Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
+		Where("client_inbounds.inbound_id = ?", inboundId).
+		Order("clients.id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+
+	out := make([]model.Client, 0, len(rows))
+	for i := range rows {
+		c := rows[i].ToClient()
+		if rows[i].FlowOverride != "" {
+			c.Flow = rows[i].FlowOverride
+		}
+		out = append(out, *c)
+	}
+	return out, nil
+}
+
+func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	row := &model.ClientRecord{}
+	err := tx.Where("email = ?", email).First(row).Error
+	if err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	var ids []int
+	err := tx.Table("client_inbounds").
+		Select("client_inbounds.inbound_id").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email = ?", email).
+		Scan(&ids).Error
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
+	row := &model.ClientRecord{}
+	if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
+	var ids []int
+	err := database.GetDB().Table("client_inbounds").
+		Where("client_id = ?", id).
+		Order("inbound_id ASC").
+		Pluck("inbound_id", &ids).Error
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+func (s *ClientService) List() ([]ClientWithAttachments, error) {
+	db := database.GetDB()
+	var rows []model.ClientRecord
+	if err := db.Order("id ASC").Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	if len(rows) == 0 {
+		return []ClientWithAttachments{}, nil
+	}
+
+	clientIds := make([]int, 0, len(rows))
+	emails := make([]string, 0, len(rows))
+	for i := range rows {
+		clientIds = append(clientIds, rows[i].Id)
+		if rows[i].Email != "" {
+			emails = append(emails, rows[i].Email)
+		}
+	}
+
+	var links []model.ClientInbound
+	if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
+		return nil, err
+	}
+	attachments := make(map[int][]int, len(rows))
+	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 := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
+			return nil, err
+		}
+		for i := range stats {
+			trafficByEmail[stats[i].Email] = &stats[i]
+		}
+	}
+
+	out := make([]ClientWithAttachments, 0, len(rows))
+	for i := range rows {
+		out = append(out, ClientWithAttachments{
+			ClientRecord: rows[i],
+			InboundIds:   attachments[rows[i].Id],
+			Traffic:      trafficByEmail[rows[i].Email],
+		})
+	}
+	return out, nil
+}
+
+type ClientCreatePayload struct {
+	Client     model.Client `json:"client"`
+	InboundIds []int        `json:"inboundIds"`
+}
+
+func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
+	if payload == nil {
+		return false, common.NewError("empty payload")
+	}
+	client := payload.Client
+	if strings.TrimSpace(client.Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+	if len(payload.InboundIds) == 0 {
+		return false, common.NewError("at least one inbound is required")
+	}
+
+	if client.SubID == "" {
+		client.SubID = uuid.NewString()
+	}
+	if !client.Enable {
+		client.Enable = true
+	}
+	now := time.Now().UnixMilli()
+	if client.CreatedAt == 0 {
+		client.CreatedAt = now
+	}
+	client.UpdatedAt = now
+
+	existing := &model.ClientRecord{}
+	err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		return false, err
+	}
+	emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
+	if emailTaken {
+		if existing.SubID == "" || existing.SubID != client.SubID {
+			return false, common.NewError("email already in use:", client.Email)
+		}
+	}
+
+	needRestart := false
+	for _, ibId := range payload.InboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		if err := s.fillProtocolDefaults(&client, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		})
+		if addErr != nil {
+			return needRestart, addErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
+	switch ib.Protocol {
+	case model.VMESS, model.VLESS:
+		if c.ID == "" {
+			c.ID = uuid.NewString()
+		}
+	case model.Trojan:
+		if c.Password == "" {
+			c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
+		}
+	case model.Shadowsocks:
+		method := shadowsocksMethodFromSettings(ib.Settings)
+		if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
+			c.Password = randomShadowsocksClientKey(method)
+		}
+	case model.Hysteria, model.Hysteria2:
+		if c.Auth == "" {
+			c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
+		}
+	}
+	return nil
+}
+
+// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
+// settings JSON. Returns "" when the field is missing or settings is invalid.
+func shadowsocksMethodFromSettings(settings string) string {
+	if settings == "" {
+		return ""
+	}
+	var m map[string]any
+	if err := json.Unmarshal([]byte(settings), &m); err != nil {
+		return ""
+	}
+	method, _ := m["method"].(string)
+	return method
+}
+
+// randomShadowsocksClientKey returns a per-client key sized to the cipher.
+// The 2022-blake3 ciphers require a base64-encoded key of an exact byte
+// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and
+// chacha20-poly1305) — anything else fails with "bad key" on xray start.
+// Older ciphers accept arbitrary passwords, so we keep the uuid-style.
+func randomShadowsocksClientKey(method string) string {
+	if n := shadowsocksKeyBytes(method); n > 0 {
+		return random.Base64Bytes(n)
+	}
+	return strings.ReplaceAll(uuid.NewString(), "-", "")
+}
+
+// validShadowsocksClientKey reports whether key is acceptable for the cipher.
+// For 2022-blake3 it must decode to the exact byte length the cipher needs;
+// any other method accepts any non-empty string.
+func validShadowsocksClientKey(method, key string) bool {
+	n := shadowsocksKeyBytes(method)
+	if n == 0 {
+		return key != ""
+	}
+	decoded, err := base64.StdEncoding.DecodeString(key)
+	if err != nil {
+		return false
+	}
+	return len(decoded) == n
+}
+
+func shadowsocksKeyBytes(method string) int {
+	switch method {
+	case "2022-blake3-aes-128-gcm":
+		return 16
+	case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
+		return 32
+	}
+	return 0
+}
+
+// applyShadowsocksClientMethod ensures each client entry carries a "method"
+// field for legacy shadowsocks ciphers. xray's multi-user shadowsocks code
+// requires a per-client method; an empty/missing field fails with
+// "unsupported cipher method:". 2022-blake3 ciphers use the top-level
+// method only, so the per-client field must stay absent.
+func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
+	method, _ := settings["method"].(string)
+	if method == "" || strings.HasPrefix(method, "2022-blake3-") {
+		return
+	}
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if existing, _ := cm["method"].(string); existing != "" {
+			continue
+		}
+		cm["method"] = method
+		clients[i] = cm
+	}
+}
+
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	inboundIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+
+	if strings.TrimSpace(updated.Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+	if updated.SubID == "" {
+		updated.SubID = existing.SubID
+	}
+	if updated.SubID == "" {
+		updated.SubID = uuid.NewString()
+	}
+	updated.UpdatedAt = time.Now().UnixMilli()
+	if updated.CreatedAt == 0 {
+		updated.CreatedAt = existing.CreatedAt
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		oldKey := clientKeyForProtocol(inbound.Protocol, existing)
+		if oldKey == "" {
+			continue
+		}
+		if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		}, oldKey)
+		if upErr != nil {
+			return needRestart, upErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	tombstoneClientEmail(existing.Email)
+
+	inboundIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		key := clientKeyForProtocol(inbound.Protocol, existing)
+		if key == "" {
+			continue
+		}
+		nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+
+	db := database.GetDB()
+	if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
+		return needRestart, err
+	}
+	if !keepTraffic && existing.Email != "" {
+		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+			return needRestart, err
+		}
+		if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
+			return needRestart, err
+		}
+	}
+	if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
+		return needRestart, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	currentIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+	have := make(map[int]struct{}, len(currentIds))
+	for _, x := range currentIds {
+		have[x] = struct{}{}
+	}
+
+	clientWire := existing.ToClient()
+	clientWire.UpdatedAt = time.Now().UnixMilli()
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		if _, attached := have[ibId]; attached {
+			continue
+		}
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		copyClient := *clientWire
+		if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		})
+		if addErr != nil {
+			return needRestart, addErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
+	return s.Create(inboundSvc, &ClientCreatePayload{
+		Client:     client,
+		InboundIds: []int{inboundId},
+	})
+}
+
+func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Detach(inboundSvc, rec.Id, []int{inboundId})
+}
+
+func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Attach(inboundSvc, rec.Id, inboundIds)
+}
+
+func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Detach(inboundSvc, rec.Id, inboundIds)
+}
+
+func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Delete(inboundSvc, rec.Id, keepTraffic)
+}
+
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Update(inboundSvc, rec.Id, updated)
+}
+
+func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		return false, err
+	}
+	if len(inboundIds) == 0 {
+		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
+			return false, rErr
+		}
+		return false, nil
+	}
+	needRestart := false
+	for _, ibId := range inboundIds {
+		nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
+		if rErr != nil {
+			return needRestart, rErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
+	db := database.GetDB()
+	now := time.Now().UnixMilli()
+	depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+
+	var rows []xray.ClientTraffic
+	if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
+		return 0, false, err
+	}
+	if len(rows) == 0 {
+		return 0, false, nil
+	}
+
+	emails := make(map[string]struct{}, len(rows))
+	for _, r := range rows {
+		if r.Email != "" {
+			emails[r.Email] = struct{}{}
+		}
+	}
+
+	needRestart := false
+	deleted := 0
+	for email := range emails {
+		var rec model.ClientRecord
+		if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				continue
+			}
+			return deleted, needRestart, err
+		}
+		nr, err := s.Delete(inboundSvc, rec.Id, false)
+		if err != nil {
+			return deleted, needRestart, err
+		}
+		if nr {
+			needRestart = true
+		}
+		deleted++
+	}
+	return deleted, needRestart, nil
+}
+
+func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
+	return submitTrafficWrite(func() error {
+		return s.resetAllClientTrafficsLocked(id)
+	})
+}
+
+func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
+	db := database.GetDB()
+	now := time.Now().Unix() * 1000
+
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		whereText := "inbound_id "
+		if id == -1 {
+			whereText += " > ?"
+		} else {
+			whereText += " = ?"
+		}
+
+		result := tx.Model(xray.ClientTraffic{}).
+			Where(whereText, id).
+			Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+
+		if result.Error != nil {
+			return result.Error
+		}
+
+		inboundWhereText := "id "
+		if id == -1 {
+			inboundWhereText += " > ?"
+		} else {
+			inboundWhereText += " = ?"
+		}
+
+		result = tx.Model(model.Inbound{}).
+			Where(inboundWhereText, id).
+			Update("last_traffic_reset_time", now)
+
+		return result.Error
+	}); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *ClientService) ResetAllTraffics() (bool, error) {
+	res := database.GetDB().Model(&xray.ClientTraffic{}).
+		Where("1 = 1").
+		Updates(map[string]any{"up": 0, "down": 0})
+	if res.Error != nil {
+		return false, res.Error
+	}
+	return res.RowsAffected > 0, nil
+}
+
+func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	currentIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+	have := make(map[int]struct{}, len(currentIds))
+	for _, x := range currentIds {
+		have[x] = struct{}{}
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		if _, attached := have[ibId]; !attached {
+			continue
+		}
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		key := clientKeyForProtocol(inbound.Protocol, existing)
+		if key == "" {
+			continue
+		}
+		nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
+	emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
+	if err != nil {
+		return "", err
+	}
+	seen := make(map[string]string, len(clients))
+	for _, client := range clients {
+		if client.Email == "" {
+			continue
+		}
+		key := strings.ToLower(client.Email)
+		if prev, ok := seen[key]; ok {
+			if prev != client.SubID || client.SubID == "" {
+				return client.Email, nil
+			}
+			continue
+		}
+		seen[key] = client.SubID
+		if existingSub, ok := emailSubIDs[key]; ok {
+			if client.SubID == "" || existingSub == "" || existingSub != client.SubID {
+				return client.Email, nil
+			}
+		}
+	}
+	return "", nil
+}
+
+func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
+	defer lockInbound(data.Id).Unlock()
+
+	clients, err := inboundSvc.GetClients(data)
+	if err != nil {
+		return false, err
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(data.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	interfaceClients := settings["clients"].([]any)
+	nowTs := time.Now().Unix() * 1000
+	for i := range interfaceClients {
+		if cm, ok := interfaceClients[i].(map[string]any); ok {
+			if _, ok2 := cm["created_at"]; !ok2 {
+				cm["created_at"] = nowTs
+			}
+			cm["updated_at"] = nowTs
+			interfaceClients[i] = cm
+		}
+	}
+	existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+	if err != nil {
+		return false, err
+	}
+	if existEmail != "" {
+		return false, common.NewError("Duplicate email:", existEmail)
+	}
+
+	oldInbound, err := inboundSvc.GetInbound(data.Id)
+	if err != nil {
+		return false, err
+	}
+
+	for _, client := range clients {
+		if strings.TrimSpace(client.Email) == "" {
+			return false, common.NewError("client email is required")
+		}
+		switch oldInbound.Protocol {
+		case "trojan":
+			if client.Password == "" {
+				return false, common.NewError("empty client ID")
+			}
+		case "shadowsocks":
+			if client.Email == "" {
+				return false, common.NewError("empty client ID")
+			}
+		case "hysteria", "hysteria2":
+			if client.Auth == "" {
+				return false, common.NewError("empty client ID")
+			}
+		default:
+			if client.ID == "" {
+				return false, common.NewError("empty client ID")
+			}
+		}
+	}
+
+	var oldSettings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+	if err != nil {
+		return false, err
+	}
+
+	if oldInbound.Protocol == model.Shadowsocks {
+		applyShadowsocksClientMethod(interfaceClients, oldSettings)
+	}
+
+	oldClients := oldSettings["clients"].([]any)
+	oldClients = compactOrphans(database.GetDB(), oldClients)
+	oldClients = append(oldClients, interfaceClients...)
+
+	oldSettings["clients"] = oldClients
+
+	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	db := database.GetDB()
+	tx := db.Begin()
+
+	defer func() {
+		if err != nil {
+			tx.Rollback()
+		} else {
+			tx.Commit()
+		}
+	}()
+
+	needRestart := false
+	rt, rterr := inboundSvc.runtimeFor(oldInbound)
+	if rterr != nil {
+		if oldInbound.NodeID != nil {
+			err = rterr
+			return false, err
+		}
+		needRestart = true
+	} else if oldInbound.NodeID == nil {
+		for _, client := range clients {
+			if len(client.Email) == 0 {
+				needRestart = true
+				continue
+			}
+			inboundSvc.AddClientStat(tx, data.Id, &client)
+			if !client.Enable {
+				continue
+			}
+			cipher := ""
+			if oldInbound.Protocol == "shadowsocks" {
+				cipher = oldSettings["method"].(string)
+			}
+			err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+				"email":    client.Email,
+				"id":       client.ID,
+				"auth":     client.Auth,
+				"security": client.Security,
+				"flow":     client.Flow,
+				"password": client.Password,
+				"cipher":   cipher,
+			})
+			if err1 == nil {
+				logger.Debug("Client added on", rt.Name(), ":", client.Email)
+			} else {
+				logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+				needRestart = true
+			}
+		}
+	} else {
+		for _, client := range clients {
+			if len(client.Email) > 0 {
+				inboundSvc.AddClientStat(tx, data.Id, &client)
+			}
+			if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
+				err = err1
+				return false, err
+			}
+		}
+	}
+
+	if err = tx.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		err = gcErr
+		return false, err
+	}
+	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
+	defer lockInbound(data.Id).Unlock()
+
+	clients, err := inboundSvc.GetClients(data)
+	if err != nil {
+		return false, err
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(data.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	interfaceClients := settings["clients"].([]any)
+
+	oldInbound, err := inboundSvc.GetInbound(data.Id)
+	if err != nil {
+		return false, err
+	}
+
+	oldClients, err := inboundSvc.GetClients(oldInbound)
+	if err != nil {
+		return false, err
+	}
+
+	oldEmail := ""
+	newClientId := ""
+	clientIndex := -1
+	for index, oldClient := range oldClients {
+		oldClientId := ""
+		switch oldInbound.Protocol {
+		case "trojan":
+			oldClientId = oldClient.Password
+			newClientId = clients[0].Password
+		case "shadowsocks":
+			oldClientId = oldClient.Email
+			newClientId = clients[0].Email
+		case "hysteria", "hysteria2":
+			oldClientId = oldClient.Auth
+			newClientId = clients[0].Auth
+		default:
+			oldClientId = oldClient.ID
+			newClientId = clients[0].ID
+		}
+		if clientId == oldClientId {
+			oldEmail = oldClient.Email
+			clientIndex = index
+			break
+		}
+	}
+
+	if newClientId == "" || clientIndex == -1 {
+		return false, common.NewError("empty client ID")
+	}
+	if strings.TrimSpace(clients[0].Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+
+	if clients[0].Email != oldEmail {
+		existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+		if err != nil {
+			return false, err
+		}
+		if existEmail != "" {
+			return false, common.NewError("Duplicate email:", existEmail)
+		}
+	}
+
+	var oldSettings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+	if err != nil {
+		return false, err
+	}
+	settingsClients := oldSettings["clients"].([]any)
+	var preservedCreated any
+	if clientIndex >= 0 && clientIndex < len(settingsClients) {
+		if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
+			if v, ok2 := oldMap["created_at"]; ok2 {
+				preservedCreated = v
+			}
+		}
+	}
+	if len(interfaceClients) > 0 {
+		if newMap, ok := interfaceClients[0].(map[string]any); ok {
+			if preservedCreated == nil {
+				preservedCreated = time.Now().Unix() * 1000
+			}
+			newMap["created_at"] = preservedCreated
+			newMap["updated_at"] = time.Now().Unix() * 1000
+			interfaceClients[0] = newMap
+		}
+	}
+	if oldInbound.Protocol == model.Shadowsocks {
+		applyShadowsocksClientMethod(interfaceClients, oldSettings)
+	}
+	settingsClients[clientIndex] = interfaceClients[0]
+	oldSettings["clients"] = settingsClients
+
+	if oldInbound.Protocol == model.VLESS {
+		hasVisionFlow := false
+		for _, c := range settingsClients {
+			cm, ok := c.(map[string]any)
+			if !ok {
+				continue
+			}
+			if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
+				hasVisionFlow = true
+				break
+			}
+		}
+		if !hasVisionFlow {
+			delete(oldSettings, "testseed")
+		}
+	}
+
+	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+	db := database.GetDB()
+	tx := db.Begin()
+
+	defer func() {
+		if err != nil {
+			tx.Rollback()
+		} else {
+			tx.Commit()
+		}
+	}()
+
+	if len(clients[0].Email) > 0 {
+		if len(oldEmail) > 0 {
+			emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
+			targetExists := int64(0)
+			if !emailUnchanged {
+				if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil {
+					return false, err
+				}
+			}
+			if emailUnchanged || targetExists == 0 {
+				err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0])
+				if err != nil {
+					return false, err
+				}
+				err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email)
+				if err != nil {
+					return false, err
+				}
+			} else {
+				stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+				if sErr != nil {
+					return false, sErr
+				}
+				if !stillUsed {
+					if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil {
+						return false, err
+					}
+					if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil {
+						return false, err
+					}
+				}
+				if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil {
+					return false, err
+				}
+			}
+		} else {
+			inboundSvc.AddClientStat(tx, data.Id, &clients[0])
+		}
+	} else {
+		stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+		if err != nil {
+			return false, err
+		}
+		if !stillUsed {
+			err = inboundSvc.DelClientStat(tx, oldEmail)
+			if err != nil {
+				return false, err
+			}
+			err = inboundSvc.DelClientIPs(tx, oldEmail)
+			if err != nil {
+				return false, err
+			}
+		}
+	}
+	needRestart := false
+	if len(oldEmail) > 0 {
+		rt, rterr := inboundSvc.runtimeFor(oldInbound)
+		if rterr != nil {
+			if oldInbound.NodeID != nil {
+				err = rterr
+				return false, err
+			}
+			needRestart = true
+		} else if oldInbound.NodeID == nil {
+			if oldClients[clientIndex].Enable {
+				err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
+				if err1 == nil {
+					logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+			if clients[0].Enable {
+				cipher := ""
+				if oldInbound.Protocol == "shadowsocks" {
+					cipher = oldSettings["method"].(string)
+				}
+				err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+					"email":    clients[0].Email,
+					"id":       clients[0].ID,
+					"security": clients[0].Security,
+					"flow":     clients[0].Flow,
+					"auth":     clients[0].Auth,
+					"password": clients[0].Password,
+					"cipher":   cipher,
+				})
+				if err1 == nil {
+					logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
+				} else {
+					logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+		} else {
+			if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
+				err = err1
+				return false, err
+			}
+		}
+	} else {
+		logger.Debug("Client old email not found")
+		needRestart = true
+	}
+	if err = tx.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		err = gcErr
+		return false, err
+	}
+	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return false, err
+	}
+	var settings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	email := ""
+	client_key := "id"
+	switch oldInbound.Protocol {
+	case "trojan":
+		client_key = "password"
+	case "shadowsocks":
+		client_key = "email"
+	case "hysteria", "hysteria2":
+		client_key = "auth"
+	}
+
+	interfaceClients := settings["clients"].([]any)
+	var newClients []any
+	needApiDel := false
+	clientFound := false
+	for _, client := range interfaceClients {
+		c := client.(map[string]any)
+		c_id := c[client_key].(string)
+		if c_id == clientId {
+			clientFound = true
+			email, _ = c["email"].(string)
+			needApiDel, _ = c["enable"].(bool)
+		} else {
+			newClients = append(newClients, client)
+		}
+	}
+
+	if !clientFound {
+		return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
+	}
+
+	db := database.GetDB()
+	newClients = compactOrphans(db, newClients)
+	if newClients == nil {
+		newClients = []any{}
+	}
+	settings["clients"] = newClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+	if err != nil {
+		return false, err
+	}
+
+	if !emailShared {
+		err = inboundSvc.DelClientIPs(db, email)
+		if err != nil {
+			logger.Error("Error in delete client IPs")
+			return false, err
+		}
+	}
+	needRestart := false
+
+	if len(email) > 0 {
+		var enables []bool
+		err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
+		if err != nil {
+			logger.Error("Get stats error")
+			return false, err
+		}
+		notDepleted := len(enables) > 0 && enables[0]
+		if !emailShared {
+			err = inboundSvc.DelClientStat(db, email)
+			if err != nil {
+				logger.Error("Delete stats Data Error")
+				return false, err
+			}
+		}
+		if needApiDel && notDepleted && oldInbound.NodeID == nil {
+			rt, rterr := inboundSvc.runtimeFor(oldInbound)
+			if rterr != nil {
+				needRestart = true
+			} else {
+				err1 := rt.RemoveUser(context.Background(), oldInbound, email)
+				if err1 == nil {
+					logger.Debug("Client deleted on", rt.Name(), ":", email)
+					needRestart = false
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+		}
+	}
+	if oldInbound.NodeID != nil && len(email) > 0 {
+		rt, rterr := inboundSvc.runtimeFor(oldInbound)
+		if rterr != nil {
+			return false, rterr
+		}
+		if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+			return false, err1
+		}
+	}
+	if err := db.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		return false, gcErr
+	}
+	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return false, err
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
+		return false, err
+	}
+
+	interfaceClients, ok := settings["clients"].([]any)
+	if !ok {
+		return false, common.NewError("invalid clients format in inbound settings")
+	}
+
+	var newClients []any
+	needApiDel := false
+	found := false
+
+	for _, client := range interfaceClients {
+		c, ok := client.(map[string]any)
+		if !ok {
+			continue
+		}
+		if cEmail, ok := c["email"].(string); ok && cEmail == email {
+			found = true
+			needApiDel, _ = c["enable"].(bool)
+		} else {
+			newClients = append(newClients, client)
+		}
+	}
+
+	if !found {
+		return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
+	}
+	db := database.GetDB()
+	newClients = compactOrphans(db, newClients)
+	if newClients == nil {
+		newClients = []any{}
+	}
+	settings["clients"] = newClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+	if err != nil {
+		return false, err
+	}
+
+	if !emailShared {
+		if err := inboundSvc.DelClientIPs(db, email); err != nil {
+			logger.Error("Error in delete client IPs")
+			return false, err
+		}
+	}
+
+	needRestart := false
+
+	if len(email) > 0 && !emailShared {
+		traffic, err := inboundSvc.GetClientTrafficByEmail(email)
+		if err != nil {
+			return false, err
+		}
+		if traffic != nil {
+			if err := inboundSvc.DelClientStat(db, email); err != nil {
+				logger.Error("Delete stats Data Error")
+				return false, err
+			}
+		}
+
+		if needApiDel {
+			rt, rterr := inboundSvc.runtimeFor(oldInbound)
+			if rterr != nil {
+				if oldInbound.NodeID != nil {
+					return false, rterr
+				}
+				needRestart = true
+			} else if oldInbound.NodeID == nil {
+				if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
+					logger.Debug("Client deleted on", rt.Name(), ":", email)
+					needRestart = false
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			} else {
+				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+					return false, err1
+				}
+			}
+		}
+	}
+
+	if err := db.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		return false, gcErr
+	}
+	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, trafficId int, tgId int64) (bool, error) {
+	traffic, inbound, err := inboundSvc.GetClientInboundByTrafficID(trafficId)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId)
+	}
+
+	clientEmail := traffic.Email
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["tgId"] = tgId
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) checkIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	clients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	isEnable := false
+
+	for _, client := range clients {
+		if client.Email == clientEmail {
+			isEnable = client.Enable
+			break
+		}
+	}
+
+	return isEnable, err
+}
+
+func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, false, err
+	}
+	if inbound == nil {
+		return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, false, err
+	}
+
+	clientId := ""
+	clientOldEnabled := false
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			clientOldEnabled = oldClient.Enable
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["enable"] = !clientOldEnabled
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	if err != nil {
+		return false, needRestart, err
+	}
+
+	return !clientOldEnabled, needRestart, nil
+}
+
+func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
+	current, err := s.checkIsEnabledByEmail(inboundSvc, clientEmail)
+	if err != nil {
+		return false, false, err
+	}
+	if current == enable {
+		return false, false, nil
+	}
+	newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
+	if err != nil {
+		return false, needRestart, err
+	}
+	return newEnabled == enable, needRestart, nil
+}
+
+func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["limitIp"] = count
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["expiryTime"] = expiry_time
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundService, clientEmail string, totalGB int) (bool, error) {
+	if totalGB < 0 {
+		return false, common.NewError("totalGB must be >= 0")
+	}
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["totalGB"] = totalGB * 1024 * 1024 * 1024
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}

+ 59 - 0
web/service/client_test.go

@@ -0,0 +1,59 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
+	c := ClientWithAttachments{
+		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
+		InboundIds:   []int{3, 5},
+		Traffic:      &xray.ClientTraffic{Email: "[email protected]", Up: 1024, Down: 4096, Enable: true},
+	}
+	out, err := json.Marshal(c)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if parsed["email"] != "[email protected]" {
+		t.Errorf("expected ClientRecord fields to survive, got %v", parsed)
+	}
+	ids, ok := parsed["inboundIds"].([]any)
+	if !ok {
+		t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out)
+	}
+	if len(ids) != 2 {
+		t.Errorf("expected 2 inbound ids, got %d", len(ids))
+	}
+	if _, ok := parsed["traffic"].(map[string]any); !ok {
+		t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"])
+	}
+}
+
+func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
+	c := ClientWithAttachments{
+		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
+		InboundIds:   nil,
+	}
+	out, err := json.Marshal(c)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if _, present := parsed["traffic"]; present {
+		t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"])
+	}
+	if _, present := parsed["inboundIds"]; !present {
+		t.Errorf("expected inboundIds key to always be present, got %s", out)
+	}
+}

+ 4 - 1
web/service/config.json

@@ -30,7 +30,10 @@
   "outbounds": [{
   "outbounds": [{
       "protocol": "freedom",
       "protocol": "freedom",
       "settings": {
       "settings": {
-        "domainStrategy": "AsIs"
+        "domainStrategy": "AsIs",
+        "finalRules": [
+          { "action": "allow", "ip": ["geoip:private"] }
+        ]
       },
       },
       "tag": "direct"
       "tag": "direct"
     },
     },

+ 147 - 0
web/service/fallback.go

@@ -0,0 +1,147 @@
+package service
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+
+	"gorm.io/gorm"
+)
+
+type FallbackService struct{}
+
+// FallbackInput is the payload shape POSTed by the inbound form.
+type FallbackInput struct {
+	ChildId   int    `json:"childId"`
+	Name      string `json:"name"`
+	Alpn      string `json:"alpn"`
+	Path      string `json:"path"`
+	Xver      int    `json:"xver"`
+	SortOrder int    `json:"sortOrder"`
+}
+
+// GetByMaster returns every fallback rule attached to the master inbound.
+func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
+	var rows []model.InboundFallback
+	err := database.GetDB().
+		Where("master_id = ?", masterId).
+		Order("sort_order ASC, id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+// GetParentForChild finds the first fallback rule that points at childId.
+// Used by client-link generation: when a child inbound is attached as a
+// fallback, its client links should advertise the master's address+port
+// and TLS instead of the child's loopback listen.
+func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
+	var row model.InboundFallback
+	err := database.GetDB().
+		Where("child_id = ?", childId).
+		Order("sort_order ASC, id ASC").
+		First(&row).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &row, nil
+}
+
+// SetByMaster replaces the master's entire fallback list atomically.
+func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
+	db := database.GetDB()
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
+			return err
+		}
+		for i, c := range items {
+			if c.ChildId <= 0 || c.ChildId == masterId {
+				continue
+			}
+			row := model.InboundFallback{
+				MasterId:  masterId,
+				ChildId:   c.ChildId,
+				Name:      c.Name,
+				Alpn:      c.Alpn,
+				Path:      c.Path,
+				Xver:      c.Xver,
+				SortOrder: c.SortOrder,
+			}
+			if row.SortOrder == 0 {
+				row.SortOrder = i
+			}
+			if err := tx.Create(&row).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+// BuildFallbacksJSON resolves the master's fallback rows into Xray's
+// expected settings.fallbacks shape, looking up each child's listen+port
+// to fill the dest field. Returns nil when the master has no rules.
+func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	var rows []model.InboundFallback
+	err := tx.Where("master_id = ?", masterId).
+		Order("sort_order ASC, id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+	if len(rows) == 0 {
+		return nil, nil
+	}
+
+	childIds := make([]int, 0, len(rows))
+	for i := range rows {
+		childIds = append(childIds, rows[i].ChildId)
+	}
+	var children []model.Inbound
+	if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
+		return nil, err
+	}
+	byId := make(map[int]*model.Inbound, len(children))
+	for i := range children {
+		byId[children[i].Id] = &children[i]
+	}
+
+	out := make([]map[string]any, 0, len(rows))
+	for _, r := range rows {
+		child, ok := byId[r.ChildId]
+		if !ok {
+			continue
+		}
+		listen := strings.TrimSpace(child.Listen)
+		if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
+			listen = "127.0.0.1"
+		}
+		entry := map[string]any{
+			"dest": fmt.Sprintf("%s:%d", listen, child.Port),
+		}
+		if r.Name != "" {
+			entry["name"] = r.Name
+		}
+		if r.Alpn != "" {
+			entry["alpn"] = r.Alpn
+		}
+		if r.Path != "" {
+			entry["path"] = r.Path
+		}
+		if r.Xver > 0 {
+			entry["xver"] = r.Xver
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}

File diff suppressed because it is too large
+ 260 - 646
web/service/inbound.go


+ 1 - 1
web/service/metric_history.go

@@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
 }
 }
 
 
 // systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
 // systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
-// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
+// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
 // per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
 // per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
 // process-local — survival across panel restart is not required.
 // process-local — survival across panel restart is not required.
 var (
 var (

+ 119 - 15
web/service/node.go

@@ -24,6 +24,7 @@ type HeartbeatPatch struct {
 	LastHeartbeat int64
 	LastHeartbeat int64
 	LatencyMs     int
 	LatencyMs     int
 	XrayVersion   string
 	XrayVersion   string
+	PanelVersion  string
 	CpuPct        float64
 	CpuPct        float64
 	MemPct        float64
 	MemPct        float64
 	UptimeSecs    uint64
 	UptimeSecs    uint64
@@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	var nodes []*model.Node
 	var nodes []*model.Node
 	err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
 	err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
-	return nodes, err
+	if err != nil || len(nodes) == 0 {
+		return nodes, err
+	}
+
+	type inboundRow struct {
+		Id     int
+		NodeID int `gorm:"column:node_id"`
+	}
+	var inboundRows []inboundRow
+	if err := db.Table("inbounds").
+		Select("id, node_id").
+		Where("node_id IS NOT NULL").
+		Scan(&inboundRows).Error; err != nil {
+		return nodes, nil
+	}
+	if len(inboundRows) == 0 {
+		return nodes, nil
+	}
+	inboundsByNode := make(map[int][]int, len(nodes))
+	nodeByInbound := make(map[int]int, len(inboundRows))
+	for _, row := range inboundRows {
+		inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
+		nodeByInbound[row.Id] = row.NodeID
+	}
+
+	type clientCountRow struct {
+		NodeID int `gorm:"column:node_id"`
+		Count  int `gorm:"column:count"`
+	}
+	var clientCounts []clientCountRow
+	if err := db.Raw(`
+		SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
+		FROM inbounds
+		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
+		WHERE inbounds.node_id IS NOT NULL
+		GROUP BY inbounds.node_id
+	`).Scan(&clientCounts).Error; err == nil {
+		for _, row := range clientCounts {
+			for _, n := range nodes {
+				if n.Id == row.NodeID {
+					n.ClientCount = row.Count
+					break
+				}
+			}
+		}
+	}
+
+	now := time.Now().UnixMilli()
+	type trafficRow struct {
+		InboundID  int `gorm:"column:inbound_id"`
+		Email      string
+		Enable     bool
+		Total      int64
+		Up         int64
+		Down       int64
+		ExpiryTime int64 `gorm:"column:expiry_time"`
+	}
+	var trafficRows []trafficRow
+	inboundIDs := make([]int, 0, len(nodeByInbound))
+	for id := range nodeByInbound {
+		inboundIDs = append(inboundIDs, id)
+	}
+	if err := db.Table("client_traffics").
+		Select("inbound_id, email, enable, total, up, down, expiry_time").
+		Where("inbound_id IN ?", inboundIDs).
+		Scan(&trafficRows).Error; err == nil {
+		online := make(map[string]struct{})
+		for _, email := range s.onlineEmails() {
+			online[email] = struct{}{}
+		}
+		depletedByNode := make(map[int]int)
+		onlineByNode := make(map[int]int)
+		for _, row := range trafficRows {
+			nodeID, ok := nodeByInbound[row.InboundID]
+			if !ok {
+				continue
+			}
+			expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
+			exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
+			if expired || exhausted || !row.Enable {
+				depletedByNode[nodeID]++
+			}
+			if _, ok := online[row.Email]; ok {
+				onlineByNode[nodeID]++
+			}
+		}
+		for _, n := range nodes {
+			n.InboundCount = len(inboundsByNode[n.Id])
+			n.DepletedCount = depletedByNode[n.Id]
+			n.OnlineCount = onlineByNode[n.Id]
+		}
+	}
+
+	return nodes, nil
+}
+
+func (s *NodeService) onlineEmails() []string {
+	svc := InboundService{}
+	return svc.GetOnlineClients()
 }
 }
 
 
 func (s *NodeService) GetById(id int) (*model.Node, error) {
 func (s *NodeService) GetById(id int) (*model.Node, error) {
@@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"last_heartbeat": p.LastHeartbeat,
 		"last_heartbeat": p.LastHeartbeat,
 		"latency_ms":     p.LatencyMs,
 		"latency_ms":     p.LatencyMs,
 		"xray_version":   p.XrayVersion,
 		"xray_version":   p.XrayVersion,
+		"panel_version":  p.PanelVersion,
 		"cpu_pct":        p.CpuPct,
 		"cpu_pct":        p.CpuPct,
 		"mem_pct":        p.MemPct,
 		"mem_pct":        p.MemPct,
 		"uptime_secs":    p.UptimeSecs,
 		"uptime_secs":    p.UptimeSecs,
@@ -238,7 +338,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 			Xray struct {
 			Xray struct {
 				Version string `json:"version"`
 				Version string `json:"version"`
 			} `json:"xray"`
 			} `json:"xray"`
-			Uptime uint64 `json:"uptime"`
+			PanelVersion string `json:"panelVersion"`
+			Uptime       uint64 `json:"uptime"`
 		} `json:"obj"`
 		} `json:"obj"`
 	}
 	}
 	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
 	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
@@ -255,28 +356,31 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 		patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
 		patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
 	}
 	}
 	patch.XrayVersion = o.Xray.Version
 	patch.XrayVersion = o.Xray.Version
+	patch.PanelVersion = o.PanelVersion
 	patch.UptimeSecs = o.Uptime
 	patch.UptimeSecs = o.Uptime
 	return patch, nil
 	return patch, nil
 }
 }
 
 
 type ProbeResultUI struct {
 type ProbeResultUI struct {
-	Status      string  `json:"status"`
-	LatencyMs   int     `json:"latencyMs"`
-	XrayVersion string  `json:"xrayVersion"`
-	CpuPct      float64 `json:"cpuPct"`
-	MemPct      float64 `json:"memPct"`
-	UptimeSecs  uint64  `json:"uptimeSecs"`
-	Error       string  `json:"error"`
+	Status       string  `json:"status"`
+	LatencyMs    int     `json:"latencyMs"`
+	XrayVersion  string  `json:"xrayVersion"`
+	PanelVersion string  `json:"panelVersion"`
+	CpuPct       float64 `json:"cpuPct"`
+	MemPct       float64 `json:"memPct"`
+	UptimeSecs   uint64  `json:"uptimeSecs"`
+	Error        string  `json:"error"`
 }
 }
 
 
 func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 	r := ProbeResultUI{
 	r := ProbeResultUI{
-		LatencyMs:   p.LatencyMs,
-		XrayVersion: p.XrayVersion,
-		CpuPct:      p.CpuPct,
-		MemPct:      p.MemPct,
-		UptimeSecs:  p.UptimeSecs,
-		Error:       p.LastError,
+		LatencyMs:    p.LatencyMs,
+		XrayVersion:  p.XrayVersion,
+		PanelVersion: p.PanelVersion,
+		CpuPct:       p.CpuPct,
+		MemPct:       p.MemPct,
+		UptimeSecs:   p.UptimeSecs,
+		Error:        p.LastError,
 	}
 	}
 	if ok {
 	if ok {
 		r.Status = "online"
 		r.Status = "online"

+ 162 - 0
web/service/node_test.go

@@ -0,0 +1,162 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestNormalizeBasePath(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"", "/"},
+		{"   ", "/"},
+		{"/", "/"},
+		{"/panel", "/panel/"},
+		{"panel", "/panel/"},
+		{"panel/", "/panel/"},
+		{"/panel/", "/panel/"},
+		{"  /panel  ", "/panel/"},
+		{"/a/b/c", "/a/b/c/"},
+	}
+	for _, c := range cases {
+		t.Run(c.in, func(t *testing.T) {
+			got := normalizeBasePath(c.in)
+			if got != c.want {
+				t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestNodeMetricKey(t *testing.T) {
+	cases := []struct {
+		id     int
+		metric string
+		want   string
+	}{
+		{1, "cpu", "node:1:cpu"},
+		{42, "mem", "node:42:mem"},
+		{0, "anything", "node:0:anything"},
+	}
+	for _, c := range cases {
+		got := nodeMetricKey(c.id, c.metric)
+		if got != c.want {
+			t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
+		}
+	}
+}
+
+func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
+	p := HeartbeatPatch{
+		Status:       "ignored-source",
+		LatencyMs:    42,
+		XrayVersion:  "1.8.4",
+		PanelVersion: "3.0.0",
+		CpuPct:       12.5,
+		MemPct:       33.3,
+		UptimeSecs:   12345,
+		LastError:    "",
+	}
+	ui := p.ToUI(true)
+	if ui.Status != "online" {
+		t.Fatalf("Status = %q, want online", ui.Status)
+	}
+	if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
+		t.Fatalf("scalar copy mismatch: %+v", ui)
+	}
+	if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
+		t.Fatalf("metric copy mismatch: %+v", ui)
+	}
+	if ui.Error != "" {
+		t.Fatalf("Error = %q, want empty", ui.Error)
+	}
+}
+
+func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
+	p := HeartbeatPatch{LastError: "connection refused"}
+	ui := p.ToUI(false)
+	if ui.Status != "offline" {
+		t.Fatalf("Status = %q, want offline", ui.Status)
+	}
+	if ui.Error != "connection refused" {
+		t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
+	}
+}
+
+func TestNodeService_Normalize_Valid(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{
+		Name:     "  primary  ",
+		ApiToken: "  abc  ",
+		Address:  "example.com",
+		Port:     8443,
+		Scheme:   "",
+		BasePath: "panel",
+	}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Name != "primary" {
+		t.Fatalf("Name not trimmed: %q", n.Name)
+	}
+	if n.ApiToken != "abc" {
+		t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
+	}
+	if n.Scheme != "https" {
+		t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
+	}
+	if n.BasePath != "/panel/" {
+		t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
+	}
+}
+
+func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Scheme != "http" {
+		t.Fatalf("Scheme = %q, want http", n.Scheme)
+	}
+}
+
+func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "   ", Address: "example.com", Port: 443}
+	if err := s.normalize(n); err == nil {
+		t.Fatal("expected error for empty name")
+	}
+}
+
+func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
+	if err := s.normalize(n); err == nil {
+		t.Fatal("expected error for invalid host")
+	}
+}
+
+func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
+	s := &NodeService{}
+	for _, port := range []int{0, -1, 65536, 100000} {
+		n := &model.Node{Name: "n", Address: "example.com", Port: port}
+		if err := s.normalize(n); err == nil {
+			t.Fatalf("expected error for port %d", port)
+		}
+	}
+}
+
+func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Scheme != "https" {
+		t.Fatalf("Scheme = %q, want https", n.Scheme)
+	}
+}

+ 14 - 6
web/service/panel.go

@@ -16,6 +16,7 @@ import (
 
 
 	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
 )
 )
 
 
 // PanelService provides business logic for panel management operations.
 // PanelService provides business logic for panel management operations.
@@ -35,14 +36,21 @@ const (
 )
 )
 
 
 func (s *PanelService) RestartPanel(delay time.Duration) error {
 func (s *PanelService) RestartPanel(delay time.Duration) error {
-	p, err := os.FindProcess(syscall.Getpid())
-	if err != nil {
-		return err
-	}
 	go func() {
 	go func() {
 		time.Sleep(delay)
 		time.Sleep(delay)
-		err := p.Signal(syscall.SIGHUP)
+		if global.TriggerRestart() {
+			return
+		}
+		if runtime.GOOS == "windows" {
+			logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
+			return
+		}
+		p, err := os.FindProcess(syscall.Getpid())
 		if err != nil {
 		if err != nil {
+			logger.Error("panel restart: FindProcess failed:", err)
+			return
+		}
+		if err := p.Signal(syscall.SIGHUP); err != nil {
 			logger.Error("failed to send SIGHUP signal:", err)
 			logger.Error("failed to send SIGHUP signal:", err)
 		}
 		}
 	}()
 	}()
@@ -213,7 +221,7 @@ func compareVersionStrings(a string, b string) (int, bool) {
 	if !okA || !okB {
 	if !okA || !okB {
 		return 0, false
 		return 0, false
 	}
 	}
-	for i := 0; i < len(aParts); i++ {
+	for i := range len(aParts) {
 		if aParts[i] > bParts[i] {
 		if aParts[i] > bParts[i] {
 			return 1, true
 			return 1, true
 		}
 		}

+ 1 - 1
web/service/port_conflict.go

@@ -72,7 +72,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 				// "udp", or "tcp,udp". if it's set, it wins outright.
 				// "udp", or "tcp,udp". if it's set, it wins outright.
 				if n, ok := st["network"].(string); ok && n != "" {
 				if n, ok := st["network"].(string); ok && n != "" {
 					bits = 0
 					bits = 0
-					for _, part := range strings.Split(n, ",") {
+					for part := range strings.SplitSeq(n, ",") {
 						switch strings.TrimSpace(part) {
 						switch strings.TrimSpace(part) {
 						case "tcp":
 						case "tcp":
 							bits |= transportTCP
 							bits |= transportTCP

+ 5 - 4
web/service/port_conflict_test.go

@@ -56,7 +56,8 @@ func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protoco
 	}
 	}
 }
 }
 
 
-func intPtr(v int) *int { return &v }
+//go:fix inline
+func intPtr(v int) *int { return new(v) }
 
 
 func TestInboundTransports(t *testing.T) {
 func TestInboundTransports(t *testing.T) {
 	cases := []struct {
 	cases := []struct {
@@ -360,7 +361,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 func TestCheckPortConflict_NodeScope(t *testing.T) {
 func TestCheckPortConflict_NodeScope(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
 	seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
-	seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
+	seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
 
 
 	svc := &InboundService{}
 	svc := &InboundService{}
 
 
@@ -370,8 +371,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
 		want   bool
 		want   bool
 	}{
 	}{
 		{"new local same port + tcp clashes with local", nil, true},
 		{"new local same port + tcp clashes with local", nil, true},
-		{"new remote on different node from local is fine", intPtr(2), false},
-		{"new remote on existing node 1 clashes", intPtr(1), true},
+		{"new remote on different node from local is fine", new(2), false},
+		{"new remote on existing node 1 clashes", new(1), true},
 	}
 	}
 	for _, c := range cases {
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 		t.Run(c.name, func(t *testing.T) {

+ 132 - 7
web/service/server.go

@@ -71,11 +71,12 @@ type Status struct {
 		ErrorMsg string       `json:"errorMsg"`
 		ErrorMsg string       `json:"errorMsg"`
 		Version  string       `json:"version"`
 		Version  string       `json:"version"`
 	} `json:"xray"`
 	} `json:"xray"`
-	Uptime   uint64    `json:"uptime"`
-	Loads    []float64 `json:"loads"`
-	TcpCount int       `json:"tcpCount"`
-	UdpCount int       `json:"udpCount"`
-	NetIO    struct {
+	PanelVersion string    `json:"panelVersion"`
+	Uptime       uint64    `json:"uptime"`
+	Loads        []float64 `json:"loads"`
+	TcpCount     int       `json:"tcpCount"`
+	UdpCount     int       `json:"udpCount"`
+	NetIO        struct {
 		Up   uint64 `json:"up"`
 		Up   uint64 `json:"up"`
 		Down uint64 `json:"down"`
 		Down uint64 `json:"down"`
 	} `json:"netIO"`
 	} `json:"netIO"`
@@ -104,6 +105,7 @@ type Release struct {
 type ServerService struct {
 type ServerService struct {
 	xrayService        XrayService
 	xrayService        XrayService
 	inboundService     InboundService
 	inboundService     InboundService
+	settingService     SettingService
 	cachedIPv4         string
 	cachedIPv4         string
 	cachedIPv6         string
 	cachedIPv6         string
 	noIPv6             bool
 	noIPv6             bool
@@ -114,6 +116,128 @@ type ServerService struct {
 	emaCPU             float64
 	emaCPU             float64
 	cachedCpuSpeedMhz  float64
 	cachedCpuSpeedMhz  float64
 	lastCpuInfoAttempt time.Time
 	lastCpuInfoAttempt time.Time
+
+	lastStatusMu sync.RWMutex
+	lastStatus   *Status
+
+	versionsCacheMu sync.Mutex
+	versionsCache   *cachedXrayVersions
+}
+
+type cachedXrayVersions struct {
+	versions  []string
+	fetchedAt time.Time
+}
+
+// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
+// is purely informational (rendered in the "switch Xray version" picker) so a
+// quarter-hour staleness window is fine and saves the API budget.
+const xrayVersionsCacheTTL = 15 * time.Minute
+
+// allowedHistoryBuckets is the bucket-second whitelist for time-series
+// aggregation endpoints (server + node metrics). Restricting it prevents
+// callers from triggering arbitrary aggregation work and keeps the
+// frontend's bucket selector self-documenting.
+var allowedHistoryBuckets = map[int]bool{
+	2:   true, // Real-time view
+	30:  true, // 30s intervals
+	60:  true, // 1m intervals
+	120: true, // 2m intervals
+	180: true, // 3m intervals
+	300: true, // 5m intervals
+}
+
+// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
+// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
+// /server/xrayObservatoryHistory, and /nodes/history.
+func IsAllowedHistoryBucket(bucketSeconds int) bool {
+	return allowedHistoryBuckets[bucketSeconds]
+}
+
+// LastStatus returns the most recent Status snapshot collected by
+// RefreshStatus. Safe for concurrent readers.
+func (s *ServerService) LastStatus() *Status {
+	s.lastStatusMu.RLock()
+	defer s.lastStatusMu.RUnlock()
+	return s.lastStatus
+}
+
+// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
+// appends it to the system-metrics time series. Returns the new snapshot (may
+// be nil if collection failed). Called by the background ticker; the caller is
+// responsible for any side effects (websocket broadcast, xray metrics sample).
+func (s *ServerService) RefreshStatus() *Status {
+	next := s.GetStatus(s.LastStatus())
+	if next == nil {
+		return nil
+	}
+	s.lastStatusMu.Lock()
+	s.lastStatus = next
+	s.lastStatusMu.Unlock()
+	s.AppendStatusSample(time.Now(), next)
+	return next
+}
+
+// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
+// failure we serve the last successful list (if any) so the UI doesn't go
+// blank during a GitHub API hiccup; if there's no cache at all the underlying
+// error is surfaced.
+func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
+	s.versionsCacheMu.Lock()
+	cache := s.versionsCache
+	s.versionsCacheMu.Unlock()
+	if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
+		return cache.versions, nil
+	}
+	versions, err := s.GetXrayVersions()
+	if err != nil {
+		if cache != nil {
+			logger.Warning("GetXrayVersionsCached: serving stale list:", err)
+			return cache.versions, nil
+		}
+		return nil, err
+	}
+	s.versionsCacheMu.Lock()
+	s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
+	s.versionsCacheMu.Unlock()
+	return versions, nil
+}
+
+// GetDefaultLogOutboundTags scans the default Xray config for freedom and
+// blackhole outbound tags so /getXrayLogs can colour-code log lines without
+// the controller re-doing the JSON walk. Falls back to the historical
+// "direct"/"blocked" defaults when the config can't be read.
+func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
+	config, err := s.settingService.GetDefaultXrayConfig()
+	if err == nil && config != nil {
+		if cfgMap, ok := config.(map[string]any); ok {
+			if outbounds, ok := cfgMap["outbounds"].([]any); ok {
+				for _, outbound := range outbounds {
+					obMap, ok := outbound.(map[string]any)
+					if !ok {
+						continue
+					}
+					tag, _ := obMap["tag"].(string)
+					if tag == "" {
+						continue
+					}
+					switch obMap["protocol"] {
+					case "freedom":
+						freedoms = append(freedoms, tag)
+					case "blackhole":
+						blackholes = append(blackholes, tag)
+					}
+				}
+			}
+		}
+	}
+	if len(freedoms) == 0 {
+		freedoms = []string{"direct"}
+	}
+	if len(blackholes) == 0 {
+		blackholes = []string{"blocked"}
+	}
+	return freedoms, blackholes
 }
 }
 
 
 // AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
 // AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
@@ -360,6 +484,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
 		status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
 	}
 	}
 	status.Xray.Version = s.xrayService.GetXrayVersion()
 	status.Xray.Version = s.xrayService.GetXrayVersion()
+	status.PanelVersion = config.GetVersion()
 
 
 	// Application stats
 	// Application stats
 	var rtm runtime.MemStats
 	var rtm runtime.MemStats
@@ -383,8 +508,8 @@ func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
 
 
 // AppendStatusSample writes one tick of every metric we keep — CPU, memory
 // AppendStatusSample writes one tick of every metric we keep — CPU, memory
 // percent, network throughput (bytes/s), online client count, and the three
 // percent, network throughput (bytes/s), online client count, and the three
-// load averages. Called by ServerController.refreshStatus on the same @2s
-// cadence as AppendCpuSample, so all series stay aligned.
+// load averages. Called by RefreshStatus on the same @2s cadence as
+// AppendCpuSample, so all series stay aligned.
 func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
 func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
 	if status == nil {
 	if status == nil {
 		return
 		return

File diff suppressed because it is too large
+ 284 - 407
web/service/tgbot.go


+ 1 - 1
web/service/tgbot_test.go

@@ -6,7 +6,7 @@ import (
 )
 )
 
 
 func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
 func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
-	typ := reflect.TypeOf(LoginAttempt{})
+	typ := reflect.TypeFor[LoginAttempt]()
 	if _, ok := typ.FieldByName("Password"); ok {
 	if _, ok := typ.FieldByName("Password"); ok {
 		t.Fatal("LoginAttempt must not carry attempted passwords")
 		t.Fatal("LoginAttempt must not carry attempted passwords")
 	}
 	}

Some files were not shown because too many files changed in this diff