22 Комити 62f303905e ... ccd56a56a8

Аутор SHA1 Порука Датум
  dependabot[bot] ccd56a56a8 chore(deps): bump github.com/klauspost/compress from 1.18.6 to 1.19.0 (#5731) пре 17 часа
  dependabot[bot] 7a844682b3 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.5 to 4.26.6 (#5730) пре 17 часа
  dependabot[bot] 6626bf4a07 chore(deps): bump google.golang.org/grpc from 1.81.1 to 1.82.0 (#5729) пре 17 часа
  MHSanaei c0df365524 chore(frontend): bump minor npm deps пре 17 часа
  Vitaliy Pavlov 5361b56e5e fix(update): avoid full dnf system upgrade (#5717) пре 17 часа
  nima1024m 9e13b32c34 fix: make all self-managed file downloads/installs atomic, with real completion status (#5711) пре 17 часа
  nima1024m ade74eb321 fix(balancers): keep mixed strategies on one observer (#5674) пре 17 часа
  MHSanaei 97e2c9e7ba fix(web): sync the VLESS generate-key dropdown with the encryption field пре 18 часа
  MHSanaei 5e8327e728 fix(settings): include savePayload in the category body memo deps пре 18 часа
  MHSanaei 7c12700c7d fix(sub): resolve subscription clients and stats from normalized tables пре 18 часа
  MHSanaei c0d17e132d fix(job): batch ip-limit per-email lookups and persistence пре 19 часа
  MHSanaei fc5be5b9e4 feat(web): broadcast delta client stats above a snapshot threshold пре 19 часа
  MHSanaei c3cc8b4374 fix(job): gate ip-limit scan on clients.limit_ip instead of parsing all settings пре 19 часа
  MHSanaei 97588dd0b9 fix(traffic): disable depleted clients by id instead of a second full scan пре 19 часа
  MHSanaei fb1d055b06 fix(traffic): persist delayed-start expiry only for converted clients пре 19 часа
  MHSanaei 4fc301682f test(scale): cover traffic poll, ws payloads, ip-limit job, sub and xray config at 500k пре 19 часа
  MHSanaei 28f7690224 docs: move architecture map into docs/ and refresh it against the live tree пре 21 часа
  MHSanaei 92303094fd feat(settings): let users clear stored secrets from the UI пре 21 часа
  MHSanaei fb3a1559b2 fix(sub): default https:// for scheme-less support and profile URLs пре 21 часа
  MHSanaei a335456cd3 fix(settings): repair legacy path settings that block every settings save пре 22 часа
  MHSanaei 9a3a12b260 fix(node): stop Postgres deadlocks and deleted-client resurrection in node sync пре 22 часа
  MHSanaei 4d6f2ddd97 fix(node): stop force-restarting a node's Xray when its clients auto-disable пре 22 часа
90 измењених фајлова са 4537 додато и 731 уклоњено
  1. 3 0
      CLAUDE.md
  2. 581 0
      docs/architecture.md
  3. 167 177
      frontend/package-lock.json
  4. 5 5
      frontend/package.json
  5. 75 0
      frontend/public/openapi.json
  6. 6 0
      frontend/src/generated/examples.ts
  7. 28 0
      frontend/src/generated/schemas.ts
  8. 7 0
      frontend/src/generated/types.ts
  9. 8 0
      frontend/src/generated/zod.ts
  10. 2 2
      frontend/src/hooks/useClients.ts
  11. 29 0
      frontend/src/lib/xray/vless-encryption.ts
  12. 3 0
      frontend/src/models/setting.ts
  13. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  14. 7 19
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  15. 12 17
      frontend/src/pages/inbounds/form/protocols/vless.tsx
  16. 25 8
      frontend/src/pages/index/PanelUpdateModal.tsx
  17. 8 4
      frontend/src/pages/settings/EmailTab.tsx
  18. 8 4
      frontend/src/pages/settings/GeneralTab.tsx
  19. 45 0
      frontend/src/pages/settings/SecretInput.tsx
  20. 1 1
      frontend/src/pages/settings/SettingsPage.tsx
  21. 8 4
      frontend/src/pages/settings/TelegramTab.tsx
  22. 0 1
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  23. 13 21
      frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx
  24. 29 23
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  25. 260 5
      frontend/src/test/balancer-observatory-sync.test.ts
  26. 47 0
      frontend/src/test/observatory-settings-tab.test.tsx
  27. 121 0
      frontend/src/test/routing-reference-cleanup.test.ts
  28. 27 0
      frontend/src/test/vless-encryption.test.ts
  29. 3 3
      go.mod
  30. 6 6
      go.sum
  31. 93 15
      install.sh
  32. 8 0
      internal/config/config.go
  33. 35 1
      internal/database/db.go
  34. 41 0
      internal/database/db_seed_test.go
  35. 3 6
      internal/sub/clash_service.go
  36. 8 10
      internal/sub/json_service.go
  37. 5 5
      internal/sub/json_service_test.go
  38. 4 4
      internal/sub/mutation_audit_test.go
  39. 2 5
      internal/sub/remark_vars.go
  40. 171 65
      internal/sub/service.go
  41. 26 10
      internal/sub/service_dedup_test.go
  42. 241 0
      internal/sub/sub_scale_test.go
  43. 21 0
      internal/util/common/url.go
  44. 29 0
      internal/util/common/url_test.go
  45. 152 0
      internal/web/controller/panel_update_test.go
  46. 18 4
      internal/web/controller/server.go
  47. 13 2
      internal/web/controller/setting.go
  48. 217 101
      internal/web/job/check_client_ip_job.go
  49. 34 3
      internal/web/job/check_client_ip_job_integration_test.go
  50. 229 0
      internal/web/job/check_client_ip_scale_test.go
  51. 66 21
      internal/web/job/node_traffic_sync_job.go
  52. 46 8
      internal/web/job/xray_traffic_job.go
  53. 31 0
      internal/web/service/client_link.go
  54. 37 0
      internal/web/service/client_locks.go
  55. 2 2
      internal/web/service/global_traffic_test.go
  56. 7 0
      internal/web/service/inbound.go
  57. 8 0
      internal/web/service/inbound_client_ips.go
  58. 74 0
      internal/web/service/inbound_client_traffic_test.go
  59. 27 24
      internal/web/service/inbound_disable.go
  60. 20 25
      internal/web/service/inbound_node.go
  61. 12 4
      internal/web/service/inbound_node_ips.go
  62. 40 31
      internal/web/service/inbound_traffic.go
  63. 42 0
      internal/web/service/node_client_traffic_sum_test.go
  64. 170 11
      internal/web/service/panel/panel.go
  65. 7 0
      internal/web/service/panel/panel_other.go
  66. 211 0
      internal/web/service/panel/panel_test.go
  67. 14 0
      internal/web/service/panel/panel_unix.go
  68. 150 3
      internal/web/service/scale_helpers_test.go
  69. 25 8
      internal/web/service/setting.go
  70. 49 1
      internal/web/service/setting_security_test.go
  71. 106 0
      internal/web/service/traffic_poll_scale_test.go
  72. 93 0
      internal/web/service/ws_payload_scale_test.go
  73. 66 0
      internal/web/service/xray_config_scale_test.go
  74. 12 2
      internal/web/translation/ar-EG.json
  75. 12 2
      internal/web/translation/en-US.json
  76. 12 2
      internal/web/translation/es-ES.json
  77. 12 2
      internal/web/translation/fa-IR.json
  78. 12 2
      internal/web/translation/id-ID.json
  79. 12 2
      internal/web/translation/ja-JP.json
  80. 12 2
      internal/web/translation/pt-BR.json
  81. 12 2
      internal/web/translation/ru-RU.json
  82. 12 2
      internal/web/translation/tr-TR.json
  83. 12 2
      internal/web/translation/uk-UA.json
  84. 12 2
      internal/web/translation/vi-VN.json
  85. 12 2
      internal/web/translation/zh-CN.json
  86. 12 2
      internal/web/translation/zh-TW.json
  87. 5 4
      internal/web/websocket/notifier.go
  88. 1 1
      tools/openapigen/main.go
  89. 115 16
      update.sh
  90. 66 15
      x-ui.sh

+ 3 - 0
CLAUDE.md

@@ -3,6 +3,9 @@
 Operational guide for AI agents working in this repo. Long-form human docs:
 `CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
 Read those before large changes. This file is the short, must-follow version.
+For a deep navigation map (request lifecycle, cron-job table, symptom → file
+index, layering rules), read `docs/architecture.md` on demand — do not guess
+file locations when it can answer in one hop.
 
 ## Stack
 - Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.

+ 581 - 0
docs/architecture.md

@@ -0,0 +1,581 @@
+# 3x-ui — Architecture & Code Map
+
+> Navigation map for contributors and AI coding agents (referenced from `CLAUDE.md`).
+> Goal: jump to the right file in one hop instead of grepping the whole tree.
+> Tracks the `main` branch — paths reflect the latest changes, so verify against the live
+> tree rather than a pinned release (Go module `github.com/mhsanaei/3x-ui/v3`).
+>
+> **How to use this file:** read "Mental model" + "Request lifecycle" first, then
+> use the **Symptom → File index** to locate work. Respect the **Layering rules**
+> when adding code. Verify with the commands in **Build / Test / Lint**.
+
+---
+
+## 1. Mental model (the 30-second version)
+
+3x-ui is a **web control panel for [Xray-core](https://github.com/XTLS/Xray-core)**. The Go
+backend is the source of truth: it stores inbounds/clients/settings in a DB, renders an
+Xray JSON config from that state, supervises the Xray child process, and exposes a REST +
+WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
+separate HTTP server serves **subscription links** to end users.
+
+The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
+inbounds exist — the `mtg` Telegram-proxy binary (`internal/mtproto/`).
+
+Servers and processes, all launched from `main.go`:
+
+| Server / process | Package | Purpose | Default port |
+|---|---|---|---|
+| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
+| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
+| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
+| **mtg** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds | per inbound |
+
+Two key ideas that explain most of the complexity:
+
+1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the
+   backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC
+   API mutation) over a full process restart. See §5.1.
+2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui
+   instances). Every state-changing inbound/client operation is dispatched through a
+   `runtime.Runtime` interface that is either **`Local`** (this box's Xray gRPC API) or
+   **`Remote`** (HTTPS call to a child node, with `verify`/`skip`/`pin`/`mtls` TLS modes).
+   This is the single most important abstraction in the project. See §5.2.
+
+---
+
+## 2. Tech stack
+
+**Backend (Go 1.26):**
+- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
+- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
+- Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
+- Xray: **xtls/xray-core** vendored as a library; the panel talks to the running core over
+  its **gRPC API** and also shells out to manage the process.
+- Telegram bot: **mymmrac/telego**. i18n: **nicksnyder/go-i18n**.
+- Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
+
+**Frontend (`frontend/`):**
+- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
+- Data layer: **TanStack Query** (`@tanstack/react-query`) over **axios**; **Zod 4** schemas.
+- Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**.
+- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
+  embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
+  `login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
+  deployment.
+
+**Important:** the legacy Go-template UI and `web/assets/` are **gone**. All HTML/JS comes
+from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/web`.
+
+---
+
+## 3. Request lifecycle (follow the data)
+
+### 3.1 Admin API request (e.g. "add a client")
+
+```
+Browser (React, axios)
+  → POST {basePath}/panel/api/...
+    → Gin engine (internal/web/web.go: initRouter)
+      → middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt)
+                          → [DomainValidator, if webDomain set] → gzip → sessions("3x-ui")
+                          → base-path/cache-control context → Localizer
+                          → API routes add: ConfigEnvelope (zstd + SHA-256) → CSRF
+        → Controller (internal/web/controller/*.go)   // HTTP concerns only: bind, validate, respond
+          → Service (internal/web/service/*.go)        // business logic + transactions
+            → GORM → DB (internal/database)            // persistence
+            → runtime.Runtime dispatch                 // apply to Xray (Local) or node (Remote)
+              → Local:  internal/xray (gRPC API or config regen + restart)
+              → Remote: internal/web/runtime/remote.go → HTTPS → child node's API
+```
+
+The controller layer is thin. **Business logic lives in services.** When something is wrong
+with *behavior*, the bug is almost always in a service file, not a controller.
+
+### 3.2 Subscription request (end-user fetching their config)
+
+```
+End user → GET {subPath}/{subId}   (separate server, internal/sub)
+  → internal/sub/controller.go (routes: raw / JSON / Clash variants, feature-flagged)
+    → internal/sub/service.go (~2.5k lines — the link/config builder)
+      → reads inbounds+clients+hosts from DB, renders per-protocol share links /
+        Clash YAML / JSON (Host rows can override address/SNI/path per inbound)
+```
+
+### 3.3 Background work (cron jobs)
+
+Scheduled in `internal/web/web.go` → `startTask()`. Each job is a struct in
+`internal/web/job/`. Examples: poll Xray traffic every 5s, check client IP limits every 10s,
+node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly). See §5.4.
+
+---
+
+## 4. Directory map (what lives where)
+
+```
+3x-ui/
+├── main.go                     # Entry point: CLI (run / migrate / migrate-db / setting / cert),
+│                               #   bootstrap, signal handling, restart loop
+├── go.mod / go.sum             # Go deps (module path ends in /v3)
+│
+├── internal/                   # ALL backend Go code (private packages)
+│   ├── config/                 # Env-var config: paths, DB kind/DSN, log level, version
+│   │                           #   Every XUI_* env var is read here (config.go)
+│   ├── database/
+│   │   ├── db.go               # InitDB: connect, AutoMigrate, seeders (~1.4k lines). DB hotspot.
+│   │   ├── migrate_data.go     # Data migrations (seeders/normalizers beyond AutoMigrate)
+│   │   ├── dialect.go          # SQLite vs Postgres SQL differences
+│   │   ├── dump_sqlite.go      # DB export/backup
+│   │   └── model/              # **ALL GORM models** (model.go ~1.1k lines + siblings:
+│   │                           #   node_client_traffic.go, node_client_ip.go,
+│   │                           #   client_global_traffic.go). ⭐ Start here for data shape.
+│   ├── eventbus/               # In-process pub/sub (buffered channel): outbound.down|up,
+│   │                           #   xray.crash, node.down|up, cpu.high, memory.high, login.attempt
+│   ├── tunnelmonitor/          # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
+│   │                           #   HTTP probe (default Cloudflare trace); repeated failures
+│   │                           #   trigger an Xray restart hook. Independent of panel settings.
+│   ├── xray/                   # Xray-core integration (the proxy engine wrapper)
+│   │   ├── process.go          # Spawn/supervise the Xray child process (~750 lines)
+│   │   ├── api.go              # gRPC client to a running Xray (add/remove user, stats) (~800 lines)
+│   │   ├── hot_diff.go         # ⭐ Compute minimal live changes to avoid full restart (~500 lines)
+│   │   ├── config.go           # Xray config object model
+│   │   ├── inbound.go          # Inbound JSON shaping
+│   │   ├── client_traffic.go   # ClientTraffic model (persisted as client_traffics)
+│   │   ├── traffic.go          # Traffic type helpers
+│   │   └── log_writer.go       # Pipe Xray stdout/stderr into the panel logger
+│   │
+│   ├── web/                    # The panel server
+│   │   ├── web.go              # ⭐ Server bootstrap: initRouter (all routes) + startTask (all cron jobs)
+│   │   ├── controller/         # HTTP handlers (thin). One file per resource:
+│   │   │   ├── inbound.go      #   /panel/api/inbounds
+│   │   │   ├── client.go       #   /panel/api/clients (CRUD + bulk + ips + onlines)
+│   │   │   ├── group.go        #   client-group endpoints
+│   │   │   ├── node.go         #   /panel/api/nodes   (multi-node management)
+│   │   │   ├── host.go         #   /panel/api/hosts   (per-inbound subscription host overrides)
+│   │   │   ├── server.go       #   /panel/api/server  (status, xray version, certs, logs, DB import/export)
+│   │   │   ├── setting.go      #   /panel/api/setting (settings + API tokens)
+│   │   │   ├── xray_setting.go #   /panel/api/xray    (raw Xray config editor, WARP/Nord)
+│   │   │   ├── api.go          #   /panel/api gateway (token auth, envelope + CSRF wiring)
+│   │   │   ├── index.go        #   login/logout/csrf/2FA
+│   │   │   ├── spa.go          #   SPA fallback for /panel UI routes
+│   │   │   └── websocket.go    #   WS upgrade endpoint
+│   │   ├── service/            # ⭐⭐ Business logic. This is where most real work happens.
+│   │   │   ├── inbound.go              # Inbound CRUD core (~1.4k lines)
+│   │   │   ├── inbound_node.go         # ⭐ Node sync for inbounds: reconcile, traffic merge (~1.1k lines)
+│   │   │   ├── inbound_traffic.go      # Per-client traffic accounting (~1.1k lines)
+│   │   │   ├── inbound_clients.go      # Client-within-inbound operations
+│   │   │   ├── inbound_sublink.go      # Inbound-level subscription link helpers
+│   │   │   ├── inbound_migration.go    # Inbound schema/format migrations
+│   │   │   ├── client_crud.go          # Client create/read/update/delete
+│   │   │   ├── client_bulk.go          # Bulk client ops (~1.6k lines)
+│   │   │   ├── client_inbound_apply.go # ⭐ Apply client changes to runtime (Local/Remote) (~1.2k lines)
+│   │   │   ├── client_groups.go        # Client grouping
+│   │   │   ├── client_link.go          # Per-client share-link generation
+│   │   │   ├── client_external_link.go # External links attached to clients
+│   │   │   ├── client_wireguard.go     # WireGuard client specifics
+│   │   │   ├── client_paging.go        # Server-side pagination/sort/filter for client lists
+│   │   │   ├── node.go                 # ⭐ NodeService: CRUD, probe, heartbeat, dirty-tracking (~1.1k lines)
+│   │   │   ├── node_mtls.go            # Node mTLS certificate management (master side)
+│   │   │   ├── node_tree.go            # Node hierarchy / descendants
+│   │   │   ├── host.go                 # Host rows (subscription output overrides)
+│   │   │   ├── server.go               # ServerService: status, certs, xray install, DB ops (~2.2k lines)
+│   │   │   ├── setting.go              # SettingService: all panel settings + defaults (~1.3k lines)
+│   │   │   ├── setting_mtls.go         # mTLS settings (node hardening)
+│   │   │   ├── traffic_writer.go       # Batched persistence of traffic deltas to the DB
+│   │   │   ├── xray.go                 # ⭐ XrayService: config gen + restart/hot-apply (~1.2k lines)
+│   │   │   ├── xray_setting.go         # Raw Xray config persistence
+│   │   │   ├── xray_metrics.go         # Xray observability metrics
+│   │   │   ├── metric_history.go       # Historical system/xray metrics
+│   │   │   ├── reality_scan.go         # REALITY target scanner
+│   │   │   ├── url_safety.go           # Outbound URL validation (SSRF guards)
+│   │   │   ├── outbound_subscription.go# Outbound subscription (e.g. Warp/Nord provider configs)
+│   │   │   ├── port_conflict.go        # Detect inbound port collisions
+│   │   │   ├── fallback.go             # Xray fallback (SNI/ALPN routing on shared port)
+│   │   │   ├── email/                  # Email notification service (SMTP)
+│   │   │   ├── integration/            # External providers: warp.go (Cloudflare WARP), nord.go (NordVPN)
+│   │   │   ├── outbound/               # Outbound config service
+│   │   │   ├── panel/                  # Cross-cutting panel services:
+│   │   │   │   ├── panel.go            #   panel-level helpers
+│   │   │   │   ├── user.go             #   admin user auth (bcrypt)
+│   │   │   │   ├── api_token.go        #   API token CRUD (SHA-256 hashed)
+│   │   │   │   └── websocket.go        #   WS hub / push service
+│   │   │   └── tgbot/                  # Telegram bot command handlers
+│   │   ├── runtime/            # ⭐⭐ The Local/Remote node abstraction (see §5.2)
+│   │   │   ├── runtime.go      #   the Runtime interface (the contract)
+│   │   │   ├── local.go        #   Local impl → this box's Xray gRPC API
+│   │   │   ├── remote.go       #   Remote impl → HTTPS calls to a child node
+│   │   │   ├── tls_client.go   #   per-node HTTP client: verify / skip / pin / mtls
+│   │   │   └── manager.go      #   RuntimeFor(nodeID) → picks Local or Remote
+│   │   ├── job/               # Cron job structs (one file per job — see §5.4)
+│   │   ├── middleware/        # Gin middleware: security.go (headers/HSTS), bodylimit.go,
+│   │   │                      #   domainValidator.go, validate.go (CSRF), config_envelope.go
+│   │   ├── global/           # Global singletons: web server + sub server handles, restart hook
+│   │   ├── network/          # Custom net listeners (e.g. proxy-protocol aware)
+│   │   ├── session/          # Session/cookie helpers
+│   │   ├── websocket/        # WS hub implementation
+│   │   ├── locale/ + translation/  # i18n middleware + 13 locale JSON catalogs
+│   │   ├── entity/           # Shared request/response DTOs
+│   │   └── dist/             # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
+│   │
+│   ├── sub/                   # The subscription server (separate from panel)
+│   │   ├── sub.go             #   server bootstrap
+│   │   ├── controller.go      #   routes for raw / JSON / Clash subscription formats
+│   │   ├── service.go         # ⭐ The link/config builder (~2.5k lines — share-link logic lives here)
+│   │   ├── json_service.go    #   JSON subscription format
+│   │   ├── clash_service.go   #   Clash/Mihomo YAML format
+│   │   ├── clash_external.go  #   external Clash config integration
+│   │   ├── external_subscription.go / external_config.go  # external sub import/aggregation
+│   │   ├── host_sub.go        #   Host-row overrides applied to subscription output
+│   │   ├── endpoint.go        #   subscription endpoint configuration
+│   │   ├── vless_route.go     #   VLESS route shaping
+│   │   ├── remark_vars.go     #   remark variable expansion
+│   │   └── links.go           #   link helpers
+│   │
+│   ├── mtproto/              # Embedded MTProto (Telegram) proxy: manager.go + per-OS
+│   │                         #   process supervision + orphan cleanup
+│   ├── logger/              # App logger (op/go-logging + lumberjack rotation)
+│   └── util/                # Leaf helpers (no business logic):
+│       ├── common/          #   errors, misc
+│       ├── crypto/          #   key/cert generation (x25519, ML-KEM/ML-DSA, ECH)
+│       ├── link/            #   outbound share-link building primitives
+│       ├── wirecodec/ + wireguard/  # WireGuard codec + integration helpers
+│       └── random/, json_util/, reflect_util/, sys/, netproxy/, netsafe/, ldap/
+│
+├── frontend/                 # React SPA (built into internal/web/dist)
+│   ├── vite.config.js        # ⭐ Build config: outDir → ../internal/web/dist, dev on :5173
+│   │                         #   (strict) proxying to :2053, entries index/login/subpage.html
+│   ├── package.json          # scripts: dev / build / preview / lint / typecheck / test / gen
+│   └── src/
+│       ├── main.tsx / routes.tsx / queryClient.ts   # SPA entry, router, query client
+│       ├── entries/          # Extra HTML entry points: login.tsx, subpage.tsx
+│       ├── pages/            # ⭐ Route screens. Mirrors the panel's feature areas:
+│       │   ├── inbounds/     #   inbound list + the big inbound form (protocols/security/transport)
+│       │   ├── clients/      #   client management screens
+│       │   ├── nodes/        #   multi-node UI
+│       │   ├── hosts/        #   subscription host-override UI
+│       │   ├── xray/         #   raw Xray config UI (routing, dns, outbounds, balancers, overrides)
+│       │   ├── index/        #   dashboard/home
+│       │   └── settings/, groups/, sub/, login/, api-docs/
+│       ├── api/              # ⭐ Data layer: axios-init, QueryProvider, queryKeys, websocket bridge
+│       │   └── queries/      #   TanStack Query hooks (useNodesQuery, useStatusQuery, …)
+│       ├── schemas/          # Zod schemas: protocols, forms, api, primitives
+│       ├── generated/        # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts
+│       ├── components/       # Reusable UI (clients/ form/ ui/ viz/ feedback/ utility/)
+│       ├── lib/              # Frontend domain logic (xray/ inbounds/ clients/)
+│       ├── hooks/, models/, layouts/, i18n/, utils/, styles/
+│       └── test/             # Vitest + golden fixtures (config-generation snapshot tests)
+│
+├── tools/openapigen/         # ⭐ Go program that emits frontend/src/generated/* from Go types (§5.5)
+├── docs/                     # Markdown docs (this file, custom-subscription-templates.md, …)
+├── media/                    # README images
+│
+├── Dockerfile / docker-compose.yml / DockerEntrypoint.sh / DockerInit.sh   # Container build/run
+├── install.sh / update.sh / x-ui.sh                        # VPS install + management CLI
+├── x-ui.service.*  / x-ui.rc                               # systemd units (debian/rhel/arch) + rc script
+├── windows_files/                                          # Windows service support
+└── .github/workflows/        # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
+                              #     mutation.yml, cleanup_caches.yml, claude-bot.yml
+```
+
+---
+
+## 5. Cross-cutting subsystems (the parts that span many files)
+
+### 5.1 DB → Xray config pipeline (config generation & application)
+
+The panel never edits Xray's running config directly from controllers. The flow is:
+
+1. A service mutates DB state (inbound/client/setting).
+2. `XrayService` (`service/xray.go`) builds a fresh `xray.Config` from DB state
+   (`GetXrayConfig`).
+3. It tries a **hot apply** (`tryHotApply` → `xray/hot_diff.go`): diff old vs new config and
+   push only the deltas over the Xray gRPC API (add/remove inbound, add/remove user) — **no
+   process restart**, so live connections survive.
+4. If the diff isn't hot-applicable (structural change), it falls back to a **full restart**
+   of the Xray process (`xray/process.go`).
+
+Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
+`IsNeedRestartAndSetFalse`), consumed by a `@every 30s` cron task registered in `startTask()`
+— any number of mutations inside the window causes at most one restart.
+
+**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
+`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
+
+### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
+
+A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
+inbound/client operation goes through the `runtime.Runtime` interface so the *same service
+code* works whether the target is the local Xray or a remote node.
+
+- **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`,
+  `UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`,
+  `RestartXray`, `ResetClientTraffic`, `ResetInboundTraffic`, `ResetAllTraffics`.
+- **`Local`** (`local.go`): calls this box's Xray gRPC API directly.
+- **`Remote`** (`remote.go`): serializes the operation and sends it over HTTPS to the child
+  node's API.
+- **TLS modes** (`tls_client.go`, per-node `TlsVerifyMode`):
+  `verify` (system CAs, default) / `skip` (no validation) / `pin` (leaf cert SHA-256 must
+  match `PinnedCertSha256`) / `mtls` (master presents a client certificate; node cert checked
+  against system roots; API token optional). Master-side cert management:
+  `service/node_mtls.go` + `service/setting_mtls.go`.
+- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
+  otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
+
+**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an
+`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
+online clients back to the originating panel using **stable GUIDs** rather than local IDs.
+Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge,
+`synthNodeGuid`, `panelGuid`) and `service/node.go` (`effectiveNodeGuid`, heartbeat, dirty
+tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an offline node's
+inbound edits converge once it reconnects.
+
+**Where to look for node bugs:**
+- Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`.
+- Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths).
+- Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`).
+- Edits to an offline node not applying on reconnect → dirty/reconcile logic in `service/inbound_node.go` + `service/node.go` (`MarkNodeDirty`/`ClearNodeDirty`/`NodeSyncState`).
+- TLS/mTLS handshake failures → `runtime/tls_client.go`, `service/node_mtls.go`, `service/node.go` (`FetchCertFingerprint`).
+
+### 5.3 Traffic accounting
+
+Per-client and per-inbound up/down counters originate from Xray's stats API and are persisted
+to the DB. The Xray traffic job polls the core; node traffic is pulled from child nodes and
+merged with GUID-based baselines to avoid double counting after resets.
+
+**Key files:** `service/inbound_traffic.go`, `service/traffic_writer.go`,
+`job/xray_traffic_job.go`, `job/node_traffic_sync_job.go`, `service/inbound_node.go`
+(`SetRemoteTraffic` / `upsertNodeBaseline`), models `xray.ClientTraffic`,
+`model.NodeClientTraffic`, `model.ClientGlobalTraffic` (cross-master totals).
+Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
+
+### 5.4 Background jobs (cron)
+
+All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
+
+| Schedule | Job | Purpose / condition |
+|---|---|---|
+| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
+| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
+| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
+| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
+| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
+| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
+| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
+| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
+| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
+| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | Log cleanup; resets |
+| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
+| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
+| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
+| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
+| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
+| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
+| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
+
+To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file.
+
+### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files
+
+The Go backend is the schema source of truth. `tools/openapigen` (a Go program, with a
+`StructAllow` allowlist of exported types) emits
+`frontend/src/generated/{schemas,types,zod,examples}.ts`. The frontend build runs this first:
+
+- `npm run gen:zod` → `go run ./tools/openapigen` (regenerate from Go)
+- `npm run gen:api` → builds the OpenAPI doc (`scripts/build-openapi.mjs`, driven by the
+  hand-maintained endpoint registry `src/pages/api-docs/endpoints.ts`)
+- `npm run build` runs `gen:api` then `vite build`.
+
+**Implication:** if you change a Go model/DTO that crosses the API boundary, regenerate the
+frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/` by hand.
+
+### 5.6 Share-link / subscription generation
+
+Two distinct code paths produce client configs:
+- **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go`
+  + `util/link/outbound.go`.
+- **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links),
+  `internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML).
+  **`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/
+  security per inbound in subscription output — applied in `sub/host_sub.go`.
+
+Both paths must agree per protocol. A malformed link for a specific protocol/transport combo
+(e.g. XHTTP + Reality) is usually a field-lookup mismatch in **`internal/sub/service.go`** (and
+its tests `service_test.go` / golden fixtures), or in `util/link/outbound.go`. The frontend
+also has protocol schemas under `frontend/src/schemas/protocols/` and `frontend/src/lib/xray/`.
+
+### 5.7 Event bus (in-process pub/sub)
+
+`internal/eventbus/` is a minimal buffered-channel pub/sub. Producers call a non-blocking
+`Publish(Event)`; all subscribers receive every event. Event types: `outbound.down|up`,
+`xray.crash`, `node.down|up`, `cpu.high`, `memory.high`, `login.attempt`, with structured
+payloads (OutboundHealthData, NodeHealthData, LoginEventData, SystemMetricData). Producers
+include the CPU/memory jobs, node heartbeat, and login handling; consumers include the
+Telegram bot and the email notifier (`service/email/`). Use it for cross-cutting
+notifications instead of importing notification services into producers.
+
+### 5.8 Tunnel health monitor
+
+`internal/tunnelmonitor/` is an optional watchdog configured **only via env vars**
+(`XUI_TUNNEL_HEALTH_*`, read in `internal/config/`), deliberately independent of panel
+settings so it can be enabled from a systemd `EnvironmentFile` even when the panel is
+unreachable. It periodically probes an HTTP URL (default: Cloudflare trace endpoint) through
+the tunnel; after N successive failures (default 3) it fires a recovery callback wired to an
+Xray restart.
+
+---
+
+## 6. Data model cheat-sheet
+
+GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered
+for AutoMigrate in `internal/database/db.go`.
+
+| Model | Table role | Notable fields |
+|---|---|---|
+| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
+| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
+| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
+| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
+| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
+| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
+| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
+| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
+| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
+| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
+| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
+| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
+| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
+| `OutboundTraffics` | Outbound counters | per outbound tag |
+| `OutboundSubscription` | External provider subs | Warp/Nord style |
+| `Setting` | Key/value panel settings | everything configurable |
+| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
+| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
+| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
+
+---
+
+## 7. Symptom → File index (start here when debugging)
+
+| Symptom / task | Primary file(s) | Then check |
+|---|---|---|
+| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
+| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
+| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
+| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
+| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
+| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
+| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
+| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
+| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
+| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
+| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
+| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
+| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
+| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
+| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
+| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
+| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
+| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
+| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
+| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
+| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
+| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
+| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
+| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |
+| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
+| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
+| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
+| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
+| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
+| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
+| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
+| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
+| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
+| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
+
+---
+
+## 8. Layering rules (where new code belongs)
+
+1. **Controllers are thin.** Only: bind/validate input, call one service, shape the HTTP
+   response. No DB queries, no Xray calls, no business rules in `controller/`.
+2. **Services own the logic and transactions.** All business rules, DB access, and decisions
+   about applying changes live in `service/`. If you're tempted to query GORM from a
+   controller, move it to a service.
+3. **Never touch Xray's running state from a controller or job directly.** Go through
+   `XrayService` / the `runtime.Runtime` interface so local vs node dispatch stays correct.
+4. **Any state-changing inbound/client op must dispatch through `runtime.Runtime`**, not
+   straight to `xray/api.go` — otherwise node deployments silently break.
+5. **`internal/util/*` is leaf-only** (no imports of `service`/`controller`/`database`). Keep
+   helpers pure.
+6. **Don't hand-edit generated files:** `frontend/src/generated/*` and `internal/web/dist/*`.
+   Regenerate instead.
+7. **Models are the contract.** Changing a model field that crosses the API boundary means:
+   update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types.
+8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user*
+   fetches goes in `internal/sub`. Don't blur them.
+9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
+   of importing the Telegram/email services into producers.
+
+---
+
+## 9. Build / Test / Lint (verify your changes)
+
+The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make gen`
+(regenerate Zod/OpenAPI), `make lint` (Go + frontend), `make test` (Go `-shuffle=on` +
+frontend), `make race`, `make build`. Run `make help` for everything. Raw commands:
+
+**Backend (Go):**
+```bash
+go build ./...                      # compile everything
+go test ./...                       # run all Go tests (many *_test.go alongside sources)
+go test ./internal/web/service/...  # focused: service-layer tests
+go test ./internal/xray/...         # hot-diff / process / api tests
+go test ./internal/sub/...          # subscription + golden link tests
+go vet ./...                        # static checks
+golangci-lint run                   # full lint (gofumpt + goimports formatting)
+go run main.go                      # run the panel locally (serves embedded dist if built)
+```
+
+**Frontend (`cd frontend`, Node ≥ 22):**
+```bash
+npm install
+npm run dev          # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
+npm run typecheck    # tsc --noEmit
+npm run lint         # eslint src
+npm run test         # vitest (incl. golden config-generation snapshots)
+npm run gen          # regenerate src/generated/* from Go (gen:zod + gen:api)
+npm run build        # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed)
+```
+
+**Full local loop:** `cd frontend && npm run build` (refresh embedded `dist/`) → back to repo
+root → `go build ./...` / `go run main.go`.
+
+**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
+
+**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
+`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
+(multi-arch image + release builds), `cleanup_caches.yml`, `claude-bot.yml` (issue bot).
+
+---
+
+## 10. Gotchas & conventions
+
+- **Module path is `.../v3`.** Internal imports use `github.com/mhsanaei/3x-ui/v3/internal/...`.
+- **SQLite vs Postgres.** Default is SQLite at `{XUI_DB_FOLDER}/x-ui.db`. Postgres via
+  `XUI_DB_TYPE=postgres` + `XUI_DB_DSN`. Some SQL paths are dialect-aware (`database/dialect.go`);
+  test both when touching raw queries (there are `*_scale_postgres_test.go` suites).
+- **`Inbound.Settings` / `StreamSettings` / `Sniffing` are raw JSON strings**, not structured
+  columns. Parsing/validation happens in services and the `xray` package, not in GORM.
+- **Hot-reload is the default; full restart is the fallback.** Changes that look config-only
+  but cause a restart usually mean the diff in `xray/hot_diff.go` didn't recognize them as hot.
+- **Node TLS:** remote calls honor `TlsVerifyMode` (`verify`/`skip`/`pin`/`mtls`). "Works on
+  skip, fails on verify/pin/mtls" → cert/fingerprint handling in `service/node.go`
+  (`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
+- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
+  in-process restart hook (`global.SetRestartHook`) funnels into the same path.
+- **i18n:** backend catalogs in `internal/web/translation/` (13 locales, shared with the
+  frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
+  locale (Jalali calendar via `persian-calendar-suite`).
+- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
+  `frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
+  not blindly, when output changes.

+ 167 - 177
frontend/package-lock.json

@@ -18,15 +18,15 @@
         "axios": "^1.18.1",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
-        "i18next": "^26.3.3",
+        "i18next": "^26.3.4",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
         "qs": "^6.15.3",
         "react": "^19.2.7",
         "react-dom": "^19.2.7",
         "react-i18next": "^17.0.8",
-        "react-router-dom": "^7.18.0",
-        "recharts": "^3.9.0",
+        "react-router-dom": "^7.18.1",
+        "recharts": "^3.9.1",
         "swagger-ui-react": "^5.32.8",
         "zod": "^4.4.3"
       },
@@ -45,8 +45,8 @@
         "globals": "^17.7.0",
         "jsdom": "^29.1.1",
         "typescript": "^6.0.3",
-        "typescript-eslint": "^8.62.0",
-        "vite": "8.1.0",
+        "typescript-eslint": "^8.62.1",
+        "vite": "8.1.3",
         "vitest": "^4.1.9"
       },
       "engines": {
@@ -1106,9 +1106,9 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.137.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
-      "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+      "version": "0.138.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+      "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -1844,20 +1844,10 @@
         }
       }
     },
-    "node_modules/@reduxjs/toolkit/node_modules/immer": {
-      "version": "11.1.8",
-      "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
-      "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
-      "license": "MIT",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/immer"
-      }
-    },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
-      "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+      "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
       "cpu": [
         "arm64"
       ],
@@ -1872,9 +1862,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
-      "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+      "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
       "cpu": [
         "arm64"
       ],
@@ -1889,9 +1879,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
-      "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+      "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
       "cpu": [
         "x64"
       ],
@@ -1906,9 +1896,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
-      "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+      "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
       "cpu": [
         "x64"
       ],
@@ -1923,9 +1913,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
-      "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+      "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
       "cpu": [
         "arm"
       ],
@@ -1940,9 +1930,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
-      "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+      "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
       "cpu": [
         "arm64"
       ],
@@ -1960,9 +1950,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
-      "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+      "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
       "cpu": [
         "arm64"
       ],
@@ -1980,9 +1970,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
-      "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+      "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
       "cpu": [
         "ppc64"
       ],
@@ -2000,9 +1990,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
-      "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+      "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
       "cpu": [
         "s390x"
       ],
@@ -2020,9 +2010,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
-      "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+      "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
       "cpu": [
         "x64"
       ],
@@ -2040,9 +2030,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
-      "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+      "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
       "cpu": [
         "x64"
       ],
@@ -2060,9 +2050,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
-      "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+      "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
       "cpu": [
         "arm64"
       ],
@@ -2077,9 +2067,9 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
-      "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+      "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
       "cpu": [
         "wasm32"
       ],
@@ -2096,9 +2086,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
-      "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+      "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
       "cpu": [
         "arm64"
       ],
@@ -2113,9 +2103,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
-      "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+      "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
       "cpu": [
         "x64"
       ],
@@ -3109,17 +3099,17 @@
       "license": "MIT"
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
-      "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
+      "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/regexpp": "^4.12.2",
-        "@typescript-eslint/scope-manager": "8.62.0",
-        "@typescript-eslint/type-utils": "8.62.0",
-        "@typescript-eslint/utils": "8.62.0",
-        "@typescript-eslint/visitor-keys": "8.62.0",
+        "@typescript-eslint/scope-manager": "8.62.1",
+        "@typescript-eslint/type-utils": "8.62.1",
+        "@typescript-eslint/utils": "8.62.1",
+        "@typescript-eslint/visitor-keys": "8.62.1",
         "ignore": "^7.0.5",
         "natural-compare": "^1.4.0",
         "ts-api-utils": "^2.5.0"
@@ -3132,7 +3122,7 @@
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^8.62.0",
+        "@typescript-eslint/parser": "^8.62.1",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "typescript": ">=4.8.4 <6.1.0"
       }
@@ -3148,16 +3138,16 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
-      "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
+      "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.62.0",
-        "@typescript-eslint/types": "8.62.0",
-        "@typescript-eslint/typescript-estree": "8.62.0",
-        "@typescript-eslint/visitor-keys": "8.62.0",
+        "@typescript-eslint/scope-manager": "8.62.1",
+        "@typescript-eslint/types": "8.62.1",
+        "@typescript-eslint/typescript-estree": "8.62.1",
+        "@typescript-eslint/visitor-keys": "8.62.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3173,14 +3163,14 @@
       }
     },
     "node_modules/@typescript-eslint/project-service": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
-      "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
+      "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/tsconfig-utils": "^8.62.0",
-        "@typescript-eslint/types": "^8.62.0",
+        "@typescript-eslint/tsconfig-utils": "^8.62.1",
+        "@typescript-eslint/types": "^8.62.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3195,14 +3185,14 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
-      "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
+      "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.62.0",
-        "@typescript-eslint/visitor-keys": "8.62.0"
+        "@typescript-eslint/types": "8.62.1",
+        "@typescript-eslint/visitor-keys": "8.62.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3213,9 +3203,9 @@
       }
     },
     "node_modules/@typescript-eslint/tsconfig-utils": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
-      "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
+      "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3230,15 +3220,15 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
-      "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
+      "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.62.0",
-        "@typescript-eslint/typescript-estree": "8.62.0",
-        "@typescript-eslint/utils": "8.62.0",
+        "@typescript-eslint/types": "8.62.1",
+        "@typescript-eslint/typescript-estree": "8.62.1",
+        "@typescript-eslint/utils": "8.62.1",
         "debug": "^4.4.3",
         "ts-api-utils": "^2.5.0"
       },
@@ -3255,9 +3245,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
-      "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
+      "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3269,16 +3259,16 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
-      "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
+      "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/project-service": "8.62.0",
-        "@typescript-eslint/tsconfig-utils": "8.62.0",
-        "@typescript-eslint/types": "8.62.0",
-        "@typescript-eslint/visitor-keys": "8.62.0",
+        "@typescript-eslint/project-service": "8.62.1",
+        "@typescript-eslint/tsconfig-utils": "8.62.1",
+        "@typescript-eslint/types": "8.62.1",
+        "@typescript-eslint/visitor-keys": "8.62.1",
         "debug": "^4.4.3",
         "minimatch": "^10.2.2",
         "semver": "^7.7.3",
@@ -3310,16 +3300,16 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
-      "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
+      "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.9.1",
-        "@typescript-eslint/scope-manager": "8.62.0",
-        "@typescript-eslint/types": "8.62.0",
-        "@typescript-eslint/typescript-estree": "8.62.0"
+        "@typescript-eslint/scope-manager": "8.62.1",
+        "@typescript-eslint/types": "8.62.1",
+        "@typescript-eslint/typescript-estree": "8.62.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3334,13 +3324,13 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
-      "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
+      "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/types": "8.62.1",
         "eslint-visitor-keys": "^5.0.0"
       },
       "engines": {
@@ -3919,9 +3909,9 @@
       "license": "MIT"
     },
     "node_modules/baseline-browser-mapping": {
-      "version": "2.10.40",
-      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
-      "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
+      "version": "2.10.41",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
+      "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
       "dev": true,
       "license": "Apache-2.0",
       "bin": {
@@ -4059,9 +4049,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001799",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
-      "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+      "version": "1.0.30001800",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
+      "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
       "dev": true,
       "funding": [
         {
@@ -4645,9 +4635,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.381",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
-      "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
+      "version": "1.5.384",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
+      "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
       "dev": true,
       "license": "ISC"
     },
@@ -4778,9 +4768,9 @@
       }
     },
     "node_modules/es-module-lexer": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
-      "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
+      "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
       "dev": true,
       "license": "MIT"
     },
@@ -5701,9 +5691,9 @@
       }
     },
     "node_modules/i18next": {
-      "version": "26.3.3",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
-      "integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
+      "version": "26.3.4",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
+      "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
       "funding": [
         {
           "type": "individual",
@@ -5759,9 +5749,9 @@
       }
     },
     "node_modules/immer": {
-      "version": "10.2.0",
-      "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
-      "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+      "version": "11.1.9",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
+      "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
       "license": "MIT",
       "funding": {
         "type": "opencollective",
@@ -7691,9 +7681,9 @@
       }
     },
     "node_modules/react-router": {
-      "version": "7.18.0",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
-      "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
+      "version": "7.18.1",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+      "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
       "license": "MIT",
       "dependencies": {
         "cookie": "^1.0.1",
@@ -7713,12 +7703,12 @@
       }
     },
     "node_modules/react-router-dom": {
-      "version": "7.18.0",
-      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
-      "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
+      "version": "7.18.1",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
+      "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
       "license": "MIT",
       "dependencies": {
-        "react-router": "7.18.0"
+        "react-router": "7.18.1"
       },
       "engines": {
         "node": ">=20.0.0"
@@ -7749,9 +7739,9 @@
       }
     },
     "node_modules/recharts": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz",
-      "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==",
+      "version": "3.9.1",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz",
+      "integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==",
       "license": "MIT",
       "workspaces": [
         "www"
@@ -7762,7 +7752,7 @@
         "decimal.js-light": "^2.5.1",
         "es-toolkit": "^1.39.3",
         "eventemitter3": "^5.0.1",
-        "immer": "^10.1.1",
+        "immer": "^11.1.8",
         "react-redux": "8.x.x || 9.x.x",
         "reselect": "5.2.0",
         "tiny-invariant": "^1.3.3",
@@ -7928,13 +7918,13 @@
       }
     },
     "node_modules/rolldown": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
-      "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+      "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@oxc-project/types": "=0.137.0",
+        "@oxc-project/types": "=0.138.0",
         "@rolldown/pluginutils": "^1.0.0"
       },
       "bin": {
@@ -7944,21 +7934,21 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.1.3",
-        "@rolldown/binding-darwin-arm64": "1.1.3",
-        "@rolldown/binding-darwin-x64": "1.1.3",
-        "@rolldown/binding-freebsd-x64": "1.1.3",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
-        "@rolldown/binding-linux-arm64-gnu": "1.1.3",
-        "@rolldown/binding-linux-arm64-musl": "1.1.3",
-        "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
-        "@rolldown/binding-linux-s390x-gnu": "1.1.3",
-        "@rolldown/binding-linux-x64-gnu": "1.1.3",
-        "@rolldown/binding-linux-x64-musl": "1.1.3",
-        "@rolldown/binding-openharmony-arm64": "1.1.3",
-        "@rolldown/binding-wasm32-wasi": "1.1.3",
-        "@rolldown/binding-win32-arm64-msvc": "1.1.3",
-        "@rolldown/binding-win32-x64-msvc": "1.1.3"
+        "@rolldown/binding-android-arm64": "1.1.4",
+        "@rolldown/binding-darwin-arm64": "1.1.4",
+        "@rolldown/binding-darwin-x64": "1.1.4",
+        "@rolldown/binding-freebsd-x64": "1.1.4",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+        "@rolldown/binding-linux-arm64-musl": "1.1.4",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+        "@rolldown/binding-linux-x64-gnu": "1.1.4",
+        "@rolldown/binding-linux-x64-musl": "1.1.4",
+        "@rolldown/binding-openharmony-arm64": "1.1.4",
+        "@rolldown/binding-wasm32-wasi": "1.1.4",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+        "@rolldown/binding-win32-x64-msvc": "1.1.4"
       }
     },
     "node_modules/safe-array-concat": {
@@ -8831,16 +8821,16 @@
       }
     },
     "node_modules/typescript-eslint": {
-      "version": "8.62.0",
-      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
-      "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
+      "version": "8.62.1",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
+      "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/eslint-plugin": "8.62.0",
-        "@typescript-eslint/parser": "8.62.0",
-        "@typescript-eslint/typescript-estree": "8.62.0",
-        "@typescript-eslint/utils": "8.62.0"
+        "@typescript-eslint/eslint-plugin": "8.62.1",
+        "@typescript-eslint/parser": "8.62.1",
+        "@typescript-eslint/typescript-estree": "8.62.1",
+        "@typescript-eslint/utils": "8.62.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8972,16 +8962,16 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.1.0",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
-      "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
+      "version": "8.1.3",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+      "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
-        "postcss": "^8.5.15",
-        "rolldown": "~1.1.2",
+        "postcss": "^8.5.16",
+        "rolldown": "~1.1.3",
         "tinyglobby": "^0.2.17"
       },
       "bin": {

+ 5 - 5
frontend/package.json

@@ -31,15 +31,15 @@
     "axios": "^1.18.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
-    "i18next": "^26.3.3",
+    "i18next": "^26.3.4",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.3",
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",
-    "react-router-dom": "^7.18.0",
-    "recharts": "^3.9.0",
+    "react-router-dom": "^7.18.1",
+    "recharts": "^3.9.1",
     "swagger-ui-react": "^5.32.8",
     "zod": "^4.4.3"
   },
@@ -58,8 +58,8 @@
     "globals": "^17.7.0",
     "jsdom": "^29.1.1",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.62.0",
-    "vite": "8.1.0",
+    "typescript-eslint": "^8.62.1",
+    "vite": "8.1.3",
     "vitest": "^4.1.9"
   },
   "overrides": {

+ 75 - 0
frontend/public/openapi.json

@@ -2147,6 +2147,34 @@
         ],
         "type": "object"
       },
+      "PanelUpdateStatus": {
+        "description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
+        "properties": {
+          "exitCode": {
+            "example": 0,
+            "type": "integer"
+          },
+          "finishedAt": {
+            "example": 1735689612,
+            "type": "integer"
+          },
+          "runId": {
+            "example": "1735689600123456789",
+            "type": "string"
+          },
+          "state": {
+            "example": "success",
+            "type": "string"
+          }
+        },
+        "required": [
+          "exitCode",
+          "finishedAt",
+          "runId",
+          "state"
+        ],
+        "type": "object"
+      },
       "ProbeResultUI": {
         "properties": {
           "cpuPct": {
@@ -3901,6 +3929,47 @@
         }
       }
     },
+    "/panel/api/server/getUpdateStatus": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.",
+        "operationId": "get_panel_api_server_getUpdateStatus",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/PanelUpdateStatus"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "exitCode": 0,
+                    "finishedAt": 1735689612,
+                    "runId": "1735689600123456789",
+                    "state": "success"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/getConfigJson": {
       "get": {
         "tags": [
@@ -4427,6 +4496,12 @@
                     },
                     "obj": {}
                   }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "runId": "1735689600123456789"
+                  }
                 }
               }
             }

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

@@ -468,6 +468,12 @@ export const EXAMPLES: Record<string, unknown> = {
     "total": 0,
     "up": 0
   },
+  "PanelUpdateStatus": {
+    "exitCode": 0,
+    "finishedAt": 1735689612,
+    "runId": "1735689600123456789",
+    "state": "success"
+  },
   "ProbeResultUI": {
     "cpuPct": 12.5,
     "error": "",

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

@@ -2121,6 +2121,34 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "PanelUpdateStatus": {
+    "description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.",
+    "properties": {
+      "exitCode": {
+        "example": 0,
+        "type": "integer"
+      },
+      "finishedAt": {
+        "example": 1735689612,
+        "type": "integer"
+      },
+      "runId": {
+        "example": "1735689600123456789",
+        "type": "string"
+      },
+      "state": {
+        "example": "success",
+        "type": "string"
+      }
+    },
+    "required": [
+      "exitCode",
+      "finishedAt",
+      "runId",
+      "state"
+    ],
+    "type": "object"
+  },
   "ProbeResultUI": {
     "properties": {
       "cpuPct": {

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

@@ -464,6 +464,13 @@ export interface OutboundTraffics {
   up: number;
 }
 
+export interface PanelUpdateStatus {
+  exitCode: number;
+  finishedAt: number;
+  runId: string;
+  state: string;
+}
+
 export interface ProbeResultUI {
   cpuPct: number;
   error: string;

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

@@ -495,6 +495,14 @@ export const OutboundTrafficsSchema = z.object({
 });
 export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
 
+export const PanelUpdateStatusSchema = z.object({
+  exitCode: z.number().int(),
+  finishedAt: z.number().int(),
+  runId: z.string(),
+  state: z.string(),
+});
+export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
+
 export const ProbeResultUISchema = z.object({
   cpuPct: z.number(),
   error: z.string(),

+ 2 - 2
frontend/src/hooks/useClients.ts

@@ -551,9 +551,9 @@ export function useClients() {
 
   const applyClientStatsEvent = useCallback((payload: unknown) => {
     if (!payload || typeof payload !== 'object') return;
-    const p = payload as { clients?: ClientStatRow[] };
+    const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
     if (!Array.isArray(p.clients) || p.clients.length === 0) return;
-    setAllClientStats(p.clients);
+    if (p.snapshot !== false) setAllClientStats(p.clients);
     const byEmail = new Map<string, ClientTraffic>();
     for (const row of p.clients) {
       if (row && row.email) byEmail.set(row.email, row);

+ 29 - 0
frontend/src/lib/xray/vless-encryption.ts

@@ -0,0 +1,29 @@
+export type VlessAuthKind =
+  | 'x25519'
+  | 'x25519_xorpub'
+  | 'x25519_random'
+  | 'mlkem768'
+  | 'mlkem768_xorpub'
+  | 'mlkem768_random';
+
+export const VLESS_AUTH_LABEL_KEYS: Record<VlessAuthKind, string> = {
+  x25519: 'pages.inbounds.vlessAuthX25519',
+  x25519_xorpub: 'pages.inbounds.vlessAuthX25519Xorpub',
+  x25519_random: 'pages.inbounds.vlessAuthX25519Random',
+  mlkem768: 'pages.inbounds.vlessAuthMlkem768',
+  mlkem768_xorpub: 'pages.inbounds.vlessAuthMlkem768Xorpub',
+  mlkem768_random: 'pages.inbounds.vlessAuthMlkem768Random',
+};
+
+const MLKEM768_MIN_KEY_LENGTH = 300;
+
+export function vlessEncryptionAuthKind(encryption: string): VlessAuthKind | null {
+  if (!encryption || encryption === 'none') return null;
+  const parts = encryption.split('.').filter(Boolean);
+  const authKey = parts[parts.length - 1] || '';
+  if (!authKey) return null;
+  const mode = parts[1] || 'native';
+  const keyType = authKey.length > MLKEM768_MIN_KEY_LENGTH ? 'mlkem768' : 'x25519';
+  if (mode === 'xorpub' || mode === 'random') return `${keyType}_${mode}`;
+  return keyType;
+}

+ 3 - 0
frontend/src/models/setting.ts

@@ -103,6 +103,9 @@ export class AllSetting {
   hasWarpSecret = false;
   hasNordSecret = false;
   hasSmtpPassword = false;
+  clearTgBotToken = false;
+  clearLdapPassword = false;
+  clearSmtpPassword = false;
 
   constructor(data?: unknown) {
     if (data != null) {

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -323,6 +323,12 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/getPanelUpdateInfo',
         summary: 'Check whether a newer 3x-ui release is available on GitHub.',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getUpdateStatus',
+        summary: 'Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.',
+        responseSchema: 'PanelUpdateStatus',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/getConfigJson',
@@ -407,6 +413,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/updatePanel',
         summary: 'Self-update the panel to the latest version. The server restarts on success.',
+        response: '{\n  "success": true,\n  "obj": {\n    "runId": "1735689600123456789"\n  }\n}',
       },
       {
         method: 'POST',

+ 7 - 19
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -41,6 +41,7 @@ import { Protocols } from '@/schemas/primitives';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
 import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
+import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
 import { SniffingSchema } from '@/schemas/primitives/sniffing';
 import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
 import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
@@ -317,27 +318,14 @@ export default function InboundFormModal({
     form.setFieldValue(['settings', 'encryption'], 'none');
   };
 
+  const vlessAuthKind = vlessEncryptionAuthKind(
+    typeof vlessEncryption === 'string' ? vlessEncryption : '',
+  );
   const selectedVlessAuth = (() => {
     const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
     if (!enc || enc === 'none') return 'None';
-    const parts = enc.split('.').filter(Boolean);
-    const authKey = parts[parts.length - 1] || '';
-    if (!authKey) return t('pages.inbounds.vlessAuthCustom');
-    const mode = parts[1] || 'native';
-    const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
-    if (mode === 'xorpub') {
-      return keyType === 'mlkem768'
-        ? t('pages.inbounds.vlessAuthMlkem768Xorpub')
-        : t('pages.inbounds.vlessAuthX25519Xorpub');
-    }
-    if (mode === 'random') {
-      return keyType === 'mlkem768'
-        ? t('pages.inbounds.vlessAuthMlkem768Random')
-        : t('pages.inbounds.vlessAuthX25519Random');
-    }
-    return keyType === 'mlkem768'
-      ? t('pages.inbounds.vlessAuthMlkem768')
-      : t('pages.inbounds.vlessAuthX25519');
+    if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom');
+    return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]);
   })();
 
   useEffect(() => {
@@ -703,7 +691,7 @@ export default function InboundFormModal({
 
       {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
 
-      {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
+      {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
 
       {isFallbackHost && fallbacksCard}
       {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)

+ 12 - 17
frontend/src/pages/inbounds/form/protocols/vless.tsx

@@ -1,18 +1,13 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
 
-type VlessAuthKind =
-  | 'x25519'
-  | 'x25519_xorpub'
-  | 'x25519_random'
-  | 'mlkem768'
-  | 'mlkem768_xorpub'
-  | 'mlkem768_random';
+import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
 
 interface VlessFieldsProps {
   saving: boolean;
   selectedVlessAuth: string;
+  vlessAuthKind: VlessAuthKind | null;
   network: string;
   security: string;
   getNewVlessEnc: (kind: VlessAuthKind) => void;
@@ -22,22 +17,22 @@ interface VlessFieldsProps {
 export default function VlessFields({
   saving,
   selectedVlessAuth,
+  vlessAuthKind,
   network,
   security,
   getNewVlessEnc,
   clearVlessEnc,
 }: VlessFieldsProps) {
   const { t } = useTranslation();
-  const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
+  const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
 
-  const authOptions = [
-    { value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
-    { value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
-    { value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
-    { value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
-    { value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
-    { value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
-  ];
+  useEffect(() => {
+    setAuthKind(vlessAuthKind ?? 'x25519');
+  }, [vlessAuthKind]);
+
+  const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
+    ([value, labelKey]) => ({ value, label: t(labelKey) }),
+  );
 
   return (
     <>

+ 25 - 8
frontend/src/pages/index/PanelUpdateModal.tsx

@@ -6,8 +6,11 @@ import axios from 'axios';
 
 import { HttpUtil, PromiseUtil } from '@/utils';
 import { formatPanelVersion } from '@/lib/panel-version';
+import type { PanelUpdateStatus } from '@/generated/types';
 import './PanelUpdateModal.css';
 
+type UpdateOutcome = 'success' | 'failed' | 'timeout';
+
 export interface PanelUpdateInfo {
   channel?: string;
   currentVersion: string;
@@ -45,19 +48,23 @@ export default function PanelUpdateModal({
 
   const isDev = info.channel === 'dev';
 
-  async function pollUntilBack(): Promise<boolean> {
+  async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
     await PromiseUtil.sleep(5000);
     const deadline = Date.now() + 90_000;
     while (Date.now() < deadline) {
       try {
-        const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
-        if (r?.data?.success) return true;
+        const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 });
+        const status = r?.data?.obj as PanelUpdateStatus | undefined;
+        if (status?.runId === expectedRunId) {
+          if (status.state === 'success') return 'success';
+          if (status.state === 'failed') return 'failed';
+        }
       } catch {
         /* still restarting */
       }
       await PromiseUtil.sleep(2000);
     }
-    return false;
+    return 'timeout';
   }
 
   async function handleChannel(checked: boolean) {
@@ -81,14 +88,24 @@ export default function PanelUpdateModal({
         const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
         onClose();
         onBusy({ busy: true, tip });
-        const result = await HttpUtil.post('/panel/api/server/updatePanel');
+        const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
         if (!result?.success) {
           onBusy({ busy: false });
           return;
         }
-        const back = await pollUntilBack();
-        if (back) await PromiseUtil.sleep(800);
-        window.location.reload();
+        const outcome = await pollUpdateStatus(result.obj?.runId ?? '');
+        onBusy({ busy: false });
+        if (outcome === 'success') {
+          await PromiseUtil.sleep(800);
+          window.location.reload();
+          return;
+        }
+        modal[outcome === 'failed' ? 'error' : 'warning']({
+          title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'),
+          content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'),
+          okText: t('refresh'),
+          onOk: () => window.location.reload(),
+        });
       },
     });
   }

+ 8 - 4
frontend/src/pages/settings/EmailTab.tsx

@@ -8,6 +8,7 @@ import { SettingListItem } from '@/components/ui';
 import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
+import SecretInput from './SecretInput';
 
 interface EmailTabProps {
   allSetting: AllSetting;
@@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
-              description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
-              <Input.Password value={allSetting.smtpPassword}
-                placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
-                onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
+              description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
+              <SecretInput value={allSetting.smtpPassword}
+                configured={allSetting.hasSmtpPassword}
+                clearArmed={allSetting.clearSmtpPassword}
+                placeholder={t('pages.settings.smtpPasswordPlaceholder')}
+                onChange={(v) => updateSetting({ smtpPassword: v })}
+                onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>

+ 8 - 4
frontend/src/pages/settings/GeneralTab.tsx

@@ -21,6 +21,7 @@ import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath } from './uriPath';
+import SecretInput from './SecretInput';
 
 interface ApiMsg<T = unknown> {
   success?: boolean;
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             <SettingListItem
               paddings="small"
               title={t('password')}
-              description={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
+              description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
             >
-              <Input.Password
+              <SecretInput
                 value={allSetting.ldapPassword}
-                placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''}
-                onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
+                configured={allSetting.hasLdapPassword}
+                clearArmed={allSetting.clearLdapPassword}
+                placeholder={t('pages.settings.ldap.passwordPlaceholder')}
+                onChange={(v) => updateSetting({ ldapPassword: v })}
+                onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
               />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>

+ 45 - 0
frontend/src/pages/settings/SecretInput.tsx

@@ -0,0 +1,45 @@
+import { Button, Input, Space } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+interface SecretInputProps {
+  value: string;
+  configured: boolean;
+  clearArmed: boolean;
+  placeholder: string;
+  onChange: (value: string) => void;
+  onClearArmedChange: (armed: boolean) => void;
+}
+
+export default function SecretInput({
+  value,
+  configured,
+  clearArmed,
+  placeholder,
+  onChange,
+  onClearArmedChange,
+}: SecretInputProps) {
+  const { t } = useTranslation();
+  return (
+    <Space.Compact style={{ width: '100%' }}>
+      <Input.Password
+        value={value}
+        placeholder={configured && !clearArmed ? placeholder : ''}
+        onChange={(e) => {
+          onChange(e.target.value);
+          if (clearArmed) onClearArmedChange(false);
+        }}
+      />
+      {configured && (
+        <Button
+          danger={clearArmed}
+          onClick={() => {
+            onChange('');
+            onClearArmedChange(!clearArmed);
+          }}
+        >
+          {clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
+        </Button>
+      )}
+    </Space.Compact>
+  );
+}

+ 1 - 1
frontend/src/pages/settings/SettingsPage.tsx

@@ -204,7 +204,7 @@ export default function SettingsPage() {
       case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
       default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
     }
-  }, [activeSlug, allSetting, updateSetting]);
+  }, [activeSlug, allSetting, updateSetting, savePayload]);
 
   return (
     <ConfigProvider theme={antdThemeConfig}>

+ 8 - 4
frontend/src/pages/settings/TelegramTab.tsx

@@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui';
 import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
+import SecretInput from './SecretInput';
 
 interface TelegramTabProps {
   allSetting: AllSetting;
@@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
             <SettingListItem
               paddings="small"
               title={t('pages.settings.telegramToken')}
-              description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
+              description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
             >
-              <Input.Password
+              <SecretInput
                 value={allSetting.tgBotToken}
-                placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
-                onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
+                configured={allSetting.hasTgBotToken}
+                clearArmed={allSetting.clearTgBotToken}
+                placeholder={t('pages.settings.telegramTokenPlaceholder')}
+                onChange={(v) => updateSetting({ tgBotToken: v })}
+                onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
               />
             </SettingListItem>
 

+ 0 - 1
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -367,7 +367,6 @@ export default function BalancersTab({
               <ObservatorySettingsTab
                 templateSettings={templateSettings}
                 mutate={mutate}
-                isMobile={isMobile}
               />
             ),
           },

+ 13 - 21
frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx

@@ -1,6 +1,6 @@
-import { useMemo, useState } from 'react';
+import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd';
+import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
 
 import { SettingListItem } from '@/components/ui';
 import {
@@ -13,11 +13,11 @@ import {
   type PingConfigObject,
 } from '@/schemas/observatory';
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { settingsRequireBurstObservatory } from './balancer-helpers';
 
 interface ObservatorySettingsTabProps {
   templateSettings: XraySettingsValue | null;
   mutate: (mutator: (next: XraySettingsValue) => void) => void;
-  isMobile: boolean;
 }
 
 const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
@@ -43,7 +43,6 @@ function SelectorTags({ tags }: { tags: string[] }) {
 export default function ObservatorySettingsTab({
   templateSettings,
   mutate,
-  isMobile,
 }: ObservatorySettingsTabProps) {
   const { t } = useTranslation();
 
@@ -63,13 +62,10 @@ export default function ObservatorySettingsTab({
 
   const hasObservatory = observatory != null;
   const hasBurst = burst != null;
-
-  const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
-  const effectiveView = !hasObservatory && hasBurst
+  const hasMixedObservers = hasObservatory && hasBurst;
+  const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
     ? 'burstObservatory'
-    : !hasBurst && hasObservatory
-      ? 'observatory'
-      : view;
+    : 'observatory';
 
   function patchObservatory(patch: Partial<ObservatoryObject>) {
     mutate((tt) => {
@@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({
 
   return (
     <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
-      <Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
-      {hasObservatory && hasBurst && (
-        <Segmented
-          block={isMobile}
-          value={effectiveView}
-          onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
-          options={[
-            { label: t('pages.xray.observatory.title'), value: 'observatory' },
-            { label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
-          ]}
+      <Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
+      {hasMixedObservers && (
+        <Alert
+          type="warning"
+          showIcon
+          title={t('pages.xray.observatory.mixedLegacy')}
         />
       )}
-      <div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
+      <div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
     </Space>
   );
 }

+ 29 - 23
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -26,6 +26,16 @@ export function collectSelectors(list: BalancerObject[]): string[] {
   return [...out];
 }
 
+export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
+  const type = b.strategy?.type || 'random';
+  return type === 'leastLoad' || ((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0);
+}
+
+export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
+  const balancers = (t?.routing?.balancers || []) as BalancerObject[];
+  return balancers.some(balancerRequiresBurstObservatory);
+}
+
 // syncObservatories keeps the (burst)observatory sections aligned with the
 // balancer strategies that actually require them. Observatories have no runtime
 // reload API in xray-core, so creating OR removing one forces a full process
@@ -36,39 +46,35 @@ export function collectSelectors(list: BalancerObject[]): string[] {
 // when its fallbackTag is set (issue #5605): with a fallbackTag the strategy
 // calls RequireFeatures(Observatory) and the core aborts startup with "not all
 // dependencies are resolved" if none exists; without a fallbackTag it never even
-// consults an observatory. leastLoad always needs the burst observer, leastPing
-// the regular one.
+// consults an observatory. leastLoad needs the burst observer, while leastPing
+// can use any extension.Observatory result with Alive/Delay. When a burst
+// observer is required, keep all observer-backed balancers on burstObservatory
+// to avoid xray-core resolving the earlier regular observatory feature instead.
 //
 // So each observer lives exactly as long as something requires it, and is
 // dropped the moment nothing does — clearing the last fallbackTag (or deleting
-// the last leastLoad) removes the burst observer again. A no-fallback balancer's
-// selector is still probed while the observer exists for another reason, but
-// never keeps it alive on its own.
+// the last leastLoad) removes the burst observer again. A no-fallback
+// Random/RoundRobin balancer never expands the observer either, because those
+// strategies do not consume observer data.
 export function syncObservatories(t: XraySettingsValue) {
   const balancers = (t.routing?.balancers || []) as BalancerObject[];
 
   const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
-  if (leastPings.length > 0) {
-    if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
-    (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
-  } else {
-    delete t.observatory;
-  }
-
-  const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0;
-  const required = balancers.filter((b) => {
-    const type = b.strategy?.type || 'random';
-    if (type === 'leastLoad') return true;
-    return (type === 'random' || type === 'roundRobin') && hasFallback(b);
-  });
-  const optional = balancers.filter((b) => {
-    const type = b.strategy?.type || 'random';
-    return (type === 'random' || type === 'roundRobin') && !hasFallback(b);
-  });
+  const required = balancers.filter(balancerRequiresBurstObservatory);
   if (required.length > 0) {
+    delete t.observatory;
     if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
-    (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]);
+    (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([
+      ...required,
+      ...leastPings,
+    ]);
   } else {
     delete t.burstObservatory;
+    if (leastPings.length > 0) {
+      if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
+      (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
+    } else {
+      delete t.observatory;
+    }
   }
 }

+ 260 - 5
frontend/src/test/balancer-observatory-sync.test.ts

@@ -7,12 +7,208 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
   return { routing, ...extra } as unknown as XraySettingsValue;
 }
 
+type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
+
+function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) {
+  if (expected === 'none') {
+    expect(t.observatory).toBeUndefined();
+    expect(t.burstObservatory).toBeUndefined();
+    return;
+  }
+
+  if (expected === 'observatory') {
+    expect(t.observatory).toBeDefined();
+    expect(t.burstObservatory).toBeUndefined();
+    expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
+    return;
+  }
+
+  expect(t.observatory).toBeUndefined();
+  expect(t.burstObservatory).toBeDefined();
+  expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
+}
+
 // Observatory sections have no reload API in xray-core, so creating one turns
 // a balancer save from a live (hot-applied) routing change into a full
 // restart. These tests pin the rule: only strategies that genuinely need an
 // observer may create one — which, for random/roundRobin, means a fallbackTag
 // is set (xray-core then requires the Observatory feature; see #5605).
 describe('syncObservatories', () => {
+  it.each([
+    {
+      name: 'random without fallback',
+      balancers: [{ tag: 'random', selector: ['random-out'] }],
+      expected: 'none' as const,
+      selectors: [],
+    },
+    {
+      name: 'random with fallback',
+      balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
+      expected: 'burstObservatory' as const,
+      selectors: ['random-out'],
+    },
+    {
+      name: 'roundRobin without fallback',
+      balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
+      expected: 'none' as const,
+      selectors: [],
+    },
+    {
+      name: 'roundRobin with fallback',
+      balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }],
+      expected: 'burstObservatory' as const,
+      selectors: ['rr-out'],
+    },
+    {
+      name: 'leastPing without fallback',
+      balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
+      expected: 'observatory' as const,
+      selectors: ['lp-out'],
+    },
+    {
+      name: 'leastPing with fallback',
+      balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }],
+      expected: 'observatory' as const,
+      selectors: ['lp-out'],
+    },
+    {
+      name: 'leastLoad without fallback',
+      balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
+      expected: 'burstObservatory' as const,
+      selectors: ['ll-out'],
+    },
+    {
+      name: 'leastLoad with fallback',
+      balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }],
+      expected: 'burstObservatory' as const,
+      selectors: ['ll-out'],
+    },
+  ])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
+    const t = tpl({ balancers });
+    syncObservatories(t);
+    expectObserver(t, expected, selectors);
+  });
+
+  it.each([
+    {
+      name: 'random + roundRobin without fallbacks',
+      balancers: [
+        { tag: 'random', selector: ['random-out'] },
+        { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
+      ],
+      expected: 'none' as const,
+      selectors: [],
+    },
+    {
+      name: 'leastPing + random without fallback',
+      balancers: [
+        { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+        { tag: 'random', selector: ['random-out'] },
+      ],
+      expected: 'observatory' as const,
+      selectors: ['lp-out'],
+    },
+    {
+      name: 'leastPing + roundRobin without fallback',
+      balancers: [
+        { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+        { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
+      ],
+      expected: 'observatory' as const,
+      selectors: ['lp-out'],
+    },
+    {
+      name: 'random fallback + random without fallback',
+      balancers: [
+        { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
+        { tag: 'random', selector: ['random-out'] },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['random-fallback-out'],
+    },
+    {
+      name: 'roundRobin fallback + roundRobin without fallback',
+      balancers: [
+        { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
+        { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['rr-fallback-out'],
+    },
+    {
+      name: 'leastLoad + random without fallback',
+      balancers: [
+        { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        { tag: 'random', selector: ['random-out'] },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['ll-out'],
+    },
+    {
+      name: 'leastLoad + roundRobin without fallback',
+      balancers: [
+        { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['ll-out'],
+    },
+    {
+      name: 'leastPing + leastLoad with disjoint selectors',
+      balancers: [
+        { tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
+        { tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
+    },
+    {
+      name: 'leastPing + random fallback',
+      balancers: [
+        { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+        { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['lp-out', 'random-fallback-out'],
+    },
+    {
+      name: 'leastPing + roundRobin fallback',
+      balancers: [
+        { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+        { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['lp-out', 'rr-fallback-out'],
+    },
+    {
+      name: 'all strategies mixed',
+      balancers: [
+        { tag: 'random', selector: ['random-out'] },
+        { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
+        { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
+        { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
+        { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+        { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
+    },
+    {
+      name: 'shared selectors are de-duplicated',
+      balancers: [
+        { tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
+        { tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
+        { tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
+      ],
+      expected: 'burstObservatory' as const,
+      selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
+    },
+  ])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
+    const t = tpl({ balancers });
+    syncObservatories(t);
+    expectObserver(t, expected, selectors);
+  });
+
   it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
     syncObservatories(t);
@@ -64,7 +260,7 @@ describe('syncObservatories', () => {
     expect(t.burstObservatory).toBeUndefined();
   });
 
-  it('keeps burstObservatory while another fallback balancer still needs it', () => {
+  it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
     const t = tpl(
       {
         balancers: [
@@ -76,7 +272,7 @@ describe('syncObservatories', () => {
     );
     syncObservatories(t);
     expect(t.burstObservatory).toBeDefined();
-    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
   });
 
   it('creates burstObservatory for leastLoad (required by the strategy)', () => {
@@ -86,20 +282,55 @@ describe('syncObservatories', () => {
     expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
   });
 
-  it('creates observatory for leastPing (required by the strategy)', () => {
+  it('creates observatory for leastPing when no burst observer is required', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
     syncObservatories(t);
     expect(t.observatory).toBeDefined();
     expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
   });
 
-  it('keeps an existing burstObservatory in sync for random balancers (legacy setups)', () => {
+  it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
+    const t = tpl(
+      {
+        balancers: [
+          { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
+          { tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
+        ],
+      },
+      { observatory: { subjectSelector: ['stale-least-ping-out'] } },
+    );
+    syncObservatories(t);
+    expect(t.observatory).toBeUndefined();
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['least-load-out', 'least-ping-out']),
+    );
+  });
+
+  it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
+    const t = tpl(
+      {
+        balancers: [
+          { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
+          { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
+          { tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
+        ],
+      },
+      { observatory: { subjectSelector: ['stale-least-ping-out'] } },
+    );
+    syncObservatories(t);
+    expect(t.observatory).toBeUndefined();
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
+    );
+  });
+
+  it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
     const t = tpl(
       { balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
       { burstObservatory: { subjectSelector: ['stale'] } },
     );
     syncObservatories(t);
-    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
   });
 
   it('removes observatories when no balancer can use them', () => {
@@ -112,6 +343,30 @@ describe('syncObservatories', () => {
     expect(t.burstObservatory).toBeUndefined();
   });
 
+  it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
+    const t = tpl(
+      { balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
+      {
+        observatory: { subjectSelector: ['stale-observatory-out'] },
+        burstObservatory: { subjectSelector: ['stale-burst-out'] },
+      },
+    );
+    syncObservatories(t);
+    expectObserver(t, 'observatory', ['lp-out']);
+  });
+
+  it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
+    const t = tpl(
+      { balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
+      {
+        observatory: { subjectSelector: ['stale-observatory-out'] },
+        burstObservatory: { subjectSelector: ['stale-burst-out'] },
+      },
+    );
+    syncObservatories(t);
+    expectObserver(t, 'burstObservatory', ['ll-out']);
+  });
+
   it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
     const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
     syncObservatories(t);

+ 47 - 0
frontend/src/test/observatory-settings-tab.test.tsx

@@ -0,0 +1,47 @@
+import { describe, expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { renderWithProviders } from './test-utils';
+
+function renderTab(templateSettings: XraySettingsValue) {
+  renderWithProviders(
+    <ObservatorySettingsTab
+      templateSettings={templateSettings}
+      mutate={vi.fn()}
+    />,
+  );
+}
+
+describe('ObservatorySettingsTab', () => {
+  it('shows one burst settings panel and warns for legacy mixed observers', () => {
+    renderTab({
+      routing: {
+        balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }],
+      },
+      observatory: { subjectSelector: ['stale-regular'] },
+      burstObservatory: { subjectSelector: ['proxy-a'] },
+    } as unknown as XraySettingsValue);
+
+    expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
+    expect(document.querySelector('.ant-segmented')).toBeFalsy();
+    expect(screen.getByText('Probe Destination')).toBeTruthy();
+    expect(screen.queryByText('Probe URL')).toBeFalsy();
+  });
+
+  it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => {
+    renderTab({
+      routing: {
+        balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }],
+      },
+      observatory: { subjectSelector: ['proxy-b'] },
+      burstObservatory: { subjectSelector: ['stale-burst'] },
+    } as unknown as XraySettingsValue);
+
+    expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy();
+    expect(document.querySelector('.ant-segmented')).toBeFalsy();
+    expect(screen.getByText('Probe URL')).toBeTruthy();
+    expect(screen.queryByText('Probe Destination')).toBeFalsy();
+  });
+});

+ 121 - 0
frontend/src/test/routing-reference-cleanup.test.ts

@@ -83,6 +83,65 @@ describe('outbound deletion', () => {
     expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
   });
 
+  it('cascade-removes the burst observer when deleting an outbound removes the last leastLoad balancer', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'll-out' }],
+      routing: {
+        rules: [],
+        balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
+      },
+      burstObservatory: { subjectSelector: ['ll-out'] },
+    });
+    const impact = planOutboundDeletion(tt, 0);
+    expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
+    expect(impact.burst).toBe(true);
+    applyOutboundDeletion(tt, 0);
+    expect(tt.burstObservatory).toBeUndefined();
+    expect(tt.routing!.balancers).toEqual([]);
+  });
+
+  it('cascade-switches from burst to regular observer when only leastPing remains', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
+      routing: {
+        rules: [],
+        balancers: [
+          { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+          { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        ],
+      },
+      burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
+    });
+    const impact = planOutboundDeletion(tt, 1);
+    expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
+    expect(impact.burst).toBe(true);
+    applyOutboundDeletion(tt, 1);
+    expect(tt.burstObservatory).toBeUndefined();
+    expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
+    expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
+  });
+
+  it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
+      routing: {
+        rules: [],
+        balancers: [
+          { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+          { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        ],
+      },
+      burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
+    });
+    const impact = planOutboundDeletion(tt, 0);
+    expect(impact.balancers).toEqual([{ tag: 'lp', reason: 'selectorEmptied' }]);
+    expect(impact.burst).toBe(false);
+    applyOutboundDeletion(tt, 0);
+    expect(tt.observatory).toBeUndefined();
+    expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
+    expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
+  });
+
   it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
     const tt = tpl({
       outbounds: [
@@ -253,6 +312,68 @@ describe('balancer deletion', () => {
     expect(tt.routing!.balancers).toEqual([]);
   });
 
+  it('reports and removes the burst observer when deleting the last leastLoad balancer', () => {
+    const tt = tpl({
+      routing: { rules: [], balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }] },
+      burstObservatory: { subjectSelector: ['a'] },
+    });
+    expect(planBalancerDeletion(tt, 0).burst).toBe(true);
+    applyBalancerDeletion(tt, 0);
+    expect(tt.burstObservatory).toBeUndefined();
+    expect(tt.routing!.balancers).toEqual([]);
+  });
+
+  it('reports and removes the burst observer when deleting the last fallback balancer', () => {
+    const tt = tpl({
+      routing: { rules: [], balancers: [{ tag: 'rf', selector: ['a'], fallbackTag: 'direct' }] },
+      burstObservatory: { subjectSelector: ['a'] },
+    });
+    expect(planBalancerDeletion(tt, 0).burst).toBe(true);
+    applyBalancerDeletion(tt, 0);
+    expect(tt.burstObservatory).toBeUndefined();
+    expect(tt.routing!.balancers).toEqual([]);
+  });
+
+  it('switches from burst to regular observer when the deleted balancer was the last burst-required one', () => {
+    const tt = tpl({
+      routing: {
+        rules: [],
+        balancers: [
+          { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+          { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        ],
+      },
+      burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
+    });
+    const impact = planBalancerDeletion(tt, 1);
+    expect(impact.burst).toBe(true);
+    expect(impact.observatory).toBe(false);
+    applyBalancerDeletion(tt, 1);
+    expect(tt.burstObservatory).toBeUndefined();
+    expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
+    expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]);
+  });
+
+  it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => {
+    const tt = tpl({
+      routing: {
+        rules: [],
+        balancers: [
+          { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
+          { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
+        ],
+      },
+      burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
+    });
+    const impact = planBalancerDeletion(tt, 0);
+    expect(impact.burst).toBe(false);
+    expect(impact.observatory).toBe(false);
+    applyBalancerDeletion(tt, 0);
+    expect(tt.observatory).toBeUndefined();
+    expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']);
+    expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]);
+  });
+
   it('does not report rules when the deleted balancer is unreferenced', () => {
     const tt = tpl({
       routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },

+ 27 - 0
frontend/src/test/vless-encryption.test.ts

@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+
+import { vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
+
+const x25519Key = 'kO9pIKKPtoUCzo3ZWfWfp0lQoWCyJC1TqL8oz1hpsFM';
+const mlkem768Key = 'A'.repeat(1590);
+
+describe('vlessEncryptionAuthKind', () => {
+  const cases: { name: string; encryption: string; want: ReturnType<typeof vlessEncryptionAuthKind> }[] = [
+    { name: 'empty string', encryption: '', want: null },
+    { name: 'none', encryption: 'none', want: null },
+    { name: 'only dots', encryption: '...', want: null },
+    { name: 'x25519 native', encryption: `mlkem768x25519plus.native.600s.${x25519Key}`, want: 'x25519' },
+    { name: 'x25519 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${x25519Key}`, want: 'x25519_xorpub' },
+    { name: 'x25519 random', encryption: `mlkem768x25519plus.random.600s.${x25519Key}`, want: 'x25519_random' },
+    { name: 'mlkem768 native', encryption: `mlkem768x25519plus.native.600s.${mlkem768Key}`, want: 'mlkem768' },
+    { name: 'mlkem768 xorpub', encryption: `mlkem768x25519plus.xorpub.600s.${mlkem768Key}`, want: 'mlkem768_xorpub' },
+    { name: 'mlkem768 random', encryption: `mlkem768x25519plus.random.600s.${mlkem768Key}`, want: 'mlkem768_random' },
+    { name: 'two-segment value treated as native', encryption: `mlkem768x25519plus.${x25519Key}`, want: 'x25519' },
+  ];
+
+  for (const c of cases) {
+    it(c.name, () => {
+      expect(vlessEncryptionAuthKind(c.encryption)).toBe(c.want);
+    });
+  }
+});

+ 3 - 3
go.mod

@@ -17,7 +17,7 @@ require (
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
-	github.com/shirou/gopsutil/v4 v4.26.5
+	github.com/shirou/gopsutil/v4 v4.26.6
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.72.0
 	github.com/xlzd/gotp v0.1.0
@@ -26,7 +26,7 @@ require (
 	golang.org/x/crypto v0.53.0
 	golang.org/x/sys v0.46.0
 	golang.org/x/text v0.38.0
-	google.golang.org/grpc v1.81.1
+	google.golang.org/grpc v1.82.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
@@ -63,7 +63,7 @@ require (
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
-	github.com/klauspost/compress v1.18.6
+	github.com/klauspost/compress v1.19.0
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect

+ 6 - 6
go.sum

@@ -115,8 +115,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
 github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
-github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
-github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
+github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -178,8 +178,8 @@ github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
 github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
-github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
-github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
+github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -281,8 +281,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-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/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
+google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 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=

+ 93 - 15
install.sh

@@ -1326,6 +1326,40 @@ setup_fail2ban() {
     return 0
 }
 
+# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
+# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
+# truncated unit file at the live path -- systemd would then fail to parse
+# it on the next daemon-reload/start. Same pattern already used for
+# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a
+# file already extracted from the release tarball) vs curl (GitHub fallback).
+_install_xui_service_unit() {
+    local source="$1"
+    local source_is_url="$2"
+    local dest="${xui_service}/x-ui.service"
+    local temp_file="${dest}.tmp.$$"
+
+    rm -f "$temp_file"
+    if [[ "$source_is_url" == "true" ]]; then
+        curl -fLRo "$temp_file" "$source" > /dev/null 2>&1
+    else
+        cp -f "$source" "$temp_file" > /dev/null 2>&1
+    fi
+    if [[ $? -ne 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    if [[ ! -s "$temp_file" ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    mv -f "$temp_file" "$dest"
+    if [[ $? -ne 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    return 0
+}
+
 install_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -1342,6 +1376,11 @@ install_x-ui() {
             echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
             exit 1
         fi
+        if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
+            rm ${xui_folder}-linux-$(arch).tar.gz -f
+            echo -e "${red}Downloaded x-ui release archive is empty${plain}"
+            exit 1
+        fi
     else
         tag_version=$1
         # The rolling dev channel ships under a fixed, non-semver tag that is
@@ -1367,12 +1406,25 @@ install_x-ui() {
             echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
             exit 1
         fi
+        if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
+            rm ${xui_folder}-linux-$(arch).tar.gz -f
+            echo -e "${red}Downloaded x-ui release archive is empty${plain}"
+            exit 1
+        fi
     fi
-    curl -fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
+    local xui_script_temp="/usr/bin/x-ui-temp.$$"
+    rm -f "${xui_script_temp}"
+    curl -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
     if [[ $? -ne 0 ]]; then
+        rm -f "${xui_script_temp}"
         echo -e "${red}Failed to download x-ui.sh${plain}"
         exit 1
     fi
+    if [[ ! -s "${xui_script_temp}" ]]; then
+        rm -f "${xui_script_temp}"
+        echo -e "${red}Downloaded x-ui.sh is empty${plain}"
+        exit 1
+    fi
 
     # Stop x-ui service and remove old resources
     if [[ -e ${xui_folder}/ ]]; then
@@ -1391,9 +1443,20 @@ install_x-ui() {
 
     # Extract resources and set permissions
     tar zxvf x-ui-linux-$(arch).tar.gz
+    if [[ $? -ne 0 ]]; then
+        rm x-ui-linux-$(arch).tar.gz -f
+        rm -f "${xui_script_temp}"
+        echo -e "${red}Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
+        exit 1
+    fi
     rm x-ui-linux-$(arch).tar.gz -f
 
     cd x-ui
+    if [[ $? -ne 0 || ! -s x-ui ]]; then
+        rm -f "${xui_script_temp}"
+        echo -e "${red}Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}"
+        exit 1
+    fi
     chmod +x x-ui
     chmod +x x-ui.sh
 
@@ -1414,7 +1477,12 @@ install_x-ui() {
     fi
 
     # Update x-ui cli and se set permission
-    mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
+    mv -f "${xui_script_temp}" /usr/bin/x-ui
+    if [[ $? -ne 0 ]]; then
+        rm -f "${xui_script_temp}"
+        echo -e "${red}Failed to install x-ui.sh${plain}"
+        exit 1
+    fi
     chmod +x /usr/bin/x-ui
     mkdir -p /var/log/x-ui
     config_after_install
@@ -1434,11 +1502,25 @@ install_x-ui() {
     fi
 
     if [[ $release == "alpine" ]]; then
-        curl -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
+        xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
+        rm -f "${xui_rc_temp}"
+        curl -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
         if [[ $? -ne 0 ]]; then
+            rm -f "${xui_rc_temp}"
             echo -e "${red}Failed to download x-ui.rc${plain}"
             exit 1
         fi
+        if [[ ! -s "${xui_rc_temp}" ]]; then
+            rm -f "${xui_rc_temp}"
+            echo -e "${red}Downloaded x-ui.rc is empty${plain}"
+            exit 1
+        fi
+        mv -f "${xui_rc_temp}" /etc/init.d/x-ui
+        if [[ $? -ne 0 ]]; then
+            rm -f "${xui_rc_temp}"
+            echo -e "${red}Failed to install x-ui.rc${plain}"
+            exit 1
+        fi
         chmod +x /etc/init.d/x-ui
         rc-update add x-ui
         rc-service x-ui start
@@ -1448,8 +1530,7 @@ install_x-ui() {
 
         if [ -f "x-ui.service" ]; then
             echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
-            cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
-            if [[ $? -eq 0 ]]; then
+            if _install_xui_service_unit "x-ui.service" "false"; then
                 service_installed=true
             fi
         fi
@@ -1459,8 +1540,7 @@ install_x-ui() {
                 ubuntu | debian | armbian)
                     if [ -f "x-ui.service.debian" ]; then
                         echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
-                        cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.debian" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1468,8 +1548,7 @@ install_x-ui() {
                 arch | manjaro | parch)
                     if [ -f "x-ui.service.arch" ]; then
                         echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
-                        cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.arch" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1477,8 +1556,7 @@ install_x-ui() {
                 *)
                     if [ -f "x-ui.service.rhel" ]; then
                         echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
-                        cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.rhel" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1491,17 +1569,17 @@ install_x-ui() {
             echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
             case "${release}" in
                 ubuntu | debian | armbian)
-                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
                     ;;
                 arch | manjaro | parch)
-                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
                     ;;
                 *)
-                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
                     ;;
             esac
 
-            if [[ $? -ne 0 ]]; then
+            if ! _install_xui_service_unit "$service_unit_url" "true"; then
                 echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
                 exit 1
             fi

+ 8 - 0
internal/config/config.go

@@ -169,6 +169,14 @@ func GetDBPath() string {
 	return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
 }
 
+// GetUpdateStatusFilePath returns the path to the panel self-update status
+// file update.sh writes on completion. It lives beside the database, outside
+// XUI_MAIN_FOLDER, so it survives an update regardless of what happens to
+// that folder.
+func GetUpdateStatusFilePath() string {
+	return filepath.Join(GetDBFolderPath(), "update-status.json")
+}
+
 // GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
 func GetDBKind() string {
 	v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))

+ 35 - 1
internal/database/db.go

@@ -664,7 +664,10 @@ func runSeeders(isUsersEmpty bool) error {
 	if err := seedWireguardPeersToClients(); err != nil {
 		return err
 	}
-	return nil
+
+	// Idempotent, not seeder-gated: bad values can re-enter via a restored
+	// backup, so re-check on every start.
+	return normalizeSettingPaths()
 }
 
 // resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
@@ -769,6 +772,37 @@ func clearLegacyProxySettings() error {
 	})
 }
 
+// normalizeSettingPaths repairs URI-path settings persisted before the
+// leading/trailing-slash rules existed (or restored from an old backup),
+// mirroring entity.AllSetting.CheckValid. CheckValid self-heals these on save,
+// but the frontend rejects the whole Settings form on the bad stored value
+// before a save can ever reach it (#5726), so the stored rows themselves must
+// be fixed. Idempotent; runs on every start.
+func normalizeSettingPaths() error {
+	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
+	var rows []model.Setting
+	if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
+		return err
+	}
+	for _, row := range rows {
+		fixed := row.Value
+		if !strings.HasPrefix(fixed, "/") {
+			fixed = "/" + fixed
+		}
+		if !strings.HasSuffix(fixed, "/") {
+			fixed += "/"
+		}
+		if fixed == row.Value {
+			continue
+		}
+		if err := db.Model(&model.Setting{}).Where("id = ?", row.Id).
+			Update("value", fixed).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func normalizeInboundClientTgId() error {
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {

+ 41 - 0
internal/database/db_seed_test.go

@@ -153,3 +153,44 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
 		t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
 	}
 }
+
+func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	seed := []model.Setting{
+		{Key: "subJsonPath", Value: "YIrCXJOOOL"},
+		{Key: "subPath", Value: "/sub"},
+		{Key: "subClashPath", Value: "clash/"},
+		{Key: "webBasePath", Value: "/panel/"},
+	}
+	for i := range seed {
+		if err := db.Create(&seed[i]).Error; err != nil {
+			t.Fatalf("seed setting %s: %v", seed[i].Key, err)
+		}
+	}
+
+	if err := normalizeSettingPaths(); err != nil {
+		t.Fatalf("normalizeSettingPaths: %v", err)
+	}
+
+	want := map[string]string{
+		"subJsonPath":  "/YIrCXJOOOL/",
+		"subPath":      "/sub/",
+		"subClashPath": "/clash/",
+		"webBasePath":  "/panel/",
+	}
+	for key, expected := range want {
+		var row model.Setting
+		if err := db.Where("key = ?", key).First(&row).Error; err != nil {
+			t.Fatalf("read %s: %v", key, err)
+		}
+		if row.Value != expected {
+			t.Errorf("%s = %q, want %q", key, row.Value, expected)
+		}
+	}
+}

+ 3 - 6
internal/sub/clash_service.go

@@ -240,8 +240,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 	case model.VLESS:
 		proxy["type"] = "vless"
 		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
-		var inboundSettings map[string]any
-		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		inboundSettings := subReq.linkSettings(inbound)
 		streamSecurity, _ := stream["security"].(string)
 		if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
 			proxy["flow"] = client.Flow
@@ -258,8 +257,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 	case model.Shadowsocks:
 		proxy["type"] = "ss"
 		proxy["password"] = client.Password
-		var inboundSettings map[string]any
-		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		inboundSettings := subReq.linkSettings(inbound)
 		method, _ := inboundSettings["method"].(string)
 		if method == "" {
 			return nil
@@ -288,8 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 // helpers prune fields (like `allowInsecure` / the salamander obfs
 // block) that the hysteria proxy wants preserved.
 func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
-	var inboundSettings map[string]any
-	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+	inboundSettings := subReq.linkSettings(inbound)
 
 	proxyType := "hysteria2"
 	authKey := "password"

+ 8 - 10
internal/sub/json_service.go

@@ -212,11 +212,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		case "vless":
 			vc := client
 			vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
-			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, vc, jsonMux(mux, hostMux)))
+			newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
 		case "trojan", "shadowsocks":
-			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux)))
+			newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "hysteria":
-			newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
+			newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux)))
 		}
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
@@ -393,7 +393,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
+func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
@@ -403,8 +403,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	outbound.StreamSettings = streamSettings
 
 	// Add encryption for VLESS outbound from inbound settings
-	var inboundSettings map[string]any
-	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+	inboundSettings := subReq.linkSettings(inbound)
 	encryption, _ := inboundSettings["encryption"].(string)
 
 	settings := map[string]any{
@@ -422,7 +421,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
+func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	serverData := make([]ServerSetting, 1)
@@ -434,8 +433,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 	}
 
 	if inbound.Protocol == model.Shadowsocks {
-		var inboundSettings map[string]any
-		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+		inboundSettings := subReq.linkSettings(inbound)
 		method, _ := inboundSettings["method"].(string)
 		serverData[0].Method = method
 
@@ -475,7 +473,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 	return result
 }
 
-func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
+func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	outbound.Protocol = string(inbound.Protocol)

+ 5 - 5
internal/sub/json_service_test.go

@@ -122,7 +122,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
 	if _, ok := settings["vnext"]; ok {
 		t.Fatal("vless outbound must not use vnext")
 	}
@@ -151,7 +151,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
 	client := model.Client{Password: "p4ss"}
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
 	server := firstServer(settings)
 	if server == nil {
 		t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
@@ -164,7 +164,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 	}
 
 	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
-	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
 	ssServer := firstServer(ssSettings)
 	if ssServer == nil {
 		t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
@@ -194,7 +194,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "uuid-1"}
 
-	raw := svc.genVless(inbound, streamSettings, client, mux)
+	raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
 	var ob map[string]any
 	if err := json.Unmarshal(raw, &ob); err != nil {
 		t.Fatalf("unmarshal outbound: %v", err)
@@ -240,7 +240,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "uuid-1"}
 
-	raw := svc.genVless(inbound, streamSettings, client, mux)
+	raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
 	var ob map[string]any
 	if err := json.Unmarshal(raw, &ob); err != nil {
 		t.Fatalf("unmarshal outbound: %v", err)

+ 4 - 4
internal/sub/mutation_audit_test.go

@@ -67,11 +67,11 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 		protocol model.Protocol
 	}{
 		{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
-		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
-		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
+		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
+		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
 		{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
-		{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
-		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
+		{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
+		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {

+ 2 - 5
internal/sub/remark_vars.go

@@ -459,11 +459,8 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
 // needed when a global remark template references client-only tokens. Falls back
 // to an email-only client if not found.
 func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
-	clients, _ := s.inboundService.GetClients(inbound)
-	for _, c := range clients {
-		if c.Email == email {
-			return c
-		}
+	if c, ok := s.clientForLink(inbound, email); ok {
+		return c
 	}
 	return model.Client{Email: email}
 }

+ 171 - 65
internal/sub/service.go

@@ -54,6 +54,17 @@ type SubService struct {
 	// doesn't own its row (multi-inbound subscriptions). Filled in
 	// getInboundsBySubId; reset per request in PrepareForRequest.
 	statsByEmail map[string]xray.ClientTraffic
+	// clientsByInbound caches clients resolved for this request keyed by
+	// inbound id then email, so the per-protocol link generators look a client
+	// up without re-parsing the inbound's settings JSON per link.
+	// fullyPrimedInbounds marks inbounds whose complete client list is cached
+	// (a miss there is authoritative). Reset per request in PrepareForRequest.
+	clientsByInbound    map[int]map[string]model.Client
+	fullyPrimedInbounds map[int]bool
+	// settingsByInbound caches each inbound's settings decoded once per request
+	// with the clients array left out; generators read only inbound-level
+	// fields (encryption, method, version, …) from it.
+	settingsByInbound map[int]map[string]any
 }
 
 // NewSubService creates a new subscription service with the given configuration.
@@ -86,10 +97,96 @@ func (s *SubService) PrepareForRequest(host string) {
 	s.address = host
 	s.usageShown = map[string]bool{}
 	s.statsByEmail = map[string]xray.ClientTraffic{}
+	s.clientsByInbound = map[int]map[string]model.Client{}
+	s.fullyPrimedInbounds = map[int]bool{}
+	s.settingsByInbound = map[int]map[string]any{}
 	s.loadNodes()
 	s.loadRemarkSettings()
 }
 
+// primeLinkClients caches clients (first occurrence per email, matching the
+// old settings-JSON iteration order) so clientForLink resolves them without a
+// parse. complete marks the inbound's whole client list as cached.
+func (s *SubService) primeLinkClients(inboundId int, clients []model.Client, complete bool) {
+	if inboundId <= 0 {
+		return
+	}
+	if s.clientsByInbound == nil {
+		s.clientsByInbound = map[int]map[string]model.Client{}
+	}
+	m := s.clientsByInbound[inboundId]
+	if m == nil {
+		m = make(map[string]model.Client, len(clients))
+		s.clientsByInbound[inboundId] = m
+	}
+	for _, c := range clients {
+		if _, exists := m[c.Email]; !exists {
+			m[c.Email] = c
+		}
+	}
+	if complete {
+		if s.fullyPrimedInbounds == nil {
+			s.fullyPrimedInbounds = map[int]bool{}
+		}
+		s.fullyPrimedInbounds[inboundId] = true
+	}
+}
+
+// clientForLink resolves one client of an inbound by email for link
+// generation: from the per-request cache when primed, otherwise by parsing
+// the settings JSON once and caching every client from it.
+func (s *SubService) clientForLink(inbound *model.Inbound, email string) (model.Client, bool) {
+	if m, ok := s.clientsByInbound[inbound.Id]; ok {
+		if c, hit := m[email]; hit {
+			return c, true
+		}
+		if s.fullyPrimedInbounds[inbound.Id] {
+			return model.Client{}, false
+		}
+	}
+	clients, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		return model.Client{}, false
+	}
+	s.primeLinkClients(inbound.Id, clients, true)
+	for i := range clients {
+		if clients[i].Email == email {
+			return clients[i], true
+		}
+	}
+	return model.Client{}, false
+}
+
+// linkSettings returns the inbound's settings decoded once per request with
+// the clients array left out — the link generators read only inbound-level
+// fields from it and resolve clients via clientForLink. The shallow
+// RawMessage pass skips materializing a huge clients array entirely.
+func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
+	if inbound.Id > 0 {
+		if cached, ok := s.settingsByInbound[inbound.Id]; ok {
+			return cached
+		}
+	}
+	shallow := map[string]json.RawMessage{}
+	_ = json.Unmarshal([]byte(inbound.Settings), &shallow)
+	out := make(map[string]any, len(shallow))
+	for key, raw := range shallow {
+		if key == "clients" {
+			continue
+		}
+		var value any
+		_ = json.Unmarshal(raw, &value)
+		out[key] = value
+	}
+	if inbound.Id > 0 {
+		if s.settingsByInbound == nil {
+			s.settingsByInbound = map[int]map[string]any{}
+		}
+		s.settingsByInbound[inbound.Id] = out
+	}
+	return out
+}
+
 // loadRemarkSettings populates the per-request remark formatting state so
 // every subscription format — raw, JSON, Clash — renders remarks the same way
 // (the date formatter reads datepicker). Loading it only in getSubs left
@@ -144,25 +241,23 @@ func listenIsInternalOnly(listen string) bool {
 }
 
 // matchingClients returns the inbound's clients whose SubID equals subId,
-// deduplicated by email. settings.clients can accumulate duplicate entries
-// for the same client (multi-node sync/import drift, old DBs): SyncInbound
-// dedupes the normalized client_inbounds rows on write but never rewrites
-// the legacy JSON, and the subscription builders iterate that JSON — so
-// without this guard every duplicate became a duplicate profile in the
-// output (#5134). Link generation keys purely on (inbound, email), so
-// same-email entries are pure duplicates and dropping them is lossless.
+// resolved from the normalized clients/client_inbounds tables (both filter
+// columns indexed) instead of parsing the settings JSON — at large client
+// counts that parse made every subscription fetch cost seconds. The
+// case-insensitive email dedupe stays as cheap insurance even though
+// clients.email is unique, preserving the #5134 guarantee that duplicate
+// settings entries never fan out into duplicate profiles. Resolved clients
+// are primed into the per-request cache so the link generators don't parse
+// settings either.
 func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
-	clients, err := s.inboundService.GetClients(inbound)
+	clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
 	if err != nil {
-		logger.Error("SubService - GetClients: Unable to get clients from inbound")
+		logger.Error("SubService - GetClientsBySubId: Unable to get clients from inbound")
 		return nil
 	}
 	var out []model.Client
 	seen := make(map[string]struct{}, len(clients))
 	for _, client := range clients {
-		if client.SubID != subId {
-			continue
-		}
 		key := strings.ToLower(client.Email)
 		if _, dup := seen[key]; dup {
 			continue
@@ -170,6 +265,7 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 		seen[key] = struct{}{}
 		out = append(out, client)
 	}
+	s.primeLinkClients(inbound.Id, out, false)
 	return out
 }
 
@@ -253,6 +349,7 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
 	if err != nil {
 		return nil
 	}
+	s.primeLinkClients(inbound.Id, clients, true)
 	s.projectThroughFallbackMaster(inbound)
 	hostEps := s.hostEndpoints(inbound, "raw")
 	var out []string
@@ -362,7 +459,7 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
-	err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
+	err := db.Model(model.Inbound{}).Where(`id in (
 		SELECT DISTINCT inbounds.id
 		FROM inbounds
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
@@ -374,19 +471,34 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 	if err != nil {
 		return nil, err
 	}
-	s.indexStatsByEmail(inbounds)
+	s.indexStatsBySubId(subId)
 	return inbounds, nil
 }
 
-// indexStatsByEmail records every loaded inbound's client traffic rows keyed by
-// email so statsForClient can resolve a client's usage even on an inbound that
-// doesn't own its (globally unique) client_traffics row. See statsByEmail.
-func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) {
+// indexStatsBySubId loads the traffic rows for just this subscriber's clients
+// into statsByEmail so statsForClient can resolve a client's usage on any of
+// its inbounds. It replaces preloading every matched inbound's ClientStats,
+// which read the entire client_traffics table on every subscription fetch of
+// a large inbound; statsForClient's per-email DB fallback covers any miss.
+func (s *SubService) indexStatsBySubId(subId string) {
 	if s.statsByEmail == nil {
 		s.statsByEmail = map[string]xray.ClientTraffic{}
 	}
-	for _, inbound := range inbounds {
-		for _, st := range inbound.ClientStats {
+	db := database.GetDB()
+	var emails []string
+	if err := db.Model(&model.ClientRecord{}).Where("sub_id = ?", subId).Pluck("email", &emails).Error; err != nil {
+		logger.Error("SubService - indexStatsBySubId: load emails:", err)
+		return
+	}
+	const chunk = 400
+	for lo := 0; lo < len(emails); lo += chunk {
+		hi := min(lo+chunk, len(emails))
+		var rows []xray.ClientTraffic
+		if err := db.Where("email IN ?", emails[lo:hi]).Find(&rows).Error; err != nil {
+			logger.Error("SubService - indexStatsBySubId: load traffics:", err)
+			return
+		}
+		for _, st := range rows {
 			s.statsByEmail[st.Email] = st
 		}
 	}
@@ -516,21 +628,14 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 	if inbound.Protocol != model.WireGuard {
 		return ""
 	}
-	settings := map[string]any{}
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
+	settings := s.linkSettings(inbound)
 	secretKey, _ := settings["secretKey"].(string)
 
-	clients, _ := s.inboundService.GetClients(inbound)
-	var client *model.Client
-	for i := range clients {
-		if clients[i].Email == email {
-			client = &clients[i]
-			break
-		}
-	}
-	if client == nil || client.PrivateKey == "" {
+	resolved, ok := s.clientForLink(inbound, email)
+	if !ok || resolved.PrivateKey == "" {
 		return ""
 	}
+	client := &resolved
 
 	link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
 	params := make(map[string]string)
@@ -607,10 +712,12 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 		applyVmessTLSParams(stream, obj)
 	}
 
-	clients, _ := s.inboundService.GetClients(inbound)
-	clientIndex := findClientIndex(clients, email)
-	obj["id"] = clients[clientIndex].ID
-	obj["scy"] = clients[clientIndex].Security
+	client, ok := s.clientForLink(inbound, email)
+	if !ok {
+		return ""
+	}
+	obj["id"] = client.ID
+	obj["scy"] = client.Security
 
 	externalProxies, _ := stream["externalProxy"].([]any)
 
@@ -659,17 +766,18 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	}
 	address := s.resolveInboundAddress(inbound)
 	stream := unmarshalStreamSettings(inbound.StreamSettings)
-	clients, _ := s.inboundService.GetClients(inbound)
-	clientIndex := findClientIndex(clients, email)
-	uuid := clients[clientIndex].ID
+	client, ok := s.clientForLink(inbound, email)
+	if !ok {
+		return ""
+	}
+	uuid := client.ID
 	port := inbound.Port
 	streamNetwork := stream["network"].(string)
 	params := make(map[string]string)
 	params["type"] = streamNetwork
 
 	// Add encryption parameter for VLESS from inbound settings
-	var settings map[string]any
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
+	settings := s.linkSettings(inbound)
 	if encryption, ok := settings["encryption"].(string); ok {
 		params["encryption"] = encryption
 	}
@@ -683,12 +791,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
+		applyShareRealityParams(stream, params, subKey(client))
 	default:
 		params["security"] = "none"
 	}
-	if len(clients[clientIndex].Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
-		params["flow"] = clients[clientIndex].Flow
+	if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
+		params["flow"] = client.Flow
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)
@@ -717,9 +825,11 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 	}
 	address := s.resolveInboundAddress(inbound)
 	stream := unmarshalStreamSettings(inbound.StreamSettings)
-	clients, _ := s.inboundService.GetClients(inbound)
-	clientIndex := findClientIndex(clients, email)
-	password := encodeUserinfo(clients[clientIndex].Password)
+	client, ok := s.clientForLink(inbound, email)
+	if !ok {
+		return ""
+	}
+	password := encodeUserinfo(client.Password)
 	port := inbound.Port
 	streamNetwork := stream["network"].(string)
 	params := make(map[string]string)
@@ -734,9 +844,9 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
-		if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
-			params["flow"] = clients[clientIndex].Flow
+		applyShareRealityParams(stream, params, subKey(client))
+		if streamNetwork == "tcp" && len(client.Flow) > 0 {
+			params["flow"] = client.Flow
 		}
 	default:
 		params["security"] = "none"
@@ -788,13 +898,14 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 	}
 	address := s.resolveInboundAddress(inbound)
 	stream := unmarshalStreamSettings(inbound.StreamSettings)
-	clients, _ := s.inboundService.GetClients(inbound)
+	client, ok := s.clientForLink(inbound, email)
+	if !ok {
+		return ""
+	}
 
-	var settings map[string]any
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
+	settings := s.linkSettings(inbound)
 	inboundPassword := settings["password"].(string)
 	method := settings["method"].(string)
-	clientIndex := findClientIndex(clients, email)
 	streamNetwork := stream["network"].(string)
 	params := make(map[string]string)
 	params["type"] = streamNetwork
@@ -828,9 +939,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		userInfo = fmt.Sprintf("%s:%s:%s",
 			url.QueryEscape(method),
 			url.QueryEscape(inboundPassword),
-			url.QueryEscape(clients[clientIndex].Password))
+			url.QueryEscape(client.Password))
 	} else {
-		userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, clients[clientIndex].Password))
+		userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, client.Password))
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)
@@ -861,15 +972,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	}
 	var stream map[string]any
 	_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
-	clients, _ := s.inboundService.GetClients(inbound)
-	clientIndex := -1
-	for i, client := range clients {
-		if client.Email == email {
-			clientIndex = i
-			break
-		}
+	client, ok := s.clientForLink(inbound, email)
+	if !ok {
+		return ""
 	}
-	auth := encodeUserinfo(clients[clientIndex].Auth)
+	auth := encodeUserinfo(client.Auth)
 	params := make(map[string]string)
 
 	params["security"] = "tls"
@@ -928,8 +1035,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 	}
 
-	var settings map[string]any
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
+	settings := s.linkSettings(inbound)
 	version, _ := settings["version"].(float64)
 	protocol := "hysteria2"
 	if int(version) == 1 {

+ 26 - 10
internal/sub/service_dedup_test.go

@@ -69,19 +69,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
 	}
 }
 
-// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count:
-// the two entries differ only by email case, so dropping strings.ToLower (or keying on
-// another field) yields two clients. The byte-identical dupes above can't catch that.
+// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count.
+// clients.email is unique but case-sensitively so: two rows differing only by email case
+// can coexist (import drift), and dropping strings.ToLower (or keying on another field)
+// would emit both. The first row by id must win, matching the old settings-JSON order.
 func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
 	const subId = "s1"
 	const uuid = "11111111-2222-4333-8444-555555555555"
-	ib := &model.Inbound{
-		Protocol: model.VLESS,
-		Settings: `{"clients":[
-			{"id":"` + uuid + `","email":"[email protected]","subId":"` + subId + `","enable":true},
-			{"id":"` + uuid + `","email":"[email protected]","subId":"` + subId + `","enable":true}
-		]}`,
+	db := database.GetDB()
+	ib := &model.Inbound{Protocol: model.VLESS, Enable: true, Port: 42002, Tag: "dedup-ci", Settings: `{"clients":[]}`}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	for _, email := range []string{"[email protected]", "[email protected]"} {
+		c := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
+		if err := db.Create(c).Error; err != nil {
+			t.Fatalf("seed client %q: %v", email, err)
+		}
+		if err := db.Create(&model.ClientInbound{ClientId: c.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("seed client_inbound %q: %v", email, err)
+		}
 	}
+
 	s := &SubService{}
 	got := s.matchingClients(ib, subId)
 	if len(got) != 1 {
@@ -90,7 +106,7 @@ func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
 	if got[0].Email != "[email protected]" {
 		t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
 	}
-	// A wrong subId must still be excluded (guards the subId filter at service.go:127).
+	// A wrong subId must still be excluded (guards the SQL subId filter).
 	if other := s.matchingClients(ib, "nope"); len(other) != 0 {
 		t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
 	}

+ 241 - 0
internal/sub/sub_scale_test.go

@@ -0,0 +1,241 @@
+package sub
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/op/go-logging"
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const scaleTargetSubId = "scale-target-sub"
+
+// setupScaleSubDB mirrors the service package's scale gating: Postgres via
+// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
+func setupScaleSubDB(t *testing.T) {
+	t.Helper()
+	xuilogger.InitLogger(logging.ERROR)
+
+	if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
+		if err := database.InitDB(""); err != nil {
+			t.Fatalf("InitDB(postgres): %v", err)
+		}
+		t.Cleanup(func() { _ = database.CloseDB() })
+		return
+	}
+	switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
+	case "1", "true", "yes":
+		if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
+			t.Fatalf("InitDB(sqlite): %v", err)
+		}
+		t.Cleanup(func() { _ = database.CloseDB() })
+		return
+	}
+	t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
+}
+
+func scaleSubSizes(t *testing.T, def ...int) []int {
+	t.Helper()
+	raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
+	if raw == "" {
+		return def
+	}
+	var out []int
+	for _, part := range strings.Split(raw, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		n, err := strconv.Atoi(part)
+		if err != nil || n <= 0 {
+			t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
+		}
+		out = append(out, n)
+	}
+	if len(out) == 0 {
+		return def
+	}
+	return out
+}
+
+func resetScaleSubTables(t *testing.T, db *gorm.DB) {
+	t.Helper()
+	if config.GetDBKind() == "postgres" {
+		if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+			t.Fatalf("truncate: %v", err)
+		}
+	} else {
+		for _, tbl := range []string{"inbounds", "clients", "client_inbounds", "client_traffics"} {
+			if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
+				t.Fatalf("delete %s: %v", tbl, err)
+			}
+		}
+		db.Exec("DELETE FROM sqlite_sequence")
+	}
+	if err := db.Where("1 = 1").Delete(&model.ClientExternalLink{}).Error; err != nil {
+		t.Fatalf("clear client_external_links: %v", err)
+	}
+}
+
+// seedScaleSubDataset seeds one VLESS inbound holding n clients (the sub
+// server's worst case: matchingClients parses the whole settings blob and
+// getInboundsBySubId preloads every ClientStats row). Three clients share
+// scaleTargetSubId; everyone else gets a unique subId.
+func seedScaleSubDataset(t *testing.T, n int) {
+	t.Helper()
+	db := database.GetDB()
+	resetScaleSubTables(t, db)
+
+	clients := make([]model.Client, n)
+	exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+	targets := map[int]bool{n / 4: true, n / 2: true, 3 * n / 4: true}
+	for i := range n {
+		subId := fmt.Sprintf("sub-%07d", i)
+		if targets[i] {
+			subId = scaleTargetSubId
+		}
+		clients[i] = model.Client{
+			ID:         uuid.NewString(),
+			Email:      fmt.Sprintf("user-%07d@subscale", i),
+			SubID:      subId,
+			Enable:     true,
+			ExpiryTime: exp,
+			TotalGB:    100 << 30,
+		}
+	}
+
+	settingsMap := map[string]any{"clients": clients, "decryption": "none"}
+	settings, err := json.Marshal(settingsMap)
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+
+	tx := db.Begin()
+	if tx.Error != nil {
+		t.Fatalf("begin seed tx: %v", tx.Error)
+	}
+	committed := false
+	defer func() {
+		if !committed {
+			tx.Rollback()
+		}
+	}()
+
+	ib := &model.Inbound{
+		UserId:         1,
+		Tag:            fmt.Sprintf("subscale-%d", n),
+		Remark:         "subscale",
+		Enable:         true,
+		Listen:         "203.0.113.1",
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       string(settings),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := tx.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+
+	records := make([]*model.ClientRecord, n)
+	for i := range clients {
+		records[i] = clients[i].ToRecord()
+	}
+	if err := tx.CreateInBatches(records, 500).Error; err != nil {
+		t.Fatalf("seed clients: %v", err)
+	}
+	links := make([]model.ClientInbound, n)
+	for i := range records {
+		links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id}
+	}
+	if err := tx.CreateInBatches(links, 1000).Error; err != nil {
+		t.Fatalf("seed client_inbounds: %v", err)
+	}
+	traffics := make([]xray.ClientTraffic, n)
+	for i := range clients {
+		traffics[i] = xray.ClientTraffic{
+			InboundId:  ib.Id,
+			Email:      clients[i].Email,
+			Enable:     true,
+			Total:      clients[i].TotalGB,
+			ExpiryTime: clients[i].ExpiryTime,
+		}
+	}
+	if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		t.Fatalf("commit seed tx: %v", err)
+	}
+	committed = true
+	db.Exec("ANALYZE")
+}
+
+// TestGetSubsScale measures one subscription fetch (raw and JSON format) for a
+// 3-client subId living inside an n-client inbound, plus a subId miss — the
+// per-request cost every subscriber pays.
+func TestGetSubsScale(t *testing.T) {
+	for _, n := range scaleSubSizes(t, 10000, 100000) {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			setupScaleSubDB(t)
+			seedScaleSubDataset(t, n)
+
+			svc := &SubService{}
+			const reps = 5
+			start := time.Now()
+			var links []string
+			for range reps {
+				var err error
+				links, _, _, _, err = svc.GetSubs(scaleTargetSubId, "sub.example.com")
+				if err != nil {
+					t.Fatalf("GetSubs: %v", err)
+				}
+			}
+			rawDur := time.Since(start) / reps
+			if len(links) != 3 {
+				t.Fatalf("GetSubs links = %d, want 3", len(links))
+			}
+
+			jsonSvc := NewSubJsonService("", "", "", &SubService{})
+			start = time.Now()
+			for range reps {
+				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
+				if err != nil {
+					t.Fatalf("GetJson: %v", err)
+				}
+				if body == "" {
+					t.Fatal("GetJson returned empty body")
+				}
+			}
+			jsonDur := time.Since(start) / reps
+
+			start = time.Now()
+			for range reps {
+				missLinks, _, _, _, err := svc.GetSubs("no-such-sub", "sub.example.com")
+				if err != nil {
+					t.Fatalf("GetSubs miss: %v", err)
+				}
+				if len(missLinks) != 0 {
+					t.Fatalf("GetSubs miss links = %d, want 0", len(missLinks))
+				}
+			}
+			missDur := time.Since(start) / reps
+
+			t.Logf("N=%-7d raw=%-10v json=%-10v miss=%v",
+				n, rawDur.Round(time.Millisecond), jsonDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
+		})
+	}
+}

+ 21 - 0
internal/util/common/url.go

@@ -0,0 +1,21 @@
+package common
+
+import "strings"
+
+// EnsureURLScheme prepends https:// to a URL that carries no scheme, so
+// subscription apps and browsers don't resolve it relative to the panel's own
+// domain (e.g. "t.me/support" turning into "https://panel.example/t.me/support").
+// Values with an explicit scheme (https://, tg://, mailto:, tel:) and empty
+// strings pass through untouched.
+func EnsureURLScheme(raw string) string {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return ""
+	}
+	if strings.Contains(trimmed, "://") ||
+		strings.HasPrefix(trimmed, "mailto:") ||
+		strings.HasPrefix(trimmed, "tel:") {
+		return trimmed
+	}
+	return "https://" + trimmed
+}

+ 29 - 0
internal/util/common/url_test.go

@@ -0,0 +1,29 @@
+package common
+
+import "testing"
+
+func TestEnsureURLScheme(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"empty", "", ""},
+		{"whitespace only", "   ", ""},
+		{"bare telegram handle", "t.me/xui_support", "https://t.me/xui_support"},
+		{"bare domain with path", "example.com/help", "https://example.com/help"},
+		{"already https", "https://t.me/xui_support", "https://t.me/xui_support"},
+		{"already http", "http://example.com", "http://example.com"},
+		{"telegram deep link", "tg://resolve?domain=xui_support", "tg://resolve?domain=xui_support"},
+		{"mailto", "mailto:[email protected]", "mailto:[email protected]"},
+		{"tel", "tel:+1234567890", "tel:+1234567890"},
+		{"trims whitespace", "  t.me/xui_support  ", "https://t.me/xui_support"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := EnsureURLScheme(tt.in); got != tt.want {
+				t.Errorf("EnsureURLScheme(%q) = %q, want %q", tt.in, got, tt.want)
+			}
+		})
+	}
+}

+ 152 - 0
internal/web/controller/panel_update_test.go

@@ -0,0 +1,152 @@
+package controller
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+
+	"github.com/gin-gonic/gin"
+)
+
+// newPanelUpdateTestEngine registers only updatePanel/getUpdateStatus directly
+// on the controller's zero value, bypassing NewServerController's cron/metrics
+// setup (unrelated to these two handlers, and unnecessary weight for a unit
+// test). Callers must set up a DB first (newHostTestDB(t)) since StartUpdate
+// reads the dev-channel setting before doing anything else.
+func newPanelUpdateTestEngine() *gin.Engine {
+	a := &ServerController{}
+	engine := gin.New()
+	engine.GET("/panel/api/server/getUpdateStatus", a.getUpdateStatus)
+	engine.POST("/panel/api/server/updatePanel", a.updatePanel)
+	return engine
+}
+
+func doPanelUpdateReq(t *testing.T, engine *gin.Engine, method, path string) hostEnvelope {
+	t.Helper()
+	req := httptest.NewRequest(method, path, nil)
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
+	}
+	var env hostEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
+	}
+	return env
+}
+
+// TestGetUpdateStatus_NoStatusFileYet exercises the read-only status endpoint
+// with no prior update having run: it must report "pending" (not an error),
+// since a missing status file is an expected, ordinary state, not a failure.
+func TestGetUpdateStatus_NoStatusFileYet(t *testing.T) {
+	newHostTestDB(t)
+	engine := newPanelUpdateTestEngine()
+
+	env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
+	if !env.Success {
+		t.Fatalf("getUpdateStatus should always report success=true (it's a best-effort read): msg=%s", env.Msg)
+	}
+	var status struct {
+		RunID string `json:"runId"`
+		State string `json:"state"`
+	}
+	if err := json.Unmarshal(env.Obj, &status); err != nil {
+		t.Fatalf("decode status: %v", err)
+	}
+	if status.State != "pending" {
+		t.Fatalf("State = %q, want %q", status.State, "pending")
+	}
+}
+
+// TestGetUpdateStatus_RunIdIsAlwaysAString is the regression test for the
+// precision bug found in review: RunID is a 19-digit UnixNano timestamp, so
+// it must round-trip over the wire as a JSON string, never a bare number -- a
+// bare number would silently lose precision in JavaScript past
+// Number.MAX_SAFE_INTEGER, breaking every future runId comparison on the
+// frontend. Decoding into a Go string field below only succeeds if the wire
+// value is actually a JSON string; a bare number there would fail to decode,
+// so this test doubles as the wire-format check.
+func TestGetUpdateStatus_RunIdIsAlwaysAString(t *testing.T) {
+	newHostTestDB(t)
+	engine := newPanelUpdateTestEngine()
+
+	statusPath := config.GetUpdateStatusFilePath()
+	body := `{"runId":"1735689600123456789","state":"success","exitCode":0,"finishedAt":1735689612}`
+	if err := os.WriteFile(statusPath, []byte(body), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
+	var status struct {
+		RunID string `json:"runId"`
+		State string `json:"state"`
+	}
+	if err := json.Unmarshal(env.Obj, &status); err != nil {
+		t.Fatalf("decode status (would fail here if runId were a bare JSON number instead of a string): %v, body=%s", err, env.Obj)
+	}
+	if status.RunID != "1735689600123456789" {
+		t.Fatalf("RunID = %q, want %q", status.RunID, "1735689600123456789")
+	}
+	if status.State != "success" {
+		t.Fatalf("State = %q, want %q", status.State, "success")
+	}
+}
+
+// TestUpdatePanel_UnsupportedPlatformReturnsNoRunId covers the one path of
+// updatePanel that's safe to exercise in an automated test on any OS/CI
+// runner: the runtime.GOOS != "linux" guard. Actually invoking StartUpdate's
+// launch logic on Linux would make a real network call and could launch a
+// real update.sh process, so that path is deliberately not covered here --
+// see the PR description for why.
+func TestUpdatePanel_UnsupportedPlatformReturnsNoRunId(t *testing.T) {
+	if runtime.GOOS == "linux" {
+		t.Skip("this test only exercises the non-Linux guard path; on Linux, updatePanel would attempt a real download/exec")
+	}
+	newHostTestDB(t)
+	engine := newPanelUpdateTestEngine()
+
+	env := doPanelUpdateReq(t, engine, http.MethodPost, "/panel/api/server/updatePanel")
+	if env.Success {
+		t.Fatal("updatePanel on an unsupported platform: success = true, want false")
+	}
+	if len(env.Obj) != 0 && string(env.Obj) != "null" {
+		t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
+	}
+}
+
+// TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch covers the one branch of
+// updatePanel that's both untested and safe to exercise on any OS/CI runner:
+// an unparseable "dev" form value is rejected by strconv.ParseBool before
+// StartUpdateChannel (and therefore any real exec/network call) is ever
+// reached, on Linux or otherwise.
+func TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch(t *testing.T) {
+	newHostTestDB(t)
+	engine := newPanelUpdateTestEngine()
+
+	form := url.Values{"dev": {"notabool"}}
+	req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("status %d, body=%s", w.Code, w.Body.String())
+	}
+	var env hostEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
+	}
+	if env.Success {
+		t.Fatal("updatePanel with dev=notabool: success = true, want false")
+	}
+	if len(env.Obj) != 0 && string(env.Obj) != "null" {
+		t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
+	}
+}

+ 18 - 4
internal/web/controller/server.go

@@ -51,6 +51,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
 	g.GET("/getXrayVersion", a.getXrayVersion)
 	g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
+	g.GET("/getUpdateStatus", a.getUpdateStatus)
 	g.GET("/getConfigJson", a.getConfigJson)
 	g.GET("/getDb", a.getDb)
 	g.GET("/getMigration", a.getMigration)
@@ -209,21 +210,34 @@ func (a *ServerController) installXray(c *gin.Context) {
 
 // updatePanel starts a panel self-update. With no "dev" form value it follows
 // this panel's own channel setting; an explicit "dev" (sent by the master node
-// updater) overrides it for this run.
+// updater) overrides it for this run. The response's runId identifies this
+// update for a later getUpdateStatus poll.
 func (a *ServerController) updatePanel(c *gin.Context) {
 	devParam := c.PostForm("dev")
+	var runID int64
 	var err error
 	if devParam == "" {
-		err = a.panelService.StartUpdate()
+		runID, err = a.panelService.StartUpdate()
 	} else {
 		dev, perr := strconv.ParseBool(devParam)
 		if perr != nil {
 			jsonMsg(c, "invalid data", perr)
 			return
 		}
-		err = a.panelService.StartUpdateChannel(dev)
+		runID, err = a.panelService.StartUpdateChannel(dev)
 	}
-	jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
+	var obj any
+	if err == nil {
+		obj = gin.H{"runId": strconv.FormatInt(runID, 10)}
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), obj, err)
+}
+
+// getUpdateStatus reports the outcome of the most recently launched panel
+// self-update (see updatePanel). Compare the returned runId against the one
+// updatePanel returned to tell this run's result apart from a stale one.
+func (a *ServerController) getUpdateStatus(c *gin.Context) {
+	jsonObj(c, a.panelService.GetUpdateStatus(), nil)
 }
 
 // setUpdateChannel toggles whether self-update tracks the rolling dev release.

+ 13 - 2
internal/web/controller/setting.go

@@ -26,9 +26,16 @@ type updateUserForm struct {
 	TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
 }
 
+// updateSettingForm carries the persisted settings plus request-scoped fields
+// that must never land in the settings table: the 2FA confirmation code and
+// the explicit clear flags for redacted secrets (a blank secret alone means
+// "unchanged", so clearing needs its own signal — see #5724).
 type updateSettingForm struct {
 	entity.AllSetting
-	TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
+	TwoFactorCode     string `json:"twoFactorCode" form:"twoFactorCode"`
+	ClearTgBotToken   bool   `json:"clearTgBotToken" form:"clearTgBotToken"`
+	ClearLdapPassword bool   `json:"clearLdapPassword" form:"clearLdapPassword"`
+	ClearSmtpPassword bool   `json:"clearSmtpPassword" form:"clearSmtpPassword"`
 }
 
 // SettingController handles settings and user management operations.
@@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 			return
 		}
 	}
-	err := a.settingService.UpdateAllSetting(allSetting)
+	err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
+		TgBotToken:   form.ClearTgBotToken,
+		LdapPassword: form.ClearLdapPassword,
+		SmtpPassword: form.ClearSmtpPassword,
+	})
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr

+ 217 - 101
internal/web/job/check_client_ip_job.go

@@ -113,33 +113,129 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
 	return observed, true
 }
 
+// hasLimitIp reports whether any client carries an IP limit. It probes the
+// normalized clients table (limit_ip is synced there by SyncInbound and the
+// legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that
+// loaded and JSON-parsed every inbound's settings blob on each 10s run.
 func (j *CheckClientIpJob) hasLimitIp() bool {
 	db := database.GetDB()
-	var inbounds []*model.Inbound
+	var probe int64
+	err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error
+	return err == nil && probe > 0
+}
 
-	err := db.Model(model.Inbound{}).Where("settings LIKE ?", "%limitIp%").Find(&inbounds).Error
-	if err != nil {
-		return false
+const ipScanChunk = 400
+
+func chunkEmails(s []string, size int) [][]string {
+	if len(s) == 0 {
+		return nil
 	}
+	chunks := make([][]string, 0, (len(s)+size-1)/size)
+	for size < len(s) {
+		s, chunks = s[size:], append(chunks, s[:size])
+	}
+	return append(chunks, s)
+}
 
-	for _, inbound := range inbounds {
-		if inbound.Settings == "" {
+// loadClientLimits maps each observed email to its clients.limit_ip in a few
+// chunked queries, replacing the per-email settings-JSON parse that previously
+// resolved the limit.
+func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int {
+	db := database.GetDB()
+	out := make(map[string]int, len(emails))
+	for _, batch := range chunkEmails(emails, ipScanChunk) {
+		var rows []struct {
+			Email   string
+			LimitIp int
+		}
+		if err := db.Model(&model.ClientRecord{}).
+			Select("email, limit_ip").
+			Where("email IN ?", batch).
+			Scan(&rows).Error; err != nil {
+			j.checkError(err)
 			continue
 		}
+		for _, r := range rows {
+			out[r.Email] = r.LimitIp
+		}
+	}
+	return out
+}
 
-		settings := map[string][]model.Client{}
-		_ = json.Unmarshal([]byte(inbound.Settings), &settings)
-		clients := settings["clients"]
-
-		for _, client := range clients {
-			limitIp := client.LimitIP
-			if limitIp > 0 {
-				return true
+// loadInboundsByEmails resolves each email's owning inbound through the
+// clients/client_inbounds relation in chunked queries. Like the old per-email
+// First() it keeps the lowest inbound id when a client spans several inbounds.
+func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
+	db := database.GetDB()
+	minInboundByEmail := make(map[string]int, len(emails))
+	for _, batch := range chunkEmails(emails, ipScanChunk) {
+		var pairs []struct {
+			Email     string
+			InboundId int
+		}
+		if err := db.Table("client_inbounds").
+			Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ?", batch).
+			Scan(&pairs).Error; err != nil {
+			j.checkError(err)
+			return nil
+		}
+		for _, p := range pairs {
+			if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur {
+				minInboundByEmail[p.Email] = p.InboundId
 			}
 		}
 	}
+	if len(minInboundByEmail) == 0 {
+		return nil
+	}
+
+	idSet := make(map[int]struct{}, len(minInboundByEmail))
+	ids := make([]int, 0, len(minInboundByEmail))
+	for _, id := range minInboundByEmail {
+		if _, seen := idSet[id]; !seen {
+			idSet[id] = struct{}{}
+			ids = append(ids, id)
+		}
+	}
+	sort.Ints(ids)
+	inboundsById := make(map[int]*model.Inbound, len(ids))
+	for lo := 0; lo < len(ids); lo += ipScanChunk {
+		hi := min(lo+ipScanChunk, len(ids))
+		var page []*model.Inbound
+		if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil {
+			j.checkError(err)
+			return nil
+		}
+		for _, ib := range page {
+			inboundsById[ib.Id] = ib
+		}
+	}
+
+	out := make(map[string]*model.Inbound, len(minInboundByEmail))
+	for email, id := range minInboundByEmail {
+		if ib, ok := inboundsById[id]; ok {
+			out[email] = ib
+		}
+	}
+	return out
+}
 
-	return false
+func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps {
+	db := database.GetDB()
+	out := make(map[string]*model.InboundClientIps, len(emails))
+	for _, batch := range chunkEmails(emails, ipScanChunk) {
+		var rows []model.InboundClientIps
+		if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil {
+			j.checkError(err)
+			continue
+		}
+		for i := range rows {
+			out[rows[i].ClientEmail] = &rows[i]
+		}
+	}
+	return out
 }
 
 // processObserved runs collection + enforcement for one scan's observations
@@ -147,27 +243,68 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 // observations as live connections, which bypass the stale cutoff: a connection
 // that opened hours ago is still live even though its timestamp is old. The
 // online-stats API always reports live connections, so the job passes true.
+// Lookups are batched up front and all inbound_client_ips writes share one
+// transaction, so a scan costs a handful of queries and one fsync instead of
+// several per observed email.
 func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
 	now := time.Now().Unix()
+
+	emails := make([]string, 0, len(observed))
+	for email := range observed {
+		emails = append(emails, email)
+	}
+	sort.Strings(emails)
+
+	limitByEmail := j.loadClientLimits(emails)
+	inboundByEmail := j.loadInboundsByEmails(emails)
+	ipRowByEmail := j.loadClientIpRows(emails)
+
 	// attribution accumulates this scan's local observations per email so they can
 	// be recorded under this panel's own guid for cross-node IP attribution.
 	attribution := make(map[string][]model.ClientIpEntry, len(observed))
-	for email, ipTimestamps := range observed {
+
+	type pendingDisconnect struct {
+		inbound *model.Inbound
+		email   string
+	}
+	var disconnects []pendingDisconnect
+
+	db := database.GetDB()
+	tx := db.Begin()
+	if tx.Error != nil {
+		j.checkError(tx.Error)
+		return false
+	}
+	committed := false
+	defer func() {
+		if !committed {
+			tx.Rollback()
+		}
+	}()
+
+	for _, email := range emails {
+		ipTimestamps := observed[email]
 
 		// The observations can still reference a client that was just renamed
 		// or deleted; its email no longer matches any inbound. Skip it (and
 		// drop any orphaned tracking row) instead of recreating a row and
-		// logging an ERROR every run (#4963).
-		inbound, err := j.getInboundByEmail(email)
-		if err != nil {
-			if errors.Is(err, gorm.ErrRecordNotFound) {
-				logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
-				j.delInboundClientIps(email)
-			} else {
-				j.checkError(err)
+		// logging an ERROR every run (#4963). The batch map resolves through
+		// the clients relation; the per-email fallback keeps its settings LIKE
+		// net for clients not yet present there.
+		inbound, ok := inboundByEmail[email]
+		if !ok {
+			var err error
+			inbound, err = j.getInboundByEmail(email)
+			if err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
+					j.delInboundClientIps(tx, email)
+				} else {
+					j.checkError(err)
+				}
+				continue
 			}
-			continue
 		}
 
 		// Convert to IPWithTimestamp slice
@@ -188,13 +325,44 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
 			attribution[email] = attrEntries
 		}
 
-		clientIpsRecord, err := j.getInboundClientIps(email)
-		if err != nil {
-			_ = j.addInboundClientIps(email, ipsWithTime)
+		clientIpsRecord, ok := ipRowByEmail[email]
+		if !ok {
+			jsonIps, err := json.Marshal(ipsWithTime)
+			if err != nil {
+				j.checkError(err)
+				continue
+			}
+			if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
+				j.checkError(err)
+			}
 			continue
 		}
 
-		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
+		cleaned, banned := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
+		shouldCleanLog = cleaned || shouldCleanLog
+		if banned {
+			disconnects = append(disconnects, pendingDisconnect{inbound: inbound, email: email})
+		}
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		j.checkError(err)
+		return shouldCleanLog
+	}
+	committed = true
+
+	// Xray disconnects run after the commit so their network round-trips never
+	// extend the scan's write transaction (node syncs upsert the same table).
+	clientsCache := make(map[int][]model.Client)
+	for _, d := range disconnects {
+		clients, cached := clientsCache[d.inbound.Id]
+		if !cached {
+			settings := map[string][]model.Client{}
+			_ = json.Unmarshal([]byte(d.inbound.Settings), &settings)
+			clients = settings["clients"]
+			clientsCache[d.inbound.Id] = clients
+		}
+		j.disconnectClientTemporarily(d.inbound, d.email, clients)
 	}
 
 	j.recordLocalAttribution(attribution)
@@ -293,81 +461,34 @@ func (j *CheckClientIpJob) checkError(e error) {
 	}
 }
 
-func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) {
-	db := database.GetDB()
-	InboundClientIps := &model.InboundClientIps{}
-	err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
-	if err != nil {
-		return nil, err
-	}
-	return InboundClientIps, nil
-}
-
-func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
-	inboundClientIps := &model.InboundClientIps{}
-	jsonIps, err := json.Marshal(ipsWithTime)
-	j.checkError(err)
-
-	inboundClientIps.ClientEmail = clientEmail
-	inboundClientIps.Ips = string(jsonIps)
-
-	db := database.GetDB()
-	tx := db.Begin()
-
-	defer func() {
-		if err == nil {
-			tx.Commit()
-		} else {
-			tx.Rollback()
-		}
-	}()
-
-	err = tx.Save(inboundClientIps).Error
-	if err != nil {
-		return err
-	}
-	return nil
-}
-
 // delInboundClientIps drops the inbound_client_ips tracking row for an email
 // that no longer maps to any inbound (a renamed or deleted client), so stale
 // access-log entries don't keep a ghost row alive (#4963).
-func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
-	db := database.GetDB()
-	if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
+func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
+	if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
 		j.checkError(err)
 	}
 }
 
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
+// updateInboundClientIps merges one email's observed IPs into its tracking row
+// and applies the IP limit. limitIp comes from the caller (the clients table);
+// writes go through the caller's transaction. banned=true asks the caller to
+// disconnect the client after the transaction commits.
+func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (shouldCleanLog, banned bool) {
 	if inbound.Settings == "" {
 		logger.Debug("wrong data:", inbound)
-		return false
-	}
-
-	settings := map[string][]model.Client{}
-	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
-	clients := settings["clients"]
-
-	// Find the client's IP limit
-	var limitIp int
-	var clientFound bool
-	for _, client := range clients {
-		if client.Email == clientEmail {
-			limitIp = client.LimitIP
-			clientFound = true
-			break
-		}
+		return false, false
 	}
 
-	if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
-		// Nothing to enforce (collection-only run, no limit, client missing, or
-		// inbound disabled): record the observed IPs for the panel and return.
+	if !enforce || limitIp <= 0 || !inbound.Enable {
+		// Nothing to enforce (collection-only run, no limit on the clients row,
+		// or inbound disabled): record the observed IPs for the panel and return.
 		jsonIps, _ := json.Marshal(newIpsWithTime)
 		inboundClientIps.Ips = string(jsonIps)
-		db := database.GetDB()
-		db.Save(inboundClientIps)
-		return false
+		if err := tx.Save(inboundClientIps).Error; err != nil {
+			logger.Error("failed to save inboundClientIps:", err)
+		}
+		return false, false
 	}
 
 	// Parse old IPs from database
@@ -386,18 +507,18 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	}
 	liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
 
-	shouldCleanLog := false
 	j.disAllowedIps = []string{}
 
 	// historical db-only ips are excluded from this count on purpose.
 	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
 	if len(bannedLive) > 0 {
 		shouldCleanLog = true
+		banned = true
 
 		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
 		if err != nil {
 			logger.Errorf("failed to open IP limit log file: %s", err)
-			return false
+			return false, false
 		}
 		defer logIpFile.Close()
 		ipLogger := log.New(logIpFile, "", log.LstdFlags)
@@ -410,9 +531,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 			j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
 			ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 		}
-
-		// force xray to drop existing connections from banned ips
-		j.disconnectClientTemporarily(inbound, clientEmail, clients)
 	}
 
 	// keep kept-live + historical in the blob so the panel keeps showing
@@ -424,18 +542,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	jsonIps, _ := json.Marshal(dbIps)
 	inboundClientIps.Ips = string(jsonIps)
 
-	db := database.GetDB()
-	err := db.Save(inboundClientIps).Error
-	if err != nil {
+	if err := tx.Save(inboundClientIps).Error; err != nil {
 		logger.Error("failed to save inboundClientIps:", err)
-		return false
+		return false, banned
 	}
 
 	if len(j.disAllowedIps) > 0 {
 		logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
 	}
 
-	return shouldCleanLog
+	return shouldCleanLog, banned
 }
 
 // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections

+ 34 - 3
internal/web/job/check_client_ip_job_integration_test.go

@@ -95,7 +95,7 @@ func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *mo
 func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
 	t.Helper()
 	inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
-	client := &model.ClientRecord{Email: email}
+	client := &model.ClientRecord{Email: email, LimitIP: limitIp}
 	if err := database.GetDB().Create(client).Error; err != nil {
 		t.Fatalf("seed client record: %v", err)
 	}
@@ -206,11 +206,14 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
+	shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 3, live, true, false)
 
 	if shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
 	}
+	if banned {
+		t.Fatalf("banned must be false with 1 live ip under limit 3")
+	}
 	if len(j.disAllowedIps) != 0 {
 		t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
 	}
@@ -259,11 +262,14 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
+	shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
 
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
 	}
+	if !banned {
+		t.Fatalf("banned must be true when the live set exceeds the limit")
+	}
 	if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
 		t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
 	}
@@ -388,3 +394,28 @@ func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
 		t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
 	}
 }
+
+// hasLimitIp gates every 10s scan on the normalized clients table: a bare
+// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed)
+// must not enable enforcement, while a single clients.limit_ip > 0 row must.
+func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
+	setupIntegrationDB(t)
+	j := &CheckClientIpJob{}
+
+	if j.hasLimitIp() {
+		t.Fatal("hasLimitIp = true on an empty database")
+	}
+
+	seedLinkedInboundWithClient(t, "no-limit", "[email protected]", 0)
+	if j.hasLimitIp() {
+		t.Fatal("hasLimitIp = true with only limit_ip=0 clients")
+	}
+
+	limited := &model.ClientRecord{Email: "[email protected]", LimitIP: 2}
+	if err := database.GetDB().Create(limited).Error; err != nil {
+		t.Fatalf("seed limited client: %v", err)
+	}
+	if !j.hasLimitIp() {
+		t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
+	}
+}

+ 229 - 0
internal/web/job/check_client_ip_scale_test.go

@@ -0,0 +1,229 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/op/go-logging"
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// setupScaleJobDB mirrors the service package's scale gating: Postgres via
+// XUI_DB_TYPE/XUI_DB_DSN, SQLite via XUI_SCALE_TEST=1, skip otherwise.
+func setupScaleJobDB(t *testing.T) {
+	t.Helper()
+	loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
+	t.Setenv("XUI_LOG_FOLDER", t.TempDir())
+
+	if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
+		if err := database.InitDB(""); err != nil {
+			t.Fatalf("InitDB(postgres): %v", err)
+		}
+		t.Cleanup(func() { _ = database.CloseDB() })
+		return
+	}
+	switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
+	case "1", "true", "yes":
+		if err := database.InitDB(filepath.Join(t.TempDir(), "scale.db")); err != nil {
+			t.Fatalf("InitDB(sqlite): %v", err)
+		}
+		t.Cleanup(func() { _ = database.CloseDB() })
+		return
+	}
+	t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
+}
+
+func scaleJobSizes(t *testing.T, def ...int) []int {
+	t.Helper()
+	raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
+	if raw == "" {
+		return def
+	}
+	var out []int
+	for _, part := range strings.Split(raw, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		n, err := strconv.Atoi(part)
+		if err != nil || n <= 0 {
+			t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
+		}
+		out = append(out, n)
+	}
+	if len(out) == 0 {
+		return def
+	}
+	return out
+}
+
+func resetScaleJobTables(t *testing.T, db *gorm.DB) {
+	t.Helper()
+	if config.GetDBKind() == "postgres" {
+		if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
+			t.Fatalf("truncate: %v", err)
+		}
+	} else {
+		for _, tbl := range []string{"inbounds", "clients", "client_inbounds"} {
+			if err := db.Exec("DELETE FROM " + tbl).Error; err != nil {
+				t.Fatalf("delete %s: %v", tbl, err)
+			}
+		}
+		db.Exec("DELETE FROM sqlite_sequence")
+	}
+	if err := db.Where("1 = 1").Delete(&model.InboundClientIps{}).Error; err != nil {
+		t.Fatalf("clear inbound client ips: %v", err)
+	}
+	if err := db.Where("1 = 1").Delete(&model.NodeClientIp{}).Error; err != nil {
+		t.Fatalf("clear node client ips: %v", err)
+	}
+}
+
+// seedScaleIPDataset seeds n clients across numInbounds inbounds. Every client
+// in the LAST inbound carries limitIp=3 (and 0 elsewhere), so hasLimitIp pays
+// its full scan cost before finding a hit, and the returned emails all resolve
+// to that last inbound for the processObserved measurement.
+func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
+	t.Helper()
+	db := database.GetDB()
+	resetScaleJobTables(t, db)
+
+	tx := db.Begin()
+	if tx.Error != nil {
+		t.Fatalf("begin seed tx: %v", tx.Error)
+	}
+	committed := false
+	defer func() {
+		if !committed {
+			tx.Rollback()
+		}
+	}()
+
+	var limitedEmails []string
+	per := n / numInbounds
+	for i := range numInbounds {
+		lo, hi := i*per, (i+1)*per
+		if i == numInbounds-1 {
+			hi = n
+		}
+		limitIp := 0
+		if i == numInbounds-1 {
+			limitIp = 3
+		}
+		clients := make([]model.Client, 0, hi-lo)
+		records := make([]*model.ClientRecord, 0, hi-lo)
+		for j := lo; j < hi; j++ {
+			email := fmt.Sprintf("user-%07d@ipscale", j)
+			clients = append(clients, model.Client{Email: email, LimitIP: limitIp, Enable: true})
+			records = append(records, &model.ClientRecord{Email: email, LimitIP: limitIp, Enable: true})
+			if limitIp > 0 {
+				limitedEmails = append(limitedEmails, email)
+			}
+		}
+		settings, err := json.Marshal(map[string][]model.Client{"clients": clients})
+		if err != nil {
+			t.Fatalf("marshal settings: %v", err)
+		}
+		ib := &model.Inbound{
+			UserId:   1,
+			Tag:      fmt.Sprintf("ipscale-%d-%d", n, i),
+			Enable:   true,
+			Port:     42000 + i,
+			Protocol: model.VLESS,
+			Settings: string(settings),
+		}
+		if err := tx.Create(ib).Error; err != nil {
+			t.Fatalf("seed inbound %d: %v", i, err)
+		}
+		if err := tx.CreateInBatches(records, 500).Error; err != nil {
+			t.Fatalf("seed clients %d: %v", i, err)
+		}
+		links := make([]model.ClientInbound, len(records))
+		for j := range records {
+			links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
+		}
+		if err := tx.CreateInBatches(links, 1000).Error; err != nil {
+			t.Fatalf("seed client_inbounds %d: %v", i, err)
+		}
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		t.Fatalf("commit seed tx: %v", err)
+	}
+	committed = true
+	db.Exec("ANALYZE")
+	return limitedEmails
+}
+
+// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
+// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
+// inbound) and processObserved with M online users (per-email inbound lookup,
+// settings parse and autocommit save). Run twice: first scan half add / half
+// update, second scan all update path.
+func TestCheckClientIpScale(t *testing.T) {
+	shapes := []struct {
+		name     string
+		inbounds int
+		observed int
+	}{{"single", 1, 50}, {"spread50", 50, 1000}}
+
+	for _, n := range scaleJobSizes(t, 10000, 100000) {
+		for _, shape := range shapes {
+			t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
+				setupScaleJobDB(t)
+				limited := seedScaleIPDataset(t, n, shape.inbounds)
+				m := min(shape.observed, len(limited))
+
+				j := NewCheckClientIpJob()
+				const reps = 3
+				start := time.Now()
+				for range reps {
+					if !j.hasLimitIp() {
+						t.Fatal("hasLimitIp = false, want true")
+					}
+				}
+				t.Logf("N=%-7d shape=%-8s hasLimitIp=%v/call", n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
+
+				now := time.Now().Unix()
+				observed := make(map[string]map[string]int64, m)
+				for i := range m {
+					observed[limited[i]] = map[string]int64{
+						fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now,
+					}
+				}
+				for i := range m / 2 {
+					seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
+				}
+
+				start = time.Now()
+				j.processObserved(observed, true, true)
+				firstScan := time.Since(start)
+				start = time.Now()
+				j.processObserved(observed, true, true)
+				secondScan := time.Since(start)
+				t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
+					n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
+					float64(secondScan.Milliseconds())/float64(m))
+
+				var rows int64
+				if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&rows).Error; err != nil {
+					t.Fatalf("count ip rows: %v", err)
+				}
+				if rows != int64(m) {
+					t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
+				}
+			})
+		}
+	}
+}

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

@@ -110,6 +110,8 @@ func (j *NodeTrafficSyncJob) Run() {
 
 	sem := make(chan struct{}, nodeTrafficSyncConcurrency)
 	var wg sync.WaitGroup
+	var activeMu sync.Mutex
+	var activeEmails []string
 	for _, n := range nodes {
 		if !n.Enable || n.Status != "online" {
 			continue
@@ -120,7 +122,11 @@ func (j *NodeTrafficSyncJob) Run() {
 		common.GoRecover("node-traffic-sync:"+n.Name, func() {
 			defer wg.Done()
 			defer func() { <-sem }()
-			j.syncOne(mgr, n, doIpSync)
+			if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
+				activeMu.Lock()
+				activeEmails = append(activeEmails, emails...)
+				activeMu.Unlock()
+			}
 		})
 	}
 	wg.Wait()
@@ -143,14 +149,6 @@ func (j *NodeTrafficSyncJob) Run() {
 
 	j.maybePushGlobals(mgr, nodes)
 
-	lastOnline, err := j.inboundService.GetClientsLastOnline()
-	if err != nil {
-		logger.Warning("node traffic sync: get last-online failed:", err)
-	}
-	if lastOnline == nil {
-		lastOnline = map[string]int64{}
-	}
-
 	// Prune stale local-online entries (no local active emails or inbound tags
 	// to add here — only the local xray poll feeds those) so a stopped local
 	// xray's clients and inbounds still age out between traffic polls.
@@ -164,6 +162,45 @@ func (j *NodeTrafficSyncJob) Run() {
 		return
 	}
 
+	// Same snapshot-vs-delta split as the local traffic job: above the
+	// threshold a full snapshot would be dropped by the hub's payload cap, so
+	// send only the rows for clients online on the synced nodes this tick.
+	snapshot := true
+	if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
+		logger.Warning("node traffic sync: count client traffics failed:", countErr)
+	} else if total > clientStatsSnapshotMaxClients {
+		snapshot = false
+	}
+
+	var stats []*xray.ClientTraffic
+	var statsErr error
+	if snapshot {
+		stats, statsErr = j.inboundService.GetAllClientTraffics()
+	} else {
+		stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
+	}
+	if statsErr != nil {
+		logger.Warning("node traffic sync: get client traffics for websocket failed:", statsErr)
+	}
+
+	var lastOnline map[string]int64
+	if snapshot {
+		var loErr error
+		if lastOnline, loErr = j.inboundService.GetClientsLastOnline(); loErr != nil {
+			logger.Warning("node traffic sync: get last-online failed:", loErr)
+		}
+	} else {
+		lastOnline = make(map[string]int64, len(stats))
+		for _, ct := range stats {
+			if ct != nil {
+				lastOnline[ct.Email] = ct.LastOnline
+			}
+		}
+	}
+	if lastOnline == nil {
+		lastOnline = map[string]int64{}
+	}
+
 	online := j.inboundService.GetOnlineClients()
 	if online == nil {
 		online = []string{}
@@ -181,10 +218,8 @@ func (j *NodeTrafficSyncJob) Run() {
 	trafficPayload["nodeTraffics"] = inboundSpeed
 	websocket.BroadcastTraffic(trafficPayload)
 
-	clientStats := map[string]any{}
-	if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
-		logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
-	} else if len(stats) > 0 {
+	clientStats := map[string]any{"snapshot": snapshot}
+	if len(stats) > 0 {
 		clientStats["clients"] = stats
 	}
 	if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -192,7 +227,7 @@ func (j *NodeTrafficSyncJob) Run() {
 	} else if len(summary) > 0 {
 		clientStats["inbounds"] = summary
 	}
-	if len(clientStats) > 0 {
+	if len(clientStats) > 1 {
 		websocket.BroadcastClientStats(clientStats)
 	}
 
@@ -318,11 +353,14 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 	wg.Wait()
 }
 
-func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
+// syncOne pulls one node's traffic snapshot and merges it. It returns the
+// emails online on that node this tick, feeding the delta broadcast above the
+// snapshot threshold; nil on any failure path.
+func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
 	rt, err := mgr.RemoteFor(n)
 	if err != nil {
 		logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
-		return
+		return nil
 	}
 
 	if n.ConfigDirty {
@@ -331,7 +369,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		reconcileCancel()
 		if reconcileErr != nil {
 			logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr)
-			return
+			return nil
 		}
 		if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
 			logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
@@ -346,21 +384,27 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	if err != nil {
 		logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
 		j.inboundService.ClearNodeOnlineClients(n.Id)
-		return
+		return nil
 	}
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {
 		logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
-		return
+		return nil
 	}
 	if changed {
 		j.structural.set()
 	}
 
+	active := make([]string, 0, len(snap.OnlineEmails))
+	active = append(active, snap.OnlineEmails...)
+	for _, emails := range snap.OnlineTree {
+		active = append(active, emails...)
+	}
+
 	if !doIpSync {
-		return
+		return active
 	}
 
 	ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
@@ -378,7 +422,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	masterIps, err := j.inboundService.GetAllInboundClientIps()
 	if err != nil {
 		logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
-		return
+		return active
 	}
 	if len(masterIps) > 0 {
 		if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
@@ -406,4 +450,5 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 			}
 		}
 	}
+	return active
 }

+ 46 - 8
internal/web/job/xray_traffic_job.go

@@ -20,6 +20,14 @@ type XrayTrafficJob struct {
 	outboundService outbound.OutboundService
 }
 
+// clientStatsSnapshotMaxClients caps how many client_traffics rows the job
+// ships as a full websocket snapshot per poll (same spirit as the
+// controller's broadcastInboundsUpdateClientLimit). Above it, a snapshot
+// would blow past the hub's payload cap and be dropped wholesale, so the job
+// broadcasts only this poll's active rows and the UI leans on its 5s REST
+// refetch for the rest.
+const clientStatsSnapshotMaxClients = 5000
+
 // NewXrayTrafficJob creates a new traffic collection job instance.
 func NewXrayTrafficJob() *XrayTrafficJob {
 	return new(XrayTrafficJob)
@@ -116,9 +124,41 @@ func (j *XrayTrafficJob) Run() {
 		return
 	}
 
-	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
-	if err != nil {
-		logger.Warning("get clients last online failed:", err)
+	// Small installs broadcast the full snapshot (see GetAllClientTraffics for
+	// why deltas alone left UI rows stale). Above the threshold the snapshot
+	// would be dropped by the hub's payload cap anyway, so ship this poll's
+	// active rows instead and scope last-online to them; the initial full map
+	// still arrives over REST.
+	snapshot := true
+	if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
+		logger.Warning("count client traffics for websocket failed:", countErr)
+	} else if total > clientStatsSnapshotMaxClients {
+		snapshot = false
+	}
+
+	var stats []*xray.ClientTraffic
+	var statsErr error
+	if snapshot {
+		stats, statsErr = j.inboundService.GetAllClientTraffics()
+	} else {
+		stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
+	}
+	if statsErr != nil {
+		logger.Warning("get client traffics for websocket failed:", statsErr)
+	}
+
+	var lastOnlineMap map[string]int64
+	if snapshot {
+		if lastOnlineMap, err = j.inboundService.GetClientsLastOnline(); err != nil {
+			logger.Warning("get clients last online failed:", err)
+		}
+	} else {
+		lastOnlineMap = make(map[string]int64, len(stats))
+		for _, ct := range stats {
+			if ct != nil {
+				lastOnlineMap[ct.Email] = ct.LastOnline
+			}
+		}
 	}
 	if lastOnlineMap == nil {
 		lastOnlineMap = make(map[string]int64)
@@ -136,10 +176,8 @@ func (j *XrayTrafficJob) Run() {
 		"lastOnlineMap":  lastOnlineMap,
 	})
 
-	clientStatsPayload := map[string]any{}
-	if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
-		logger.Warning("get all client traffics for websocket failed:", err)
-	} else if len(stats) > 0 {
+	clientStatsPayload := map[string]any{"snapshot": snapshot}
+	if len(stats) > 0 {
 		clientStatsPayload["clients"] = stats
 	}
 	if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
@@ -147,7 +185,7 @@ func (j *XrayTrafficJob) Run() {
 	} else if len(inboundSummary) > 0 {
 		clientStatsPayload["inbounds"] = inboundSummary
 	}
-	if len(clientStatsPayload) > 0 {
+	if len(clientStatsPayload) > 1 {
 		websocket.BroadcastClientStats(clientStatsPayload)
 	}
 

+ 31 - 0
internal/web/service/client_link.go

@@ -197,3 +197,34 @@ func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Clie
 	}
 	return out, nil
 }
+
+// ListForInboundBySubId is ListForInbound narrowed to one subscription id —
+// both filter columns are indexed, so the subscription server resolves a
+// subscriber's clients without touching the inbound's settings JSON.
+func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId string) ([]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 = ? AND clients.sub_id = ?", inboundId, subId).
+		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()
+		c.Flow = rows[i].FlowOverride
+		out = append(out, *c)
+	}
+	return out, nil
+}

+ 37 - 0
internal/web/service/client_locks.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"encoding/json"
 	"sync"
 	"time"
 
@@ -139,3 +140,39 @@ func isClientEmailTombstoned(email string) bool {
 	}
 	return true
 }
+
+// stripTombstonedClients drops just-deleted client entries from a node
+// snapshot's settings JSON so adopting a stale snapshot can't re-add them to
+// the central inbound while the delete tombstone is live. Returns the filtered
+// JSON and whether anything was removed.
+func stripTombstonedClients(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, _ := parsed["clients"].([]any)
+	if len(clients) == 0 {
+		return settings, false
+	}
+	kept := make([]any, 0, len(clients))
+	for _, c := range clients {
+		if cm, ok := c.(map[string]any); ok {
+			if email, _ := cm["email"].(string); email != "" && isClientEmailTombstoned(email) {
+				continue
+			}
+		}
+		kept = append(kept, c)
+	}
+	if len(kept) == len(clients) {
+		return settings, false
+	}
+	parsed["clients"] = kept
+	b, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(b), true
+}

+ 2 - 2
internal/web/service/global_traffic_test.go

@@ -74,7 +74,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
 		t.Fatalf("empty globals must use the local-only predicate")
 	}
 	seedClientRow(t, "local-cap", 1, 600, 600, 1000)
-	if _, count, _, err := svc.disableInvalidClients(db); err != nil {
+	if _, count, err := svc.disableInvalidClients(db); err != nil {
 		t.Fatalf("disableInvalidClients: %v", err)
 	} else if count != 1 {
 		t.Fatalf("local over-quota client must be disabled, disabled %d", count)
@@ -100,7 +100,7 @@ func TestGlobalUsage_DisablesClient(t *testing.T) {
 		t.Fatalf("AcceptGlobalTraffic: %v", err)
 	}
 
-	if _, count, _, err := svc.disableInvalidClients(db); err != nil {
+	if _, count, err := svc.disableInvalidClients(db); err != nil {
 		t.Fatalf("disableInvalidClients: %v", err)
 	} else if count != 1 {
 		t.Fatalf("expected 1 client disabled, got %d", count)

+ 7 - 0
internal/web/service/inbound.go

@@ -409,6 +409,13 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
 	return clients, nil
 }
 
+// GetClientsBySubId returns the inbound's clients with the given subscription
+// id, resolved from the normalized clients tables (the same source the running
+// Xray users are built from) instead of parsing the settings JSON blob.
+func (s *InboundService) GetClientsBySubId(inboundId int, subId string) ([]model.Client, error) {
+	return s.clientService.ListForInboundBySubId(nil, inboundId, subId)
+}
+
 func (s *InboundService) GetAllEmails() ([]string, error) {
 	db := database.GetDB()
 	var emails []string

+ 8 - 0
internal/web/service/inbound_client_ips.go

@@ -79,6 +79,14 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
 	now := time.Now().Unix()
 	cutoff := now - clientIpStaleAfterSeconds
 
+	// Node syncs run concurrently (one goroutine per node) and shared clients
+	// appear in several nodes' reports. Locking rows in each node's arbitrary
+	// report order lets two merges grab the same rows in opposite order, which
+	// Postgres aborts as a deadlock (40P01) — take them in one global order.
+	sort.Slice(incomingIps, func(i, j int) bool {
+		return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
+	})
+
 	tx := db.Begin()
 	defer func() {
 		if r := recover(); r != nil {

+ 74 - 0
internal/web/service/inbound_client_traffic_test.go

@@ -157,3 +157,77 @@ func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T)
 		t.Errorf("inbound settings expiry not converted: %#v", cs)
 	}
 }
+
+// TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients locks in that the
+// delayed-start persistence pass touches only clients adjustTraffics actually
+// converted this poll: the delayed client's negative expiry becomes an absolute
+// deadline while an already-absolute expiry passes through byte-identical.
+// Before the fix every polled row got its own no-op expiry UPDATE.
+func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const delayedEmail = "delayed-mixed-user"
+	const normalEmail = "normal-mixed-user"
+	const delayedUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d002"
+	const normalUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d003"
+	const sevenDays = int64(7 * 86400000)
+	normalExpiry := time.Now().AddDate(0, 1, 0).UnixMilli()
+
+	clients := []model.Client{
+		{Email: delayedEmail, ID: delayedUID, Enable: true, ExpiryTime: -sevenDays},
+		{Email: normalEmail, ID: normalUID, Enable: true, ExpiryTime: normalExpiry},
+	}
+	inbound := &model.Inbound{
+		Tag: "vless-mixed", Enable: true, Port: 45002, Protocol: model.VLESS,
+		Settings: clientsSettings(t, clients),
+	}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := InboundService{}
+	if err := svc.clientService.SyncInbound(db, inbound.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: delayedEmail, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
+		t.Fatalf("create delayed traffic row: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: normalEmail, Enable: true, ExpiryTime: normalExpiry}).Error; err != nil {
+		t.Fatalf("create normal traffic row: %v", err)
+	}
+
+	before := time.Now().UnixMilli()
+	err := svc.addClientTraffic(db, []*xray.ClientTraffic{
+		{Email: delayedEmail, Up: 10, Down: 20},
+		{Email: normalEmail, Up: 30, Down: 40},
+	})
+	if err != nil {
+		t.Fatalf("addClientTraffic: %v", err)
+	}
+
+	var delayed xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", delayedEmail).First(&delayed).Error; err != nil {
+		t.Fatalf("reload delayed row: %v", err)
+	}
+	if delayed.ExpiryTime < before+sevenDays-5000 || delayed.ExpiryTime > before+sevenDays+5000 {
+		t.Errorf("delayed expiry = %d, want ~now+7d (%d)", delayed.ExpiryTime, before+sevenDays)
+	}
+
+	var normal xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", normalEmail).First(&normal).Error; err != nil {
+		t.Fatalf("reload normal row: %v", err)
+	}
+	if normal.ExpiryTime != normalExpiry {
+		t.Errorf("normal expiry changed: %d, want %d", normal.ExpiryTime, normalExpiry)
+	}
+	if normal.Up != 30 || normal.Down != 40 {
+		t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
+	}
+}

+ 27 - 24
internal/web/service/inbound_disable.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"slices"
 	"strings"
 	"time"
 
@@ -80,7 +81,7 @@ func depletedCond(tx *gorm.DB) string {
 	return depletedClientsCondLocal
 }
 
-func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
+func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
 	now := time.Now().Unix() * 1000
 	needRestart := false
 	cond := depletedCond(tx)
@@ -90,10 +91,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 		Where(cond+" AND enable = ?", now, true).
 		Find(&depletedRows).Error
 	if err != nil {
-		return false, 0, nil, err
+		return false, 0, err
 	}
 	if len(depletedRows) == 0 {
-		return false, 0, nil, nil
+		return false, 0, nil
 	}
 
 	depletedEmails := make([]string, 0, len(depletedRows))
@@ -121,7 +122,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 			WHERE clients.email IN ?
 		`, depletedEmails).Scan(&targets).Error
 		if err != nil {
-			return false, 0, nil, err
+			return false, 0, err
 		}
 	}
 
@@ -162,13 +163,23 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 		}
 	}
 
-	result := tx.Model(xray.ClientTraffic{}).
-		Where(cond+" AND enable = ?", now, true).
-		Update("enable", false)
-	err = result.Error
-	count := result.RowsAffected
-	if err != nil {
-		return needRestart, count, nil, err
+	// Flip the rows already collected above by primary key instead of
+	// re-evaluating the depleted predicate, which was a second full scan of
+	// client_traffics on every poll. Sorted ids keep the lock order stable.
+	ids := make([]int, 0, len(depletedRows))
+	for i := range depletedRows {
+		ids = append(ids, depletedRows[i].Id)
+	}
+	slices.Sort(ids)
+	var count int64
+	for _, batch := range chunkInts(ids, sqlInChunk) {
+		result := tx.Model(xray.ClientTraffic{}).
+			Where("id IN ? AND enable = ?", batch, true).
+			Update("enable", false)
+		if result.Error != nil {
+			return needRestart, count, result.Error
+		}
+		count += result.RowsAffected
 	}
 
 	if len(depletedEmails) > 0 {
@@ -179,7 +190,6 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 		}
 	}
 
-	disabledNodeIDs := make(map[int]struct{})
 	for inboundID, group := range remoteByInbound {
 		emails := make(map[string]struct{}, len(group))
 		for _, t := range group {
@@ -188,21 +198,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 		if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
 			logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
 			needRestart = true
-		} else {
-			for _, t := range group {
-				if t.NodeID != nil {
-					disabledNodeIDs[*t.NodeID] = struct{}{}
-				}
-			}
 		}
 	}
 
-	nodeIDs := make([]int, 0, len(disabledNodeIDs))
-	for nodeID := range disabledNodeIDs {
-		nodeIDs = append(nodeIDs, nodeID)
-	}
-
-	return needRestart, count, nodeIDs, nil
+	return needRestart, count, nil
 }
 
 // markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -255,6 +254,10 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
 	return &snapshot, &ib, nil
 }
 
+// disableRemoteClients flips the clients off in the inbound's stored settings
+// and pushes the updated inbound to its node, which applies it to its own
+// running Xray. That push is the whole reconcile — restarting the node's Xray
+// afterwards would drop every live connection on the node for nothing (#5740).
 func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
 	oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
 	if err != nil {

+ 20 - 25
internal/web/service/inbound_node.go

@@ -486,6 +486,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 		inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
 
+		// Adopting the node's settings verbatim would re-add a client the master
+		// deleted moments ago if this snapshot was fetched before the deletion
+		// push landed — filter just-deleted emails out while their tombstone lives.
+		adoptedSettings := snapIb.Settings
+		if stripped, changed := stripTombstonedClients(adoptedSettings); changed {
+			adoptedSettings = stripped
+		}
+
 		updates := map[string]any{}
 		if !dirty {
 			updates["enable"] = snapIb.Enable
@@ -496,7 +504,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["protocol"] = snapIb.Protocol
 			updates["total"] = snapIb.Total
 			updates["expiry_time"] = snapIb.ExpiryTime
-			updates["settings"] = snapIb.Settings
+			updates["settings"] = adoptedSettings
 			updates["stream_settings"] = snapIb.StreamSettings
 			updates["sniffing"] = snapIb.Sniffing
 			updates["traffic_reset"] = snapIb.TrafficReset
@@ -513,7 +521,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["origin_node_guid"] = og
 		}
 
-		if !dirty && (c.Settings != snapIb.Settings ||
+		if !dirty && (c.Settings != adoptedSettings ||
 			c.Remark != snapIb.Remark ||
 			c.Listen != snapIb.Listen ||
 			c.Port != snapIb.Port ||
@@ -634,8 +642,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				if dirty {
 					continue
 				}
+				_, isNewInbound := newInboundIDs[c.Id]
+				// On a known inbound a missing row plus a live tombstone means the
+				// master just deleted this client and the snapshot predates the
+				// deletion push — recreating the row (at zero) would resurrect the
+				// client. A freshly adopted inbound still gets its row (seeded at
+				// zero) so adoption semantics stay intact.
+				if !isNewInbound && isClientEmailTombstoned(cs.Email) {
+					continue
+				}
 				var seedUp, seedDown int64
-				if _, isNewInbound := newInboundIDs[c.Id]; isNewInbound && !isClientEmailTombstoned(cs.Email) {
+				if isNewInbound && !isClientEmailTombstoned(cs.Email) {
 					seedUp, seedDown = canon.Up, canon.Down
 				}
 				row := &xray.ClientTraffic{
@@ -895,28 +912,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	return structuralChange, nil
 }
 
-func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
-	restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
-	if err != nil {
-		logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
-		return
-	}
-	if !restartOnDisable {
-		return
-	}
-	for _, nodeID := range nodeIDs {
-		nodeIDCopy := nodeID
-		rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
-		if rtErr != nil {
-			logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
-			continue
-		}
-		if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
-			logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
-		}
-	}
-}
-
 func (s *InboundService) GetOnlineClients() []string {
 	if p == nil {
 		return []string{}

+ 12 - 4
internal/web/service/inbound_node_ips.go

@@ -64,6 +64,16 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
 		existingByEmail[existing[i].Email] = &existing[i]
 	}
 
+	// Deterministic row order keeps concurrent guid merges from deadlocking on
+	// Postgres (40P01) — same discipline as MergeInboundClientIps.
+	emails := make([]string, 0, len(perEmail))
+	for email := range perEmail {
+		if email != "" {
+			emails = append(emails, email)
+		}
+	}
+	sort.Strings(emails)
+
 	tx := db.Begin()
 	defer func() {
 		if r := recover(); r != nil {
@@ -71,10 +81,8 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
 		}
 	}()
 
-	for email, incoming := range perEmail {
-		if email == "" {
-			continue
-		}
+	for _, email := range emails {
+		incoming := perEmail[email]
 		var old []model.ClientIpEntry
 		if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
 			_ = json.Unmarshal([]byte(cur.Ips), &old)

+ 40 - 31
internal/web/service/inbound_traffic.go

@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"maps"
+	"slices"
 	"strings"
 	"time"
 
@@ -19,19 +21,15 @@ import (
 )
 
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
-	var disabledNodeIDs []int
 	err = submitTrafficWrite(func() error {
 		var inner error
-		needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
+		needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
 		return inner
 	})
-	if err == nil && len(disabledNodeIDs) > 0 {
-		s.restartRemoteNodesOnDisable(disabledNodeIDs)
-	}
 	return
 }
 
-func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
+func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
 	var err error
 	db := database.GetDB()
 	tx := db.Begin()
@@ -45,11 +43,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	}()
 	err = s.addInboundTraffic(tx, inboundTraffics)
 	if err != nil {
-		return false, false, nil, err
+		return false, false, err
 	}
 	err = s.addClientTraffic(tx, clientTraffics)
 	if err != nil {
-		return false, false, nil, err
+		return false, false, err
 	}
 
 	needRestart0, count, err := s.autoRenewClients(tx)
@@ -60,7 +58,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	}
 
 	disabledClientsCount := int64(0)
-	needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
+	needRestart1, count, err := s.disableInvalidClients(tx)
 	if err != nil {
 		logger.Warning("Error in disabling invalid clients:", err)
 	} else if count > 0 {
@@ -74,7 +72,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	} else if count > 0 {
 		logger.Debugf("%v inbounds disabled", count)
 	}
-	return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
+	return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
 }
 
 func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -129,7 +127,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		return nil
 	}
 
-	dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics)
+	dbClientTraffics, convertedExpiryByEmail, err := s.adjustTraffics(tx, dbClientTraffics)
 	if err != nil {
 		return err
 	}
@@ -165,22 +163,22 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 
 	// adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute
 	// deadline) in-memory. Persist that conversion now since the traffic UPDATE
-	// above only touches up/down/last_online.
-	for _, ct := range dbClientTraffics {
-		if ct.ExpiryTime > 0 {
-			if err = tx.Exec(
-				`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
-				ct.ExpiryTime, ct.Email,
-			).Error; err != nil {
-				logger.Warning("AddClientTraffic update expiry_time ", err)
-			}
+	// above only touches up/down/last_online. Only converted emails are written:
+	// updating every polled row issued one no-op UPDATE per active client per
+	// poll. Sorted order keeps concurrent writers lock-compatible on Postgres.
+	for _, email := range slices.Sorted(maps.Keys(convertedExpiryByEmail)) {
+		if err = tx.Exec(
+			`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
+			convertedExpiryByEmail[email], email,
+		).Error; err != nil {
+			logger.Warning("AddClientTraffic update expiry_time ", err)
 		}
 	}
 
 	return nil
 }
 
-func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
+func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
 	now := time.Now().UnixMilli()
 
 	// "Start After First Use" stores a negative expiry (the duration). On the
@@ -194,7 +192,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 		}
 	}
 	if len(newExpiryByEmail) == 0 {
-		return dbClientTraffics, nil
+		return dbClientTraffics, nil, nil
 	}
 
 	delayedEmails := make([]string, 0, len(newExpiryByEmail))
@@ -212,16 +210,16 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 		Distinct().
 		Pluck("client_inbounds.inbound_id", &inboundIds).Error
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	if len(inboundIds) == 0 {
-		return dbClientTraffics, nil
+		return dbClientTraffics, nil, nil
 	}
 
 	var inbounds []*model.Inbound
 	err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
@@ -247,7 +245,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 			settings["clients"] = newClients
 			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 
 			inbounds[inbound_index].Settings = string(modifiedSettings)
@@ -280,7 +278,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 		}
 	}
 
-	return dbClientTraffics, nil
+	return dbClientTraffics, newExpiryByEmail, nil
 }
 
 func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
@@ -930,14 +928,18 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
 		}
 		traffics = append(traffics, page...)
 	}
+	overlayGlobalTraffic(db, traffics)
 	return traffics, nil
 }
 
 // GetAllClientTraffics returns the full set of client_traffics rows so the
-// websocket broadcasters can ship a complete snapshot every cycle. The old
-// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
-// the per-client section whenever no client moved bytes in the cycle or a
-// node sync failed, leaving client rows in the UI stuck at stale numbers.
+// websocket broadcasters can ship a complete snapshot every cycle. A pure
+// delta path silently dropped the per-client section whenever no client moved
+// bytes in the cycle or a node sync failed, leaving client rows in the UI
+// stuck at stale numbers — so small installs broadcast this snapshot, and only
+// above the traffic job's snapshot threshold (where the marshaled snapshot
+// would exceed the hub's payload cap and be dropped wholesale) does the job
+// fall back to active-row deltas.
 func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
 	db := database.GetDB()
 	var traffics []*xray.ClientTraffic
@@ -948,6 +950,13 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
 	return traffics, nil
 }
 
+func (s *InboundService) CountClientTraffics() (int64, error) {
+	db := database.GetDB()
+	var count int64
+	err := db.Model(xray.ClientTraffic{}).Count(&count).Error
+	return count, err
+}
+
 type InboundTrafficSummary struct {
 	Id     int   `json:"id"`
 	Up     int64 `json:"up"`

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

@@ -3,6 +3,7 @@ package service
 import (
 	"fmt"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"gorm.io/gorm"
@@ -135,6 +136,47 @@ func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count")
 }
 
+// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot
+// fetched just before a client's deletion still names the email. Applying it
+// must neither recreate the client_traffics row (at zero) nor re-add the
+// client to the central inbound's settings while the delete tombstone lives.
+func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739")
+	svc := &InboundService{}
+
+	const email = "victim-5739"
+	withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+
+	if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in").
+		Update("settings", `{"clients": []}`).Error; err != nil {
+		t.Fatalf("clear central settings: %v", err)
+	}
+	if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+		t.Fatalf("delete stats row: %v", err)
+	}
+	tombstoneClientEmail(email)
+
+	syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true})
+
+	var rows int64
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil {
+		t.Fatalf("count stats rows: %v", err)
+	}
+	if rows != 0 {
+		t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows)
+	}
+
+	var ib model.Inbound
+	if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
+		t.Fatalf("load inbound: %v", err)
+	}
+	if strings.Contains(ib.Settings, email) {
+		t.Errorf("deleted client re-added to central settings: %s", ib.Settings)
+	}
+}
+
 func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}

+ 170 - 11
internal/web/service/panel/panel.go

@@ -13,6 +13,7 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 	"syscall"
 	"time"
 
@@ -44,10 +45,64 @@ const (
 	// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
 	// newest main commit; the dev update channel installs from it.
 	devReleaseTag = "dev-latest"
+
+	updateStatePending = "pending"
+	updateStateSuccess = "success"
+	updateStateFailed  = "failed"
 )
 
+// PanelUpdateStatus reports the outcome of the most recently launched panel
+// self-update. RunID lets the caller confirm this status belongs to the
+// update it started rather than a stale result left over from an earlier
+// run; State is one of "pending", "success", or "failed". RunID is a decimal
+// string, not a JSON number: it's a formatted UnixNano timestamp, and
+// JavaScript's number type can't represent that precisely (it exceeds
+// Number.MAX_SAFE_INTEGER), which would let two different runs round to the
+// same value on the wire and defeat the whole point of this field.
+type PanelUpdateStatus struct {
+	RunID      string `json:"runId" example:"1735689600123456789"`
+	State      string `json:"state" example:"success"`
+	ExitCode   int    `json:"exitCode" example:"0"`
+	FinishedAt int64  `json:"finishedAt" example:"1735689612"`
+}
+
 var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
 
+// updateMu guards updateRunning/updateStarted/updateRunID/updatePID, which
+// stop a second self-update from launching while one is still in flight (two
+// concurrent update.sh runs would race each other extracting the release
+// tarball and swapping the service unit). A slot is released as soon as the
+// in-flight run's own status file reports success or failure -- checked
+// against updateRunID so a stale file from an even earlier run can't be
+// mistaken for this one finishing -- so a fast failure doesn't lock out a
+// retry.
+//
+// For a run that never reaches a terminal state at all, staleness is judged
+// primarily by whether the process we actually launched is still alive
+// (updatePID, via processAlive), not by wall-clock time alone: update.sh
+// runs install_base() (a package-manager update+install) before anything
+// else, plus several downloads, which can legitimately run past a short
+// fixed timeout on a slow or throttled host without anything being wrong.
+// updateStaleAfter/updatePID together are only a fallback for the systemd-run
+// launch path, where the process we can observe (systemd-run itself) has
+// already exited by the time startUpdate returns and the actual update.sh
+// unit's PID is never recorded -- for that path this is still a pure
+// wall-clock heuristic. updateHardCeiling is an absolute backstop so a
+// genuinely wedged run (alive but hung forever) can never lock out retries
+// permanently, even on the PID-tracked path.
+var (
+	updateMu      sync.Mutex
+	updateRunning bool
+	updateStarted time.Time
+	updateRunID   int64
+	updatePID     int
+)
+
+const (
+	updateStaleAfter  = 20 * time.Minute
+	updateHardCeiling = 2 * time.Hour
+)
+
 func (s *PanelService) RestartPanel(delay time.Duration) error {
 	go func() {
 		time.Sleep(delay)
@@ -122,39 +177,77 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) {
 	}, nil
 }
 
-// StartUpdate starts the official updater using this panel's own channel setting.
-func (s *PanelService) StartUpdate() error {
+// StartUpdate starts the official updater using this panel's own channel
+// setting. Returns the run ID to pass to GetUpdateStatus so the caller can
+// tell this run's result apart from a stale one.
+func (s *PanelService) StartUpdate() (int64, error) {
 	return s.startUpdate(devChannelActive())
 }
 
 // StartUpdateChannel runs the updater against an explicitly chosen channel,
 // overriding the local dev-channel setting. Used by the master node updater so
 // a node can be moved to the dev channel from the central panel.
-func (s *PanelService) StartUpdateChannel(dev bool) error {
+func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) {
 	return s.startUpdate(dev)
 }
 
-func (s *PanelService) startUpdate(useDev bool) error {
+// GetUpdateStatus reports the outcome of the most recently launched panel
+// self-update, as recorded by update.sh's EXIT trap (see the script for why
+// that covers every exit path, not just the happy one). This is a best-effort
+// side channel: a missing or unreadable status file reads as "pending"
+// rather than an error, since the update itself is what matters, not this
+// status file.
+func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
+	data, err := os.ReadFile(config.GetUpdateStatusFilePath())
+	if err != nil {
+		return &PanelUpdateStatus{State: updateStatePending}
+	}
+	var status PanelUpdateStatus
+	if err := json.Unmarshal(data, &status); err != nil {
+		return &PanelUpdateStatus{State: updateStatePending}
+	}
+	if status.State != updateStateSuccess && status.State != updateStateFailed {
+		status.State = updateStatePending
+	}
+	return &status
+}
+
+func (s *PanelService) startUpdate(useDev bool) (int64, error) {
+	runID := time.Now().UnixNano()
+	if !acquireUpdateSlot(runID) {
+		return 0, fmt.Errorf("a panel update is already in progress")
+	}
+	launched := false
+	defer func() {
+		if !launched {
+			releaseUpdateSlot()
+		}
+	}()
+
 	if runtime.GOOS != "linux" {
-		return fmt.Errorf("panel web update is supported only on Linux installations")
+		return 0, fmt.Errorf("panel web update is supported only on Linux installations")
 	}
 
 	bash, err := exec.LookPath("bash")
 	if err != nil {
-		return fmt.Errorf("bash is required to run the panel updater: %w", err)
+		return 0, fmt.Errorf("bash is required to run the panel updater: %w", err)
 	}
 
 	scriptPath, err := downloadPanelUpdater()
 	if err != nil {
-		return err
+		return 0, err
 	}
 
+	statusFile := config.GetUpdateStatusFilePath()
+
 	mainFolder, serviceFolder := resolveUpdateFolders()
 	updateTag := ""
 	if useDev {
 		updateTag = devReleaseTag
 	}
 	updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
+	runIDEnv := "XUI_UPDATE_RUN_ID=" + strconv.FormatInt(runID, 10)
+	statusFileEnv := "XUI_UPDATE_STATUS_FILE=" + statusFile
 
 	if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
 		unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
@@ -163,6 +256,8 @@ func (s *PanelService) startUpdate(useDev bool) error {
 			"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
 			"--setenv", "XUI_SERVICE="+serviceFolder,
 			"--setenv", "XUI_UPDATE_TAG="+updateTag,
+			"--setenv", runIDEnv,
+			"--setenv", statusFileEnv,
 			bash, "-lc", updateScript,
 		)
 		out, err := cmd.CombinedOutput()
@@ -171,12 +266,13 @@ func (s *PanelService) startUpdate(useDev bool) error {
 			if !strings.Contains(output, "System has not been booted with systemd") &&
 				!strings.Contains(output, "Failed to connect to bus") {
 				_ = os.Remove(scriptPath)
-				return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
+				return 0, fmt.Errorf("failed to start panel update job: %w: %s", err, output)
 			}
 			logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
 		} else {
 			logger.Infof("started panel update job via systemd-run unit %s", unitName)
-			return nil
+			launched = true
+			return runID, nil
 		}
 	}
 
@@ -185,17 +281,77 @@ func (s *PanelService) startUpdate(useDev bool) error {
 		"XUI_MAIN_FOLDER="+mainFolder,
 		"XUI_SERVICE="+serviceFolder,
 		"XUI_UPDATE_TAG="+updateTag,
+		runIDEnv,
+		statusFileEnv,
 	)
 	setDetachedProcess(cmd)
 	if err := cmd.Start(); err != nil {
 		_ = os.Remove(scriptPath)
-		return fmt.Errorf("failed to start panel update job: %w", err)
+		return 0, fmt.Errorf("failed to start panel update job: %w", err)
 	}
 	if err := cmd.Process.Release(); err != nil {
 		logger.Warning("failed to release panel update process:", err)
 	}
 	logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
-	return nil
+	recordUpdatePID(cmd.Process.Pid)
+	launched = true
+	return runID, nil
+}
+
+// acquireUpdateSlot claims the single in-flight-update slot for runID. It
+// refuses while another run is genuinely still in flight, but grants the
+// slot immediately once that run's own status file reports a terminal
+// result (success or failure) -- a fast failure shouldn't force the next
+// attempt to wait out updateStaleAfter for no reason. Past updateStaleAfter
+// with no terminal status yet, it grants the slot anyway UNLESS the process
+// we recorded (updatePID) is confirmed still alive, so a merely-slow run
+// isn't mistaken for a crashed one; past updateHardCeiling it grants the
+// slot unconditionally regardless of liveness, so a truly wedged run can
+// never lock out retries forever.
+func acquireUpdateSlot(runID int64) bool {
+	updateMu.Lock()
+	defer updateMu.Unlock()
+	if updateRunning && !previousRunIsTerminal() {
+		elapsed := time.Since(updateStarted)
+		if elapsed < updateHardCeiling {
+			stale := elapsed >= updateStaleAfter
+			alive := updatePID > 0 && processAlive(updatePID)
+			if !stale || alive {
+				return false
+			}
+		}
+	}
+	updateRunning = true
+	updateStarted = time.Now()
+	updateRunID = runID
+	updatePID = 0
+	return true
+}
+
+// recordUpdatePID notes the PID of the detached update.sh process the
+// current slot is tracking, so a later acquireUpdateSlot call can check
+// whether it is actually still running instead of only how long ago it
+// started. Only reachable for the detached-fallback launch path -- the
+// systemd-run path never learns update.sh's own PID, since the process it
+// directly observes (systemd-run) has already exited by the time it returns.
+func recordUpdatePID(pid int) {
+	updateMu.Lock()
+	updatePID = pid
+	updateMu.Unlock()
+}
+
+// previousRunIsTerminal reports whether the run currently recorded in
+// updateRunID has reached success or failure per its status file. Must be
+// called with updateMu held.
+func previousRunIsTerminal() bool {
+	status := (&PanelService{}).GetUpdateStatus()
+	return status.RunID == strconv.FormatInt(updateRunID, 10) && status.State != updateStatePending
+}
+
+func releaseUpdateSlot() {
+	updateMu.Lock()
+	updateRunning = false
+	updateMu.Unlock()
 }
 
 func downloadPanelUpdater() (string, error) {
@@ -230,6 +386,9 @@ func downloadPanelUpdater() (string, error) {
 	if err != nil {
 		return "", fmt.Errorf("write panel updater: %w", err)
 	}
+	if n == 0 {
+		return "", fmt.Errorf("panel updater download is empty")
+	}
 	if n > maxPanelUpdaterBytes {
 		return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
 	}

+ 7 - 0
internal/web/service/panel/panel_other.go

@@ -5,3 +5,10 @@ package panel
 import "os/exec"
 
 func setDetachedProcess(cmd *exec.Cmd) {}
+
+// processAlive is never meaningfully consulted outside Linux: startUpdate
+// itself is gated to runtime.GOOS == "linux" before any process is ever
+// launched, so no real PID is ever recorded on this platform.
+func processAlive(pid int) bool {
+	return false
+}

+ 211 - 0
internal/web/service/panel/panel_test.go

@@ -1,8 +1,15 @@
 package panel
 
 import (
+	"fmt"
+	"os"
+	"runtime"
+	"sync"
+	"sync/atomic"
 	"testing"
+	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
@@ -107,3 +114,207 @@ func TestShortCommit(t *testing.T) {
 		t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
 	}
 }
+
+func resetUpdateSlot(t *testing.T) {
+	t.Helper()
+	t.Cleanup(func() {
+		updateMu.Lock()
+		updateRunning = false
+		updateRunID = 0
+		updatePID = 0
+		updateMu.Unlock()
+	})
+}
+
+// writeStatusFile hand-writes the status file in the exact wire format
+// update.sh itself produces (a bare printf, not Go's json.Marshal), since
+// that's the real cross-language contract this package reads in production.
+func writeStatusFile(t *testing.T, path string, runID int64, state string) {
+	t.Helper()
+	body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
+	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestAcquireUpdateSlot(t *testing.T) {
+	resetUpdateSlot(t)
+
+	if !acquireUpdateSlot(1) {
+		t.Fatal("first acquire: got false, want true")
+	}
+	if acquireUpdateSlot(2) {
+		t.Fatal("second acquire while first is held: got true, want false")
+	}
+	releaseUpdateSlot()
+	if !acquireUpdateSlot(3) {
+		t.Fatal("acquire after release: got false, want true")
+	}
+	releaseUpdateSlot()
+}
+
+func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
+	resetUpdateSlot(t)
+
+	if !acquireUpdateSlot(1) {
+		t.Fatal("first acquire: got false, want true")
+	}
+	updateMu.Lock()
+	updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
+	updateMu.Unlock()
+
+	if !acquireUpdateSlot(2) {
+		t.Fatal("acquire after stale window elapsed: got false, want true")
+	}
+	releaseUpdateSlot()
+}
+
+// TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
+// test for the concurrency bug an upstream review found: past
+// updateStaleAfter, the old logic freed the slot purely on elapsed time, even
+// if the process it launched was still genuinely running (not crashed) --
+// update.sh's own package-manager step plus several downloads can plausibly
+// run long on a slow host with nothing actually wrong. Now a confirmed-alive
+// PID keeps the slot held past the stale window.
+func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
+	}
+	resetUpdateSlot(t)
+
+	if !acquireUpdateSlot(1) {
+		t.Fatal("first acquire: got false, want true")
+	}
+	recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
+	updateMu.Lock()
+	updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
+	updateMu.Unlock()
+
+	if acquireUpdateSlot(2) {
+		t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
+	}
+	releaseUpdateSlot()
+}
+
+// TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
+// backstop: even a confirmed-alive process can't hold the slot forever, so a
+// genuinely wedged run can't lock out retries permanently.
+func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
+	if runtime.GOOS != "linux" {
+		t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
+	}
+	resetUpdateSlot(t)
+
+	if !acquireUpdateSlot(1) {
+		t.Fatal("first acquire: got false, want true")
+	}
+	recordUpdatePID(os.Getpid())
+	updateMu.Lock()
+	updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
+	updateMu.Unlock()
+
+	if !acquireUpdateSlot(2) {
+		t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
+	}
+	releaseUpdateSlot()
+}
+
+// TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
+// bug adversarial review found: a fast failure used to still lock out retries
+// for the full updateStaleAfter window, because acquireUpdateSlot only looked
+// at the in-memory started-at timestamp, never at the status file's own
+// terminal state.
+func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	resetUpdateSlot(t)
+	path := config.GetUpdateStatusFilePath()
+
+	if !acquireUpdateSlot(111) {
+		t.Fatal("first acquire: got false, want true")
+	}
+	writeStatusFile(t, path, 111, updateStateFailed)
+
+	if !acquireUpdateSlot(222) {
+		t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
+	}
+	releaseUpdateSlot()
+}
+
+// TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
+// check is scoped to the run it actually launched: a status file left behind
+// by some earlier, unrelated run (different runID) must not be mistaken for
+// this run finishing.
+func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	resetUpdateSlot(t)
+	path := config.GetUpdateStatusFilePath()
+
+	writeStatusFile(t, path, 999, updateStateSuccess)
+	if !acquireUpdateSlot(111) {
+		t.Fatal("first acquire: got false, want true")
+	}
+
+	if acquireUpdateSlot(222) {
+		t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
+	}
+	releaseUpdateSlot()
+}
+
+// TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
+// atomic under real concurrent access, not just correct when called
+// sequentially. A prior version of this test suite only ever called
+// acquireUpdateSlot from a single goroutine, so it gave no signal if the
+// mutex's core promise (only one concurrent launch wins) were broken.
+func TestAcquireUpdateSlotConcurrency(t *testing.T) {
+	resetUpdateSlot(t)
+
+	const attempts = 200
+	var wins atomic.Int32
+	var wg sync.WaitGroup
+	wg.Add(attempts)
+	for i := range attempts {
+		go func(runID int64) {
+			defer wg.Done()
+			if acquireUpdateSlot(runID) {
+				wins.Add(1)
+			}
+		}(int64(i))
+	}
+	wg.Wait()
+
+	if got := wins.Load(); got != 1 {
+		t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
+	}
+	releaseUpdateSlot()
+}
+
+func TestGetUpdateStatus(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	path := config.GetUpdateStatusFilePath()
+	svc := &PanelService{}
+
+	if got := svc.GetUpdateStatus(); got.State != updateStatePending {
+		t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
+	}
+
+	writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
+	got := svc.GetUpdateStatus()
+	if got.RunID != "1735689600123456789" {
+		t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789")
+	}
+	if got.State != updateStateSuccess {
+		t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
+	}
+
+	if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if got := svc.GetUpdateStatus(); got.State != updateStatePending {
+		t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
+	}
+
+	writeStatusFile(t, path, 1, "some-unrecognized-state")
+	if got := svc.GetUpdateStatus(); got.State != updateStatePending {
+		t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
+	}
+}

+ 14 - 0
internal/web/service/panel/panel_unix.go

@@ -10,3 +10,17 @@ import (
 func setDetachedProcess(cmd *exec.Cmd) {
 	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
 }
+
+// processAlive reports whether pid is still a live process, via the standard
+// POSIX kill(pid, 0) liveness check: it sends no actual signal, only checking
+// whether the target exists and is signalable. ESRCH means the process is
+// gone; any other result (including a permission error, which can only mean
+// the PID exists and belongs to someone) is treated as alive, since this is
+// used to decide whether it is safe to let a second update start.
+func processAlive(pid int) bool {
+	if pid <= 0 {
+		return false
+	}
+	err := syscall.Kill(pid, 0)
+	return err == nil || err == syscall.EPERM
+}

+ 150 - 3
internal/web/service/scale_helpers_test.go

@@ -1,22 +1,28 @@
 package service
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/op/go-logging"
 	"gorm.io/gorm"
 )
 
 // setupScaleDB initializes the DB for a scale benchmark on either Postgres
-// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file),
-// and registers cleanup. Skips the test when neither backend is configured.
+// (XUI_DB_TYPE=postgres + XUI_DB_DSN) or SQLite (XUI_SCALE_TEST=1, temp file;
+// XUI_SCALE_DB_PATH persists the DB for manual smoke runs), and registers
+// cleanup. Skips the test when neither backend is configured.
 func setupScaleDB(t *testing.T) {
 	t.Helper()
 	xuilogger.InitLogger(logging.ERROR)
@@ -31,7 +37,10 @@ func setupScaleDB(t *testing.T) {
 
 	switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
 	case "1", "true", "yes":
-		dbPath := filepath.Join(t.TempDir(), "scale.db")
+		dbPath := strings.TrimSpace(os.Getenv("XUI_SCALE_DB_PATH"))
+		if dbPath == "" {
+			dbPath = filepath.Join(t.TempDir(), "scale.db")
+		}
 		if err := database.InitDB(dbPath); err != nil {
 			t.Fatalf("InitDB(sqlite): %v", err)
 		}
@@ -42,6 +51,144 @@ func setupScaleDB(t *testing.T) {
 	t.Skip("set XUI_SCALE_TEST=1 (sqlite) or XUI_DB_TYPE=postgres + XUI_DB_DSN (postgres) to run the scale benchmark")
 }
 
+// scaleSizes returns the default size ladder unless XUI_SCALE_SIZES overrides
+// it with a comma-separated list (e.g. "500000" or "10000,100000,500000").
+func scaleSizes(t *testing.T, def ...int) []int {
+	t.Helper()
+	raw := strings.TrimSpace(os.Getenv("XUI_SCALE_SIZES"))
+	if raw == "" {
+		return def
+	}
+	var out []int
+	for _, part := range strings.Split(raw, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		n, err := strconv.Atoi(part)
+		if err != nil || n <= 0 {
+			t.Fatalf("XUI_SCALE_SIZES: invalid size %q", part)
+		}
+		out = append(out, n)
+	}
+	if len(out) == 0 {
+		return def
+	}
+	return out
+}
+
+type scaleDataset struct {
+	inboundIds []int
+	tags       []string
+	emails     []string
+	perInbound [][]model.Client
+}
+
+// seedScaleDataset seeds n healthy clients (future expiry, unfilled quota)
+// spread across numInbounds inbounds, writing inbounds, clients,
+// client_inbounds and client_traffics directly in one transaction — orders of
+// magnitude faster than SyncInbound and one fsync instead of thousands.
+func seedScaleDataset(t *testing.T, n, numInbounds int) scaleDataset {
+	t.Helper()
+	db := database.GetDB()
+	resetScaleTables(t, db, "inbounds", "clients", "client_inbounds", "client_traffics")
+
+	clients := makeScaleClients(n)
+	exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+	for i := range clients {
+		clients[i].ExpiryTime = exp
+		clients[i].TotalGB = 100 << 30
+	}
+
+	ds := scaleDataset{emails: emailsOf(clients)}
+	start := time.Now()
+	tx := db.Begin()
+	if tx.Error != nil {
+		t.Fatalf("begin seed tx: %v", tx.Error)
+	}
+	committed := false
+	defer func() {
+		if !committed {
+			tx.Rollback()
+		}
+	}()
+
+	per := n / numInbounds
+	for i := range numInbounds {
+		lo, hi := i*per, (i+1)*per
+		if i == numInbounds-1 {
+			hi = n
+		}
+		chunk := clients[lo:hi]
+		ib := &model.Inbound{
+			UserId:   1,
+			Tag:      fmt.Sprintf("scale-%d-%d", n, i),
+			Enable:   true,
+			Port:     41000 + i,
+			Protocol: model.VLESS,
+			Settings: clientsSettings(t, chunk),
+		}
+		if err := tx.Create(ib).Error; err != nil {
+			t.Fatalf("seed inbound %d: %v", i, err)
+		}
+
+		records := make([]*model.ClientRecord, len(chunk))
+		for j := range chunk {
+			records[j] = chunk[j].ToRecord()
+		}
+		if err := tx.CreateInBatches(records, 500).Error; err != nil {
+			t.Fatalf("seed clients %d: %v", i, err)
+		}
+
+		links := make([]model.ClientInbound, len(records))
+		for j := range records {
+			links[j] = model.ClientInbound{ClientId: records[j].Id, InboundId: ib.Id}
+		}
+		if err := tx.CreateInBatches(links, 1000).Error; err != nil {
+			t.Fatalf("seed client_inbounds %d: %v", i, err)
+		}
+
+		traffics := make([]xray.ClientTraffic, len(chunk))
+		for j := range chunk {
+			traffics[j] = xray.ClientTraffic{
+				InboundId:  ib.Id,
+				Email:      chunk[j].Email,
+				Enable:     true,
+				Total:      chunk[j].TotalGB,
+				ExpiryTime: chunk[j].ExpiryTime,
+			}
+		}
+		if err := tx.CreateInBatches(traffics, 1000).Error; err != nil {
+			t.Fatalf("seed client_traffics %d: %v", i, err)
+		}
+
+		ds.inboundIds = append(ds.inboundIds, ib.Id)
+		ds.tags = append(ds.tags, ib.Tag)
+		ds.perInbound = append(ds.perInbound, chunk)
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		t.Fatalf("commit seed tx: %v", err)
+	}
+	committed = true
+	db.Exec("ANALYZE")
+	t.Logf("seeded N=%d across %d inbound(s) in %v", n, numInbounds, time.Since(start).Round(time.Millisecond))
+	return ds
+}
+
+// sampleEmails picks k evenly spaced emails so active clients span the id range.
+func sampleEmails(emails []string, k int) []string {
+	if k >= len(emails) {
+		return emails
+	}
+	out := make([]string, 0, k)
+	step := len(emails) / k
+	for i := 0; i < len(emails) && len(out) < k; i += step {
+		out = append(out, emails[i])
+	}
+	return out
+}
+
 // resetScaleTables empties the given tables between sub-sizes. Postgres uses a
 // single TRUNCATE ... CASCADE; SQLite deletes per table and clears the
 // autoincrement counters so ids restart like RESTART IDENTITY.

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

@@ -710,11 +710,13 @@ func (s *SettingService) GetSubTitle() (string, error) {
 }
 
 func (s *SettingService) GetSubSupportUrl() (string, error) {
-	return s.getString("subSupportUrl")
+	value, err := s.getString("subSupportUrl")
+	return common.EnsureURLScheme(value), err
 }
 
 func (s *SettingService) GetSubProfileUrl() (string, error) {
-	return s.getString("subProfileUrl")
+	value, err := s.getString("subProfileUrl")
+	return common.EnsureURLScheme(value), err
 }
 
 func (s *SettingService) GetSubAnnounce() (string, error) {
@@ -1083,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
 	return s.setInt("smtpMemory", value)
 }
 
-func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
-	if err := s.preserveRedactedSecrets(allSetting); err != nil {
+// SecretClears marks redacted secrets the user explicitly emptied. Without a
+// flag, a blank submitted secret means "unchanged" (the field is always served
+// blank to the browser) and the stored value is preserved.
+type SecretClears struct {
+	TgBotToken   bool
+	LdapPassword bool
+	SmtpPassword bool
+}
+
+func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
+	if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
 		return err
 	}
 	if err := validateSettingsURLs(allSetting); err != nil {
@@ -1130,15 +1141,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
 	})
 }
 
-func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
-	if strings.TrimSpace(allSetting.TgBotToken) == "" {
+func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
+	if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
 		value, err := s.GetTgBotToken()
 		if err != nil {
 			return err
 		}
 		allSetting.TgBotToken = value
 	}
-	if strings.TrimSpace(allSetting.LdapPassword) == "" {
+	if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
 		value, err := s.GetLdapPassword()
 		if err != nil {
 			return err
@@ -1152,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
 		}
 		allSetting.TwoFactorToken = value
 	}
-	if strings.TrimSpace(allSetting.SmtpPassword) == "" {
+	if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
 		value, err := s.GetSmtpPassword()
 		if err != nil {
 			return err
@@ -1177,6 +1188,12 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 		}
 		allSetting.TgBotAPIServer = u
 	}
+	// Support/profile links land in subscription headers and page data, where
+	// client apps resolve a scheme-less value against the panel's own domain.
+	// Non-http schemes (tg://, mailto:) are legitimate here, so only default
+	// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
+	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
+	allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
 	return nil
 }
 

+ 49 - 1
internal/web/service/setting_security_test.go

@@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
 		t.Fatal(err)
 	}
 	settings := &view.AllSetting
-	if err := s.UpdateAllSetting(settings); err != nil {
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
 		t.Fatal(err)
 	}
 	if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
@@ -94,6 +94,54 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
 	}
 }
 
+func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
+		t.Fatal(err)
+	}
+
+	view, err := s.GetAllSettingView()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
+		t.Fatal(err)
+	}
+	if got, _ := s.GetSmtpPassword(); got != "" {
+		t.Fatalf("smtp password = %q, want cleared", got)
+	}
+	if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
+		t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
+	}
+	if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
+		t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
+	}
+
+	view, err = s.GetAllSettingView()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if view.HasSmtpPassword {
+		t.Fatal("hasSmtpPassword must report false after clearing")
+	}
+	if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
+		t.Fatal(err)
+	}
+	if got, _ := s.GetTgBotToken(); got != "" {
+		t.Fatalf("tg token = %q, want cleared", got)
+	}
+	if got, _ := s.GetLdapPassword(); got != "" {
+		t.Fatalf("ldap password = %q, want cleared", got)
+	}
+}
+
 func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
 	if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
 		t.Fatal("expected localhost URL to be blocked")

+ 106 - 0
internal/web/service/traffic_poll_scale_test.go

@@ -0,0 +1,106 @@
+package service
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func pollReport(ds scaleDataset, k int) ([]*xray.Traffic, []*xray.ClientTraffic) {
+	traffics := make([]*xray.Traffic, 0, len(ds.tags))
+	for _, tag := range ds.tags {
+		traffics = append(traffics, &xray.Traffic{IsInbound: true, Tag: tag, Up: 1 << 20, Down: 2 << 20})
+	}
+	clientTraffics := make([]*xray.ClientTraffic, 0, k)
+	for _, email := range sampleEmails(ds.emails, k) {
+		clientTraffics = append(clientTraffics, &xray.ClientTraffic{Email: email, Up: 1 << 20, Down: 2 << 20})
+	}
+	return traffics, clientTraffics
+}
+
+// TestAddTrafficPollScale measures one full traffic-poll cycle (the @every 5s
+// job): per-client delta UPDATEs, the auto-renew probe and the depleted-client
+// scan, in steady state and with 100 clients depleting / renewing.
+func TestAddTrafficPollScale(t *testing.T) {
+	setupScaleDB(t)
+	svc := &InboundService{}
+	shapes := []struct {
+		name     string
+		inbounds int
+	}{{"single", 1}, {"spread50", 50}}
+
+	for _, n := range scaleSizes(t, 10000, 100000) {
+		for _, shape := range shapes {
+			t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
+				db := database.GetDB()
+				ds := seedScaleDataset(t, n, shape.inbounds)
+
+				for _, k := range []int{1000, 10000} {
+					if k > n {
+						continue
+					}
+					traffics, clientTraffics := pollReport(ds, k)
+					const reps = 3
+					start := time.Now()
+					for range reps {
+						if _, _, err := svc.AddTraffic(traffics, clientTraffics); err != nil {
+							t.Fatalf("AddTraffic steady: %v", err)
+						}
+					}
+					perPoll := time.Since(start) / reps
+					t.Logf("N=%-7d shape=%-8s K=%-6d steady=%v/poll", n, shape.name, k, perPoll.Round(time.Millisecond))
+
+					var probe xray.ClientTraffic
+					if err := db.Where("email = ?", clientTraffics[0].Email).First(&probe).Error; err != nil {
+						t.Fatalf("load probe row: %v", err)
+					}
+					if probe.Up == 0 || probe.Down == 0 {
+						t.Fatalf("steady polls did not accumulate traffic: up=%d down=%d", probe.Up, probe.Down)
+					}
+				}
+
+				depleted := ds.perInbound[0][:100]
+				if err := db.Model(&xray.ClientTraffic{}).
+					Where("email IN ?", emailsOf(depleted)).
+					Updates(map[string]any{"up": int64(100 << 30), "down": int64(0)}).Error; err != nil {
+					t.Fatalf("mark depleted: %v", err)
+				}
+				start := time.Now()
+				if _, _, err := svc.AddTraffic(nil, nil); err != nil {
+					t.Fatalf("AddTraffic disable: %v", err)
+				}
+				t.Logf("N=%-7d shape=%-8s disable100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
+				var disabledCount int64
+				if err := db.Model(&xray.ClientTraffic{}).Where("enable = ?", false).Count(&disabledCount).Error; err != nil {
+					t.Fatalf("count disabled: %v", err)
+				}
+				if disabledCount != 100 {
+					t.Fatalf("disable100: got %d disabled rows, want 100", disabledCount)
+				}
+
+				renew := ds.perInbound[0][100:200]
+				past := time.Now().Add(-time.Hour).UnixMilli()
+				if err := db.Model(&xray.ClientTraffic{}).
+					Where("email IN ?", emailsOf(renew)).
+					Updates(map[string]any{"reset": 30, "expiry_time": past, "up": int64(1 << 30)}).Error; err != nil {
+					t.Fatalf("mark renewable: %v", err)
+				}
+				start = time.Now()
+				if _, _, err := svc.AddTraffic(nil, nil); err != nil {
+					t.Fatalf("AddTraffic renew: %v", err)
+				}
+				t.Logf("N=%-7d shape=%-8s renew100=%v", n, shape.name, time.Since(start).Round(time.Millisecond))
+				var renewed xray.ClientTraffic
+				if err := db.Where("email = ?", renew[0].Email).First(&renewed).Error; err != nil {
+					t.Fatalf("load renewed row: %v", err)
+				}
+				if renewed.ExpiryTime <= past || renewed.Up != 0 {
+					t.Fatalf("renew100 did not renew: expiry=%d up=%d", renewed.ExpiryTime, renewed.Up)
+				}
+			})
+		}
+	}
+}

+ 93 - 0
internal/web/service/ws_payload_scale_test.go

@@ -0,0 +1,93 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+	"time"
+)
+
+// TestWsPayloadScale compares the websocket broadcast payload paths the
+// traffic job runs every 5s while a browser is connected: the full snapshot
+// (GetAllClientTraffics + full last-online map) against the delta variant
+// (GetActiveClientTraffics on this poll's active emails). Payload sizes are
+// logged against the hub's 10MB drop threshold.
+func TestWsPayloadScale(t *testing.T) {
+	setupScaleDB(t)
+	svc := &InboundService{}
+	const hubPayloadLimit = 10 * 1024 * 1024
+
+	for _, n := range scaleSizes(t, 10000, 100000) {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			ds := seedScaleDataset(t, n, 1)
+			k := min(10000, n)
+			activeEmails := sampleEmails(ds.emails, k)
+
+			start := time.Now()
+			all, err := svc.GetAllClientTraffics()
+			if err != nil {
+				t.Fatalf("GetAllClientTraffics: %v", err)
+			}
+			fetchAll := time.Since(start)
+			if len(all) != n {
+				t.Fatalf("GetAllClientTraffics rows = %d, want %d", len(all), n)
+			}
+			start = time.Now()
+			snapshot, err := json.Marshal(map[string]any{"clients": all})
+			if err != nil {
+				t.Fatalf("marshal snapshot: %v", err)
+			}
+			marshalAll := time.Since(start)
+			verdict := "delivered"
+			if len(snapshot) > hubPayloadLimit {
+				verdict = "DROPPED by hub (>10MB)"
+			}
+			t.Logf("N=%-7d snapshot: fetch=%-9v marshal=%-9v payload=%.1fMB %s",
+				n, fetchAll.Round(time.Millisecond), marshalAll.Round(time.Millisecond),
+				float64(len(snapshot))/(1<<20), verdict)
+
+			start = time.Now()
+			active, err := svc.GetActiveClientTraffics(activeEmails)
+			if err != nil {
+				t.Fatalf("GetActiveClientTraffics: %v", err)
+			}
+			fetchActive := time.Since(start)
+			if len(active) != k {
+				t.Fatalf("GetActiveClientTraffics rows = %d, want %d", len(active), k)
+			}
+			start = time.Now()
+			delta, err := json.Marshal(map[string]any{"clients": active})
+			if err != nil {
+				t.Fatalf("marshal delta: %v", err)
+			}
+			marshalActive := time.Since(start)
+			t.Logf("N=%-7d delta(K=%d): fetch=%-9v marshal=%-9v payload=%.1fMB",
+				n, k, fetchActive.Round(time.Millisecond), marshalActive.Round(time.Millisecond),
+				float64(len(delta))/(1<<20))
+
+			start = time.Now()
+			lastOnline, err := svc.GetClientsLastOnline()
+			if err != nil {
+				t.Fatalf("GetClientsLastOnline: %v", err)
+			}
+			fullMap := time.Since(start)
+			if len(lastOnline) != n {
+				t.Fatalf("GetClientsLastOnline entries = %d, want %d", len(lastOnline), n)
+			}
+			start = time.Now()
+			activeLastOnline := make(map[string]int64, len(active))
+			for _, ct := range active {
+				activeLastOnline[ct.Email] = ct.LastOnline
+			}
+			activeMap := time.Since(start)
+			t.Logf("N=%-7d lastOnline: fullMap=%-9v activeMap(K=%d)=%v",
+				n, fullMap.Round(time.Millisecond), k, activeMap.Round(time.Microsecond))
+
+			start = time.Now()
+			if _, err := svc.GetInboundsTrafficSummary(); err != nil {
+				t.Fatalf("GetInboundsTrafficSummary: %v", err)
+			}
+			t.Logf("N=%-7d inboundsSummary=%v", n, time.Since(start).Round(time.Millisecond))
+		})
+	}
+}

+ 66 - 0
internal/web/service/xray_config_scale_test.go

@@ -0,0 +1,66 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestGetXrayConfigScale measures building the full Xray config (the path
+// every restart/reconcile takes) and, separately, how much of the per-inbound
+// settings rebuild is spent on indented vs plain JSON marshaling.
+func TestGetXrayConfigScale(t *testing.T) {
+	setupScaleDB(t)
+	svc := &XrayService{}
+	shapes := []struct {
+		name     string
+		inbounds int
+	}{{"single", 1}, {"spread50", 50}}
+
+	for _, n := range scaleSizes(t, 10000, 100000) {
+		for _, shape := range shapes {
+			t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
+				ds := seedScaleDataset(t, n, shape.inbounds)
+
+				const reps = 3
+				start := time.Now()
+				for range reps {
+					cfg, err := svc.GetXrayConfig()
+					if err != nil {
+						t.Fatalf("GetXrayConfig: %v", err)
+					}
+					if len(cfg.InboundConfigs) < shape.inbounds {
+						t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
+					}
+				}
+				t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
+					n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
+
+				var ib model.Inbound
+				if err := database.GetDB().First(&ib, ds.inboundIds[0]).Error; err != nil {
+					t.Fatalf("load inbound: %v", err)
+				}
+				settings := map[string]any{}
+				if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+					t.Fatalf("unmarshal settings: %v", err)
+				}
+				start = time.Now()
+				if _, err := json.Marshal(settings); err != nil {
+					t.Fatalf("marshal settings: %v", err)
+				}
+				plain := time.Since(start)
+				start = time.Now()
+				if _, err := json.MarshalIndent(settings, "", "  "); err != nil {
+					t.Fatalf("marshal indent settings: %v", err)
+				}
+				indented := time.Since(start)
+				t.Logf("N=%-7d shape=%-8s settingsMarshal plain=%-9v indent=%v",
+					n, shape.name, plain.Round(time.Millisecond), indented.Round(time.Millisecond))
+			})
+		}
+	}
+}

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.",
       "panelUpdateCheckPopover": "فشل التحقق من تحديث البانل",
       "panelUpdateStartedPopover": "بدأ تحديث البانل",
+      "panelUpdateFailedTitle": "فشل تحديث البانل",
+      "panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.",
+      "panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث",
+      "panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.",
       "geofileUpdateDialog": "هل تريد حقًا تحديث ملف الجغرافيا؟",
       "geofileUpdateDialogDesc": "سيؤدي هذا إلى تحديث ملف #filename#.",
       "geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
-      "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه."
+      "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
+      "validation": {
+        "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
+      },
+      "secretClear": "مسح",
+      "secretClearUndo": "تراجع عن المسح"
     },
     "xray": {
       "title": "إعدادات Xray",
@@ -1687,7 +1696,8 @@
         "title": "المرصد",
         "burstTitle": "مرصد Burst",
         "autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
-        "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى يتمكن الموازن من قياس الوجهات الصادرة واختيار الأفضل.",
+        "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى تتمكن الموازنات المعتمدة على المرصد من فحص صحة الوجهات الصادرة قبل اختيار الهدف.",
+        "mixedLegacy": "يحتوي هذا الإعداد على Observatory و Burst Observatory معًا. يستخدم Xray مرصدًا عالميًا واحدًا، لذلك هذه الحالة القديمة المختلطة غير مدعومة؛ حفظ الموازنات سيحوّلها إلى مرصد واحد.",
         "subjectSelector": "الوجهات المراقَبة",
         "subjectSelectorDesc": "وسوم الوجهات الصادرة التي يفحصها هذا المرصد. تتم إدارتها تلقائيًا من الموازنات لديك.",
         "probeURL": "رابط الفحص (URL)",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.",
       "panelUpdateCheckPopover": "Panel update check failed",
       "panelUpdateStartedPopover": "Panel update started",
+      "panelUpdateFailedTitle": "Panel update failed",
+      "panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.",
+      "panelUpdateUnknownTitle": "Couldn't confirm the update finished",
+      "panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.",
       "geofileUpdateDialog": "Do you really want to update the geofile?",
       "geofileUpdateDialogDesc": "This will update the #filename# file.",
       "geofilesUpdateDialogDesc": "This will update all geofiles.",
@@ -1512,7 +1516,12 @@
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
       "smtpErrorUnknown": "SMTP error: {{ .Error }}",
-      "eventMemoryHigh": "Memory high (%)"
+      "eventMemoryHigh": "Memory high (%)",
+      "validation": {
+        "pathLeadingSlash": "Path must start with /"
+      },
+      "secretClear": "Clear",
+      "secretClearUndo": "Undo clear"
     },
     "xray": {
       "title": "Xray Configs",
@@ -1803,7 +1812,8 @@
         "title": "Observatory",
         "burstTitle": "Burst Observatory",
         "autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
-        "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so the balancer can measure your outbounds and pick the best one.",
+        "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so observer-backed balancers can check outbound health before choosing a target.",
+        "mixedLegacy": "This config contains both Observatory and Burst Observatory. Xray uses one global observer, so this mixed legacy state is not supported; saving balancers will normalize it to one observer.",
         "subjectSelector": "Watched Outbounds",
         "subjectSelectorDesc": "Outbound tags this observer probes. Managed automatically from your balancers.",
         "probeURL": "Probe URL",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.",
       "panelUpdateCheckPopover": "Fallo al comprobar actualización del panel",
       "panelUpdateStartedPopover": "Actualización del panel iniciada",
+      "panelUpdateFailedTitle": "Error al actualizar el panel",
+      "panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.",
+      "panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó",
+      "panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.",
       "geofileUpdateDialog": "¿Realmente deseas actualizar el geofichero?",
       "geofileUpdateDialogDesc": "Esto actualizará el archivo #filename#.",
       "geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
-      "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
+      "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
+      "validation": {
+        "pathLeadingSlash": "La ruta debe comenzar con /"
+      },
+      "secretClear": "Borrar",
+      "secretClearUndo": "Deshacer borrado"
     },
     "xray": {
       "title": "Xray Configuración",
@@ -1687,7 +1696,8 @@
         "title": "Observatorio",
         "burstTitle": "Observatorio Burst",
         "autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
-        "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que el balanceador pueda medir tus salidas y elegir la mejor.",
+        "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que los balanceadores que usan observador puedan comprobar la salud de las salidas antes de elegir un destino.",
+        "mixedLegacy": "Esta configuración contiene Observatory y Burst Observatory a la vez. Xray usa un único observador global, por lo que este estado mixto heredado no está soportado; al guardar balanceadores se normalizará a un solo observador.",
         "subjectSelector": "Salidas vigiladas",
         "subjectSelectorDesc": "Etiquetas de salida que sondea este observador. Se gestionan automáticamente a partir de tus balanceadores.",
         "probeURL": "URL de sondeo",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# به‌روزرسانی کرده و سرویس پنل را مجدداً راه‌اندازی می‌کند.",
       "panelUpdateCheckPopover": "خطا در بررسی به‌روزرسانی پنل",
       "panelUpdateStartedPopover": "به‌روزرسانی پنل آغاز شد",
+      "panelUpdateFailedTitle": "به‌روزرسانی پنل ناموفق بود",
+      "panelUpdateFailedDesc": "به‌روزرسانی با موفقیت به پایان نرسید. گزارش‌های سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.",
+      "panelUpdateUnknownTitle": "تأیید نشد که به‌روزرسانی به پایان رسیده باشد",
+      "panelUpdateUnknownDesc": "پنل به‌موقع نتیجه‌ای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارش‌های سرور را بررسی کنید.",
       "geofileUpdateDialog": "آیا واقعاً می‌خواهید فایل جغرافیایی را به‌روز کنید؟",
       "geofileUpdateDialogDesc": "این عمل فایل #filename# را به‌روز می‌کند.",
       "geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.",
@@ -1396,7 +1400,12 @@
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
       "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
-      "eventMemoryHigh": "مصرف حافظه بالا (%)"
+      "eventMemoryHigh": "مصرف حافظه بالا (%)",
+      "validation": {
+        "pathLeadingSlash": "مسیر باید با / شروع شود"
+      },
+      "secretClear": "پاک کردن",
+      "secretClearUndo": "لغو پاک کردن"
     },
     "xray": {
       "title": "پیکربندی ایکس‌ری",
@@ -1687,7 +1696,8 @@
         "title": "رصدخانه",
         "burstTitle": "رصدخانه Burst",
         "autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.",
-        "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسر بتواند خروجی‌ها را اندازه‌گیری کند و بهترین را انتخاب کند.",
+        "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسرهای متکی به رصدگر بتوانند پیش از انتخاب مقصد، سلامت خروجی‌ها را بررسی کنند.",
+        "mixedLegacy": "این پیکربندی هم Observatory و هم Burst Observatory دارد. Xray فقط از یک رصدگر سراسری استفاده می‌کند، بنابراین این حالت قدیمیِ ترکیبی پشتیبانی نمی‌شود؛ ذخیرهٔ بالانسرها آن را به یک رصدگر عادی‌سازی می‌کند.",
         "subjectSelector": "خروجی‌های تحت نظر",
         "subjectSelectorDesc": "تگ خروجی‌هایی که این رصدگر پروب می‌کند. به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شود.",
         "probeURL": "آدرس پروب (URL)",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.",
       "panelUpdateCheckPopover": "Pemeriksaan pembaruan panel gagal",
       "panelUpdateStartedPopover": "Pembaruan panel dimulai",
+      "panelUpdateFailedTitle": "Pembaruan panel gagal",
+      "panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.",
+      "panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai",
+      "panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.",
       "geofileUpdateDialog": "Apakah Anda yakin ingin memperbarui geofile?",
       "geofileUpdateDialogDesc": "Ini akan memperbarui file #filename#.",
       "geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
-      "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
+      "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
+      "validation": {
+        "pathLeadingSlash": "Path harus diawali dengan /"
+      },
+      "secretClear": "Hapus",
+      "secretClearUndo": "Batalkan hapus"
     },
     "xray": {
       "title": "Konfigurasi Xray",
@@ -1687,7 +1696,8 @@
         "title": "Observatory",
         "burstTitle": "Burst Observatory",
         "autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
-        "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer dapat mengukur outbound Anda dan memilih yang terbaik.",
+        "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer yang memakai observer dapat memeriksa kesehatan outbound sebelum memilih target.",
+        "mixedLegacy": "Konfigurasi ini berisi Observatory dan Burst Observatory sekaligus. Xray memakai satu observer global, jadi status campuran lama ini tidak didukung; menyimpan balancer akan menormalkannya menjadi satu observer.",
         "subjectSelector": "Outbound yang Dipantau",
         "subjectSelectorDesc": "Tag outbound yang di-probe observer ini. Dikelola otomatis dari balancer Anda.",
         "probeURL": "URL Probe",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。",
       "panelUpdateCheckPopover": "パネルの更新確認に失敗しました",
       "panelUpdateStartedPopover": "パネルの更新を開始しました",
+      "panelUpdateFailedTitle": "パネルの更新に失敗しました",
+      "panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。",
+      "panelUpdateUnknownTitle": "更新が完了したか確認できませんでした",
+      "panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。",
       "geofileUpdateDialog": "ジオファイルを本当に更新しますか?",
       "geofileUpdateDialogDesc": "これにより#filename#ファイルが更新されます。",
       "geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
-      "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。"
+      "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
+      "validation": {
+        "pathLeadingSlash": "パスは / で始まる必要があります"
+      },
+      "secretClear": "クリア",
+      "secretClearUndo": "クリアを取り消す"
     },
     "xray": {
       "title": "Xray 設定",
@@ -1687,7 +1696,8 @@
         "title": "オブザーバトリ",
         "burstTitle": "バースト オブザーバトリ",
         "autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
-        "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、バランサーがアウトバウンドを測定して最適なものを選べるようになります。",
+        "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、オブザーバを使うバランサーがターゲットを選ぶ前にアウトバウンドの健全性を確認できるようになります。",
+        "mixedLegacy": "この設定には Observatory と Burst Observatory の両方が含まれています。Xray は単一のグローバルオブザーバを使用するため、この古い混在状態はサポートされません。バランサーを保存すると 1 つのオブザーバに正規化されます。",
         "subjectSelector": "監視対象のアウトバウンド",
         "subjectSelectorDesc": "このオブザーバがプローブするアウトバウンドのタグ。バランサーから自動的に管理されます。",
         "probeURL": "プローブ URL",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.",
       "panelUpdateCheckPopover": "Falha na verificação de atualização do painel",
       "panelUpdateStartedPopover": "Atualização do painel iniciada",
+      "panelUpdateFailedTitle": "Falha ao atualizar o painel",
+      "panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.",
+      "panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou",
+      "panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.",
       "geofileUpdateDialog": "Você realmente deseja atualizar o geofile?",
       "geofileUpdateDialogDesc": "Isso atualizará o arquivo #filename#.",
       "geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
       "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
-      "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
+      "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
+      "validation": {
+        "pathLeadingSlash": "O caminho deve começar com /"
+      },
+      "secretClear": "Limpar",
+      "secretClearUndo": "Desfazer limpeza"
     },
     "xray": {
       "title": "Configurações Xray",
@@ -1687,7 +1696,8 @@
         "title": "Observatório",
         "burstTitle": "Observatório Burst",
         "autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
-        "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que o balanceador possa medir suas saídas e escolher a melhor.",
+        "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que balanceadores com observador possam verificar a saúde das saídas antes de escolher um destino.",
+        "mixedLegacy": "Esta configuração contém Observatory e Burst Observatory ao mesmo tempo. O Xray usa um único observador global, então esse estado misto legado não é suportado; ao salvar os balanceadores ele será normalizado para um único observador.",
         "subjectSelector": "Saídas monitoradas",
         "subjectSelectorDesc": "Tags de saída que este observador sonda. Gerenciadas automaticamente a partir dos seus balanceadores.",
         "probeURL": "URL de sondagem",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Это обновит 3X-UI до версии #version# и перезапустит сервис панели.",
       "panelUpdateCheckPopover": "Проверка обновления панели не удалась",
       "panelUpdateStartedPopover": "Обновление панели началось",
+      "panelUpdateFailedTitle": "Не удалось обновить панель",
+      "panelUpdateFailedDesc": "Обновление не завершилось успешно. Проверьте журналы сервера или выполните 'x-ui update' в командной строке.",
+      "panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления",
+      "panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.",
       "geofileUpdateDialog": "Вы действительно хотите обновить геофайл?",
       "geofileUpdateDialogDesc": "Это обновит файл #filename#.",
       "geofilesUpdateDialogDesc": "Это обновит все геофайлы.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
-      "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше."
+      "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
+      "validation": {
+        "pathLeadingSlash": "Путь должен начинаться с /"
+      },
+      "secretClear": "Очистить",
+      "secretClearUndo": "Отменить очистку"
     },
     "xray": {
       "importRules": "Импорт правил",
@@ -1687,7 +1696,8 @@
         "title": "Обсерватория",
         "burstTitle": "Burst-обсерватория",
         "autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.",
-        "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщик мог измерять исходящие и выбирать лучший.",
+        "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщики с наблюдателем могли проверять состояние исходящих перед выбором цели.",
+        "mixedLegacy": "В этой конфигурации одновременно есть Observatory и Burst Observatory. Xray использует один глобальный наблюдатель, поэтому такое устаревшее смешанное состояние не поддерживается; сохранение балансировщиков нормализует его до одного наблюдателя.",
         "subjectSelector": "Отслеживаемые исходящие",
         "subjectSelectorDesc": "Теги исходящих, которые опрашивает этот наблюдатель. Управляется автоматически на основе ваших балансировщиков.",
         "probeURL": "URL пробы",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Bu işlem 3X-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.",
       "panelUpdateCheckPopover": "Panel güncelleme kontrolü başarısız oldu",
       "panelUpdateStartedPopover": "Panel güncellemesi başlatıldı",
+      "panelUpdateFailedTitle": "Panel güncellemesi başarısız oldu",
+      "panelUpdateFailedDesc": "Güncelleme başarıyla tamamlanamadı. Sunucu günlüklerini kontrol edin veya komut satırından 'x-ui update' çalıştırın.",
+      "panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı",
+      "panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.",
       "geofileUpdateDialog": "Geofile'ı gerçekten güncellemek istiyor musunuz?",
       "geofileUpdateDialogDesc": "Bu işlem #filename# dosyasını güncelleyecektir.",
       "geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
-      "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
+      "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
+      "validation": {
+        "pathLeadingSlash": "Yol / ile başlamalıdır"
+      },
+      "secretClear": "Temizle",
+      "secretClearUndo": "Temizlemeyi geri al"
     },
     "xray": {
       "title": "Xray Yapılandırmaları",
@@ -1687,7 +1696,8 @@
         "title": "Gözlemci",
         "burstTitle": "Burst Gözlemci",
         "autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.",
-        "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece dengeleyici çıkışlarınızı ölçüp en iyisini seçebilir.",
+        "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece gözlemci kullanan dengeleyiciler hedef seçmeden önce çıkış sağlığını kontrol edebilir.",
+        "mixedLegacy": "Bu yapılandırmada hem Observatory hem de Burst Observatory var. Xray tek bir global gözlemci kullanır, bu nedenle bu eski karma durum desteklenmez; dengeleyiciler kaydedildiğinde tek bir gözlemciye normalleştirilir.",
         "subjectSelector": "İzlenen Çıkışlar",
         "subjectSelectorDesc": "Bu gözlemcinin sınadığı çıkış etiketleri. Dengeleyicilerinize göre otomatik yönetilir.",
         "probeURL": "Sınama URL'si",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Це оновить 3X-UI до #version# та перезапустить сервіс панелі.",
       "panelUpdateCheckPopover": "Перевірка оновлення панелі не вдалася",
       "panelUpdateStartedPopover": "Розпочато оновлення панелі",
+      "panelUpdateFailedTitle": "Не вдалося оновити панель",
+      "panelUpdateFailedDesc": "Оновлення не завершилося успішно. Перевірте журнали сервера або виконайте 'x-ui update' у командному рядку.",
+      "panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення",
+      "panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.",
       "geofileUpdateDialog": "Ви дійсно хочете оновити геофайл?",
       "geofileUpdateDialogDesc": "Це оновить файл #filename#.",
       "geofilesUpdateDialogDesc": "Це оновить усі геофайли.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
-      "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище."
+      "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
+      "validation": {
+        "pathLeadingSlash": "Шлях має починатися з /"
+      },
+      "secretClear": "Очистити",
+      "secretClearUndo": "Скасувати очищення"
     },
     "xray": {
       "title": "Xray конфігурації",
@@ -1687,7 +1696,8 @@
         "title": "Обсерваторія",
         "burstTitle": "Burst-обсерваторія",
         "autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.",
-        "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальник міг вимірювати вихідні й обирати найкращий.",
+        "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальники зі спостерігачем могли перевіряти стан вихідних перед вибором цілі.",
+        "mixedLegacy": "Ця конфігурація містить і Observatory, і Burst Observatory. Xray використовує одного глобального спостерігача, тому такий застарілий змішаний стан не підтримується; збереження балансувальників нормалізує його до одного спостерігача.",
         "subjectSelector": "Відстежувані вихідні",
         "subjectSelectorDesc": "Теги вихідних, які опитує цей спостерігач. Керується автоматично на основі ваших балансувальників.",
         "probeURL": "URL проби",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "Điều này sẽ cập nhật 3X-UI lên #version# và khởi động lại dịch vụ panel.",
       "panelUpdateCheckPopover": "Kiểm tra cập nhật panel thất bại",
       "panelUpdateStartedPopover": "Bắt đầu cập nhật panel",
+      "panelUpdateFailedTitle": "Cập nhật panel thất bại",
+      "panelUpdateFailedDesc": "Bản cập nhật không hoàn tất thành công. Hãy kiểm tra nhật ký máy chủ, hoặc chạy 'x-ui update' từ dòng lệnh.",
+      "panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất",
+      "panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.",
       "geofileUpdateDialog": "Bạn có chắc chắn muốn cập nhật geofile không?",
       "geofileUpdateDialogDesc": "Hành động này sẽ cập nhật tệp #filename#.",
       "geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
-      "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
+      "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
+      "validation": {
+        "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
+      },
+      "secretClear": "Xóa",
+      "secretClearUndo": "Hoàn tác xóa"
     },
     "xray": {
       "title": "Cài đặt Xray",
@@ -1687,7 +1696,8 @@
         "title": "Observatory",
         "burstTitle": "Burst Observatory",
         "autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.",
-        "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để balancer có thể đo các outbound và chọn cái tốt nhất.",
+        "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để các balancer dùng observer có thể kiểm tra sức khỏe outbound trước khi chọn mục tiêu.",
+        "mixedLegacy": "Cấu hình này có cả Observatory và Burst Observatory. Xray chỉ dùng một observer toàn cục, nên trạng thái hỗn hợp cũ này không được hỗ trợ; khi lưu balancer, nó sẽ được chuẩn hóa về một observer.",
         "subjectSelector": "Outbound được theo dõi",
         "subjectSelectorDesc": "Các thẻ outbound mà observer này dò. Được quản lý tự động từ các balancer của bạn.",
         "probeURL": "URL dò",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
       "panelUpdateCheckPopover": "面板更新检查失败",
       "panelUpdateStartedPopover": "已开始更新面板",
+      "panelUpdateFailedTitle": "面板更新失败",
+      "panelUpdateFailedDesc": "更新未成功完成。请检查服务器日志,或在命令行运行「x-ui update」。",
+      "panelUpdateUnknownTitle": "无法确认更新是否已完成",
+      "panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。",
       "geofileUpdateDialog": "您确定要更新地理文件吗?",
       "geofileUpdateDialogDesc": "这将更新 #filename# 文件。",
       "geofilesUpdateDialogDesc": "这将更新所有文件。",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
       "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
-      "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。"
+      "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
+      "validation": {
+        "pathLeadingSlash": "路径必须以 / 开头"
+      },
+      "secretClear": "清除",
+      "secretClearUndo": "撤销清除"
     },
     "xray": {
       "importRules": "导入规则",
@@ -1687,7 +1696,8 @@
         "title": "观测器",
         "burstTitle": "突发观测器",
         "autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。",
-        "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便负载均衡器测量各出站并选择最优。",
+        "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便依赖观测器的负载均衡器在选择目标前检查出站健康状态。",
+        "mixedLegacy": "此配置同时包含 Observatory 和 Burst Observatory。Xray 只使用一个全局观测器,因此不支持这种旧式混合状态;保存负载均衡器时会将其规范化为单个观测器。",
         "subjectSelector": "被观测的出站",
         "subjectSelectorDesc": "该观测器探测的出站标签。根据你的负载均衡器自动管理。",
         "probeURL": "探测 URL",

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

@@ -231,6 +231,10 @@
       "panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
       "panelUpdateCheckPopover": "面板更新檢查失敗",
       "panelUpdateStartedPopover": "面板更新已開始",
+      "panelUpdateFailedTitle": "面板更新失敗",
+      "panelUpdateFailedDesc": "更新未成功完成。請檢查伺服器日誌,或在命令列執行「x-ui update」。",
+      "panelUpdateUnknownTitle": "無法確認更新是否已完成",
+      "panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。",
       "geofileUpdateDialog": "您確定要更新地理檔案嗎?",
       "geofileUpdateDialogDesc": "這將更新 #filename# 檔案。",
       "geofilesUpdateDialogDesc": "這將更新所有文件。",
@@ -1396,7 +1400,12 @@
       "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
-      "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。"
+      "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
+      "validation": {
+        "pathLeadingSlash": "路徑必須以 / 開頭"
+      },
+      "secretClear": "清除",
+      "secretClearUndo": "復原清除"
     },
     "xray": {
       "title": "Xray 配置",
@@ -1687,7 +1696,8 @@
         "title": "觀測器",
         "burstTitle": "突發觀測器",
         "autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。",
-        "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓負載平衡器能量測各出站並選出最佳者。",
+        "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓依賴觀測器的負載平衡器能在選擇目標前檢查出站健康狀態。",
+        "mixedLegacy": "此設定同時包含 Observatory 與 Burst Observatory。Xray 只使用一個全域觀測器,因此不支援這種舊式混合狀態;儲存負載平衡器時會將其正規化為單一觀測器。",
         "subjectSelector": "被觀測的出站",
         "subjectSelectorDesc": "此觀測器探測的出站標籤。會根據你的負載平衡器自動管理。",
         "probeURL": "探測 URL",

+ 5 - 4
internal/web/websocket/notifier.go

@@ -45,10 +45,11 @@ func BroadcastTraffic(traffic any) {
 	}
 }
 
-// BroadcastClientStats broadcasts absolute per-client traffic counters for the
-// clients that had activity in the latest collection window. Use this instead
-// of re-broadcasting the full inbound list — it scales to 10k+ clients because
-// the payload only includes active rows (typically a fraction of total).
+// BroadcastClientStats broadcasts absolute per-client traffic counters. Small
+// installs send the complete row set each cycle (payload key snapshot=true);
+// above the traffic job's snapshot threshold only the rows active in the
+// latest collection window are sent (snapshot=false), which keeps the payload
+// under the hub's cap at any client count.
 func BroadcastClientStats(stats any) {
 	if hub := GetHub(); hub != nil {
 		hub.Broadcast(MessageTypeClientStats, stats)

+ 1 - 1
tools/openapigen/main.go

@@ -83,7 +83,7 @@ func run(root, outDir string) error {
 		},
 		{
 			Path:        resolveRel(root, "internal/web/service/panel"),
-			StructAllow: setOf("ApiTokenView"),
+			StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
 		},
 	}
 

+ 115 - 16
update.sh

@@ -31,6 +31,40 @@ _fail() {
     exit 2
 }
 
+# Records this run's outcome for the panel's web updater to poll, since it
+# launches this script detached and has no other way to learn whether it
+# finished. Written to a fixed path outside XUI_MAIN_FOLDER so it survives
+# the update regardless of what happens to that folder. The EXIT trap below
+# covers every exit path in this file, including the bare `exit 1`/`exit 2`
+# calls that don't go through _fail.
+xui_update_run_id="${XUI_UPDATE_RUN_ID:-0}"
+[[ "${xui_update_run_id}" =~ ^[0-9]+$ ]] || xui_update_run_id="0"
+xui_update_status_file="${XUI_UPDATE_STATUS_FILE:-/etc/x-ui/update-status.json}"
+
+_write_update_status() {
+    local state="$1"
+    local exit_code="$2"
+    local status_dir
+    status_dir="$(dirname "${xui_update_status_file}")"
+    mkdir -p "${status_dir}" > /dev/null 2>&1
+    local tmp_file="${xui_update_status_file}.tmp.$$"
+    printf '{"runId":"%s","state":"%s","exitCode":%s,"finishedAt":%s}\n' \
+        "${xui_update_run_id}" "${state}" "${exit_code}" "$(date +%s)" > "${tmp_file}" 2> /dev/null
+    mv -f "${tmp_file}" "${xui_update_status_file}" > /dev/null 2>&1
+}
+
+_report_update_exit() {
+    local code=$?
+    if [[ "${code}" -eq 0 ]]; then
+        _write_update_status "success" "0"
+    else
+        _write_update_status "failed" "${code}"
+    fi
+}
+trap _report_update_exit EXIT
+trap 'exit 143' TERM
+trap 'exit 130' INT
+
 # check root
 [[ $EUID -ne 0 ]] && _fail "FATAL ERROR: Please run this script with root privilege."
 
@@ -149,13 +183,13 @@ install_base() {
             apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
-            dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
+            dnf makecache -y > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         centos)
             if [[ "${VERSION_ID}" =~ ^7 ]]; then
                 yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             else
-                dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
+                dnf makecache -y > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             fi
             ;;
         arch | manjaro | parch)
@@ -881,6 +915,40 @@ setup_fail2ban() {
     return 0
 }
 
+# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
+# atomic mv, so a failed cp/curl or an interrupted mv never leaves a
+# truncated unit file at the live path -- systemd would then fail to parse
+# it on the next daemon-reload/start. Same pattern already used for
+# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a
+# file already extracted from the release tarball) vs curl (GitHub fallback).
+_install_xui_service_unit() {
+    local source="$1"
+    local source_is_url="$2"
+    local dest="${xui_service}/x-ui.service"
+    local temp_file="${dest}.tmp.$$"
+
+    rm -f "$temp_file"
+    if [[ "$source_is_url" == "true" ]]; then
+        ${curl_bin} -fLRo "$temp_file" "$source" > /dev/null 2>&1
+    else
+        cp -f "$source" "$temp_file" > /dev/null 2>&1
+    fi
+    if [[ $? -ne 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    if [[ ! -s "$temp_file" ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    mv -f "$temp_file" "$dest"
+    if [[ $? -ne 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    return 0
+}
+
 update_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -911,6 +979,10 @@ update_x-ui() {
     if [[ $? -ne 0 ]]; then
         _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
     fi
+    if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then
+        rm ${xui_folder}-linux-$(arch).tar.gz -f > /dev/null 2>&1
+        _fail "ERROR: Downloaded x-ui release archive is empty, please be sure that your server can access GitHub"
+    fi
 
     if [[ -e ${xui_folder}/ ]]; then
         echo -e "${green}Stopping x-ui...${plain}"
@@ -961,8 +1033,15 @@ update_x-ui() {
 
     echo -e "${green}Installing new x-ui version...${plain}"
     tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1
+    if [[ $? -ne 0 ]]; then
+        rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
+        _fail "ERROR: Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the update again"
+    fi
     rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
     cd x-ui > /dev/null 2>&1
+    if [[ $? -ne 0 || ! -s x-ui ]]; then
+        _fail "ERROR: Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the update again"
+    fi
     chmod +x x-ui > /dev/null 2>&1
 
     # Check the system's architecture and rename the file accordingly
@@ -974,10 +1053,22 @@ update_x-ui() {
     chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1
 
     echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
-    ${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
+    local xui_script_temp="/usr/bin/x-ui-temp.$$"
+    rm -f "${xui_script_temp}"
+    ${curl_bin} -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
     if [[ $? -ne 0 ]]; then
+        rm -f "${xui_script_temp}"
         _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
     fi
+    if [[ ! -s "${xui_script_temp}" ]]; then
+        rm -f "${xui_script_temp}"
+        _fail "ERROR: Downloaded x-ui.sh script is empty, please be sure that your server can access GitHub"
+    fi
+    mv -f "${xui_script_temp}" /usr/bin/x-ui
+    if [[ $? -ne 0 ]]; then
+        rm -f "${xui_script_temp}"
+        _fail "ERROR: Failed to install x-ui.sh script"
+    fi
 
     chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
     chmod +x /usr/bin/x-ui > /dev/null 2>&1
@@ -993,10 +1084,22 @@ update_x-ui() {
 
     if [[ $release == "alpine" ]]; then
         echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
-        ${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
+        xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
+        rm -f "${xui_rc_temp}"
+        ${curl_bin} -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
         if [[ $? -ne 0 ]]; then
+            rm -f "${xui_rc_temp}"
             _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
         fi
+        if [[ ! -s "${xui_rc_temp}" ]]; then
+            rm -f "${xui_rc_temp}"
+            _fail "ERROR: Downloaded startup unit x-ui.rc is empty, please be sure that your server can access GitHub"
+        fi
+        mv -f "${xui_rc_temp}" /etc/init.d/x-ui
+        if [[ $? -ne 0 ]]; then
+            rm -f "${xui_rc_temp}"
+            _fail "ERROR: Failed to install startup unit x-ui.rc"
+        fi
         chmod +x /etc/init.d/x-ui > /dev/null 2>&1
         chown root:root /etc/init.d/x-ui > /dev/null 2>&1
         rc-update add x-ui > /dev/null 2>&1
@@ -1004,8 +1107,7 @@ update_x-ui() {
     else
         if [ -f "x-ui.service" ]; then
             echo -e "${green}Installing systemd unit...${plain}"
-            cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
-            if [[ $? -ne 0 ]]; then
+            if ! _install_xui_service_unit "x-ui.service" "false"; then
                 echo -e "${red}Failed to copy x-ui.service${plain}"
                 exit 1
             fi
@@ -1015,8 +1117,7 @@ update_x-ui() {
                 ubuntu | debian | armbian)
                     if [ -f "x-ui.service.debian" ]; then
                         echo -e "${green}Installing debian-like systemd unit...${plain}"
-                        cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.debian" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1024,8 +1125,7 @@ update_x-ui() {
                 arch | manjaro | parch)
                     if [ -f "x-ui.service.arch" ]; then
                         echo -e "${green}Installing arch-like systemd unit...${plain}"
-                        cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.arch" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1033,8 +1133,7 @@ update_x-ui() {
                 *)
                     if [ -f "x-ui.service.rhel" ]; then
                         echo -e "${green}Installing rhel-like systemd unit...${plain}"
-                        cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
-                        if [[ $? -eq 0 ]]; then
+                        if _install_xui_service_unit "x-ui.service.rhel" "false"; then
                             service_installed=true
                         fi
                     fi
@@ -1046,17 +1145,17 @@ update_x-ui() {
                 echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
                 case "${release}" in
                     ubuntu | debian | armbian)
-                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
                         ;;
                     arch | manjaro | parch)
-                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
                         ;;
                     *)
-                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
                         ;;
                 esac
 
-                if [[ $? -ne 0 ]]; then
+                if ! _install_xui_service_unit "$service_unit_url" "true"; then
                     echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
                     exit 1
                 fi

+ 66 - 15
x-ui.sh

@@ -173,6 +173,41 @@ update_dev() {
     fi
 }
 
+replace_xui_script() {
+    local url="$1"
+    local use_if_modified_since="$2"
+    local temp_file="/usr/bin/x-ui-temp.$$"
+
+    rm -f "$temp_file"
+    if [[ "$use_if_modified_since" == "true" ]]; then
+        curl -fLRo "$temp_file" -z /usr/bin/x-ui "$url"
+    else
+        curl -fLRo "$temp_file" "$url"
+    fi
+    if [[ $? != 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+
+    if [[ ! -s "$temp_file" ]]; then
+        rm -f "$temp_file"
+        # -z above means "not modified since /usr/bin/x-ui" rather than a
+        # real failure, so an empty download here is success, not an error.
+        [[ "$use_if_modified_since" == "true" ]] && return 0
+        return 1
+    fi
+
+    mv -f "$temp_file" /usr/bin/x-ui
+    if [[ $? != 0 ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    # The move already landed the new script; a transient chmod failure here
+    # shouldn't make callers think the whole replace failed.
+    chmod +x /usr/bin/x-ui
+    return 0
+}
+
 update_menu() {
     echo -e "${yellow}Updating Menu${plain}"
     confirm "This function will update the menu to the latest changes." "y"
@@ -184,11 +219,8 @@ update_menu() {
         return 0
     fi
 
-    curl -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
-    chmod +x ${xui_folder}/x-ui.sh
-    chmod +x /usr/bin/x-ui
-
-    if [[ $? == 0 ]]; then
+    if replace_xui_script "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh" "false"; then
+        chmod +x ${xui_folder}/x-ui.sh
         echo -e "${green}Update successful. The panel has automatically restarted.${plain}"
         exit 0
     else
@@ -804,14 +836,12 @@ enable_bbr() {
 }
 
 update_shell() {
-    curl -fLRo /usr/bin/x-ui -z /usr/bin/x-ui https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh
-    if [[ $? != 0 ]]; then
-        echo ""
-        LOGE "Failed to download script, Please check whether the machine can connect Github"
+    if replace_xui_script "https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh" "true"; then
+        LOGI "Upgrade script succeeded, Please rerun the script"
         before_show_menu
     else
-        chmod +x /usr/bin/x-ui
-        LOGI "Upgrade script succeeded, Please rerun the script"
+        echo ""
+        LOGE "Failed to download script, Please check whether the machine can connect Github"
         before_show_menu
     fi
 }
@@ -1198,22 +1228,43 @@ update_geofiles() {
             dat_files=(geoip_RU geosite_RU)
             dat_source="runetfreedom/russia-v2ray-rules-dat"
             ;;
+        *)
+            echo -e "${red}update_geofiles: unknown dataset '${1}'${plain}"
+            return 1
+            ;;
     esac
     local failed=0 http_code
     for dat in "${dat_files[@]}"; do
         # Remove suffix for remote filename (e.g., geoip_IR -> geoip)
         remote_file="${dat%%_*}"
-        # -z skips the download (server answers 304) when the local copy is already current
-        http_code=$(curl -sSfLRo ${xui_folder}/bin/${dat}.dat -z ${xui_folder}/bin/${dat}.dat -w '%{http_code}' \
+        local dest="${xui_folder}/bin/${dat}.dat"
+        local temp_file="${dest}.tmp.$$"
+        rm -f "$temp_file"
+        # -z (against the live file, not the temp file) skips the download
+        # (server answers 304) when the local copy is already current.
+        http_code=$(curl -sSfLRo "$temp_file" -z "$dest" -w '%{http_code}' \
             https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat)
         if [[ $? -ne 0 ]]; then
             echo -e "${red}${dat}.dat: download failed${plain}"
+            rm -f "$temp_file"
             failed=1
         elif [[ "$http_code" == "304" ]]; then
             echo -e "${dat}.dat: already up to date"
+            rm -f "$temp_file"
+        elif [[ ! -s "$temp_file" ]]; then
+            echo -e "${red}${dat}.dat: downloaded file is empty${plain}"
+            rm -f "$temp_file"
+            failed=1
         else
-            echo -e "${green}${dat}.dat: updated${plain}"
-            geo_updated=1
+            mv -f "$temp_file" "$dest"
+            if [[ $? -ne 0 ]]; then
+                echo -e "${red}${dat}.dat: failed to install${plain}"
+                rm -f "$temp_file"
+                failed=1
+            else
+                echo -e "${green}${dat}.dat: updated${plain}"
+                geo_updated=1
+            fi
         fi
     done
     return $failed