18 Commits 21e01cc1e6 ... 9711a9ce22

Autor SHA1 Mensaje Fecha
  Sanaei 9711a9ce22 v3.3.0 hace 6 horas
  Sanaei 9acde8da9d Bump frontend version and deps hace 6 horas
  Rouzbeh† d9ccf157c3 feat: add manual and automatic WARP IP rotation (#5099) hace 6 horas
  Rouzbeh† be8bd4e22c fix: propagate inbound traffic reset to nodes (#5103) hace 6 horas
  Vladimir Avtsenov 5a7de02598 fix(ui): remove pointer cursor from non-interactive elements in cards (#5102) hace 7 horas
  Rouzbeh† a32c6803da fix: route WARP API requests through panel proxy (#5101) hace 7 horas
  Rouzbeh† 9f31d7d056 feat: synchronize access.log client IPs across nodes (#5098) hace 7 horas
  Turan 0d7b6872f7 docs(i18n): refine Turkish translation and network terminology (#5092) hace 8 horas
  吉姆·塞尔夫 7d908834a8 fix(ui): correct inline style syntax in client counts column on inbounds page (#5097) hace 8 horas
  Sanaei 1fa51cf0f2 feat(groups): show used traffic per group in groups table hace 8 horas
  Sanaei b24b8524b6 fix(inbounds): drop unknown nodeId when importing an inbound hace 9 horas
  Sanaei 8ce61f3cb0 fix(script): revoke also removes cert files and acme.sh tracking (#5009) hace 9 horas
  Sanaei 3d6ff2b60c fix(tgbot): apply bot settings on panel restart without full service restart hace 9 horas
  Rouzbeh† abf6b8799e feat: customizable subscription page templates (#5079) hace 10 horas
  Rouzbeh† 94b8196e84 fix(db): additional cross-DB and node traffic edge cases (migration scan + node reset time) (#5045) hace 11 horas
  nima1024m e8171ab4f7 fix(xray): sync routing rules when outbound tag is renamed (#5006) hace 11 horas
  Rouzbeh† 1c74b995c3 feat(nodes): add distinct purple indicator when panel is online but Xray core failed (#5040) hace 11 horas
  Rouzbeh† 0daedd3db9 feat: add support for subscription-based outbounds with auto-update (#5037) hace 13 horas
Se han modificado 87 ficheros con 6168 adiciones y 948 borrados
  1. 1 0
      .gitignore
  2. 1 1
      CONTRIBUTING.md
  3. 1 1
      README.md
  4. 47 47
      README.tr_TR.md
  5. 1 1
      config/version
  6. 1 0
      database/db.go
  7. 0 20
      database/dialect.go
  8. 15 3
      database/migrate_data.go
  9. 28 0
      database/model/model.go
  10. 283 265
      frontend/package-lock.json
  11. 8 1
      frontend/package.json
  12. 408 0
      frontend/public/openapi.json
  13. 8 0
      frontend/src/generated/examples.ts
  14. 40 0
      frontend/src/generated/schemas.ts
  15. 8 0
      frontend/src/generated/types.ts
  16. 8 0
      frontend/src/generated/zod.ts
  17. 62 30
      frontend/src/hooks/useXraySetting.ts
  18. 6 3
      frontend/src/layouts/AppSidebar.tsx
  19. 1 0
      frontend/src/models/setting.ts
  20. 83 0
      frontend/src/pages/api-docs/endpoints.ts
  21. 13 5
      frontend/src/pages/groups/GroupsPage.tsx
  22. 3 3
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  23. 79 28
      frontend/src/pages/nodes/NodeList.tsx
  24. 4 1
      frontend/src/pages/nodes/NodesPage.tsx
  25. 5 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  26. 18 5
      frontend/src/pages/xray/XrayPage.tsx
  27. 6 1
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  28. 33 0
      frontend/src/pages/xray/basics/helpers.ts
  29. 14 0
      frontend/src/pages/xray/outbounds/OutboundsTab.css
  30. 428 6
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  31. 207 0
      frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx
  32. 2 2
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  33. 89 21
      frontend/src/pages/xray/overrides/WarpModal.tsx
  34. 6 1
      frontend/src/pages/xray/routing/RoutingTab.tsx
  35. 1 0
      frontend/src/schemas/client.ts
  36. 8 0
      frontend/src/schemas/node.ts
  37. 5 0
      frontend/src/schemas/xray.ts
  38. 1 0
      frontend/src/styles/page-cards.css
  39. 23 0
      frontend/src/styles/utils.css
  40. 61 0
      frontend/src/test/outbound-tag-rename.test.ts
  41. 16 0
      frontend/src/test/setup.components.ts
  42. 14 14
      go.mod
  43. 30 28
      go.sum
  44. 111 21
      sub/subController.go
  45. 149 0
      sub/subController_test.go
  46. 44 0
      sub_templates/README.md
  47. 809 0
      util/link/outbound.go
  48. 62 0
      util/link/outbound_test.go
  49. 24 0
      util/wireguard.go
  50. 10 2
      web/controller/inbound.go
  51. 18 0
      web/controller/server.go
  52. 195 7
      web/controller/xray_setting.go
  53. 4 0
      web/entity/entity.go
  54. 5 1
      web/job/check_client_ip_job.go
  55. 21 0
      web/job/check_client_ip_job_test.go
  56. 39 2
      web/job/node_traffic_sync_job.go
  57. 48 0
      web/job/outbound_subscription_job.go
  58. 52 0
      web/job/warp_ip_job.go
  59. 4 0
      web/runtime/local.go
  60. 24 0
      web/runtime/remote.go
  61. 1 0
      web/runtime/runtime.go
  62. 17 9
      web/service/client.go
  63. 198 39
      web/service/inbound.go
  64. 211 0
      web/service/inbound_client_ips_merge_test.go
  65. 55 15
      web/service/node.go
  66. 4 0
      web/service/node_tree.go
  67. 540 0
      web/service/outbound_subscription.go
  68. 117 0
      web/service/outbound_subscription_test.go
  69. 23 0
      web/service/setting.go
  70. 46 7
      web/service/warp.go
  71. 42 0
      web/service/xray.go
  72. 89 0
      web/service/xray_setting.go
  73. 64 2
      web/translation/ar-EG.json
  74. 64 2
      web/translation/en-US.json
  75. 64 2
      web/translation/es-ES.json
  76. 64 2
      web/translation/fa-IR.json
  77. 64 2
      web/translation/id-ID.json
  78. 64 2
      web/translation/ja-JP.json
  79. 64 2
      web/translation/pt-BR.json
  80. 64 2
      web/translation/ru-RU.json
  81. 328 326
      web/translation/tr-TR.json
  82. 64 2
      web/translation/uk-UA.json
  83. 64 2
      web/translation/vi-VN.json
  84. 64 2
      web/translation/zh-CN.json
  85. 64 2
      web/translation/zh-TW.json
  86. 6 4
      web/web.go
  87. 28 4
      x-ui.sh

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Ignore editor and IDE settings
 .idea/
 .vscode/
+.cursor/
 .claude/
 .cache/
 .sync*

+ 1 - 1
CONTRIBUTING.md

@@ -162,7 +162,7 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
 | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
 | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
 
-The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
+The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
 
 > **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
 

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
+[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
 
 <p align="center">
   <picture>

+ 47 - 47
README.tr_TR.md

@@ -17,28 +17,28 @@
   <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
-**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
+**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
 
-Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
+Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa edilen 3X-UI; çok daha geniş protokol desteği, artırılmış kararlılık, kullanıcı başına trafik hesaplama ve kullanım kolaylığı sağlayan birçok yeni özellik sunar.
 
 > [!IMPORTANT]
-> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
+> Bu proje yalnızca kişisel kullanım için tasarlanmıştır. Lütfen yasadışı amaçlar için veya üretim (production) ortamında kullanmayın.
 
 ## Özellikler
 
-- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
-- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
-- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
-- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
-- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
-- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
-- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
-- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
-- **Telegram botu** (Uzaktan izleme ve yönetim için).
-- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
-- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
-- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
-- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
+- **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Karma), Dokodemo-door / Tunnel ve TUN.
+- **Modern aktarımlar (transports) ve güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvene alınmıştır.
+- **Geri Dönüş (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port üzerinde birden fazla protokole (ör. 443 üzerinde hem VLESS hem Trojan) hizmet verin.
+- **Kullanıcı başına yönetim** — Trafik kotaları, bitiş tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıkla paylaşım bağlantıları, QR kodları ve abonelikler.
+- **Trafik istatistikleri** — Gelen bağlantı (Inbound), istemci ve giden bağlantı (Outbound) bazında istatistikler ve sıfırlama kontrolleri.
+- **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden birden fazla sunucuyu yönetin ve ölçeklendirin.
+- **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining).
+- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
+- Uzaktan izleme ve yönetim için **Telegram botu**.
+- Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
+- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
+- Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
+- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
 
 ## Ekran Görüntüleri
 
@@ -52,7 +52,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
 
 <picture>
   <source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
-  <img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
+  <img alt="Gelen Bağlantılar (Inbounds)" src="./media/02-add-inbound-light.png">
 </picture>
 
 <picture>
@@ -73,7 +73,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
 bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
 ```
 
-Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
+Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
 
 Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
 
@@ -85,93 +85,93 @@ Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHS
 
 ## Veritabanı Seçenekleri
 
-3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
+3X-UI kurulum sırasında seçilebilecek iki arka uç (backend) destekler:
 
-- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
-- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğüm (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
+- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Kurulum gerektirmez, küçük ve orta ölçekli dağıtımlar için idealdir.
+- **PostgreSQL** — Yüksek kullanıcı sayıları veya çoklu düğüm (multi-node) kurulumları için önerilir. Yükleyici sizin için yerel olarak PostgreSQL kurabilir veya mevcut bir sunucuya DSN bağlantısı kabul edebilir.
 
-Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
+Çalışma anında veritabanı türü ortam değişkenleri (environment variables) ile seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
 
 ```
 XUI_DB_TYPE=postgres
 XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
 ```
 
-### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
+### Mevcut bir SQLite Kurulumunu PostgreSQL'e Taşıma
 
 ```bash
 x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
-# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
+# ardından /etc/default/x-ui içindeki XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
 systemctl restart x-ui
 ```
 
-Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
+Kaynak SQLite dosyasına dokunulmaz; yeni veritabanının düzgün çalıştığını doğruladıktan sonra eski SQLite dosyasını manuel olarak silebilirsiniz.
 
 ### Docker
 
-Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
+Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Birlikte paketlenmiş PostgreSQL servisi ile çalıştırmak için, `docker-compose.yml` dosyasındaki iki `XUI_DB_*` değişken satırının yorumunu kaldırın ve profille başlatın:
 
 ```bash
 docker compose --profile postgres up -d
 ```
 
-İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
+Docker imajı, kullanıcı başına **IP limitlerini** zorunlu kılmak için Fail2ban ile (varsayılan olarak etkindir) paketlenmiştir. Fail2ban, ihlalcileri `iptables` ile engeller ve bunun için `NET_ADMIN` yetkisine ihtiyaç duyar. `docker-compose.yml` bunu zaten `cap_add` üzerinden vermektedir; ancak konteyneri bunun yerine `docker run` ile başlatırsanız bu yetkileri kendiniz eklemelisiniz, aksi takdirde yasaklamalar günlüğe kaydedilir ancak uygulanmaz:
 
 ```bash
 docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 ```
 
-## Ortam Değişkenleri
+## Ortam Değişkenleri (Environment Variables)
 
 | Değişken | Açıklama | Varsayılan |
 | --- | --- | --- |
-| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
-| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
-| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
+| `XUI_DB_TYPE` | Veritabanı türü: `sqlite` veya `postgres` | `sqlite` |
+| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (eğer `XUI_DB_TYPE=postgres` ise) | — |
+| `XUI_DB_FOLDER` | SQLite veritabanı dizini | `/etc/x-ui` |
 | `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
-| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
-| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
-| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
+| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bekleme bağlantısı (PostgreSQL havuzu) | — |
+| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
+| `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
 
 ## Desteklenen Diller
 
-Panel kullanıcı arayüzü 13 dilde mevcuttur:
+Panel arayüzü 13 farklı dilde mevcuttur:
 
-English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
+İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya)
 
 ## Katkıda Bulunma
 
-Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
+Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull request (PR) göndermeden önce lütfen [Katkıda Bulunma Kılavuzunu](/CONTRIBUTING.md) okuyun.
 
 ## Özel Teşekkürler
 
 - [alireza0](https://github.com/alireza0/)
 
-## Teşekkür
+## Teşekkür & Atıf
 
-- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
-- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
+- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme (routing) kuralları; yerleşik İran alan adları ile güvenlik ve reklam engelleme odaklıdır._
+- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Bu depo, Rusya'daki engellenen alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kurallarını içerir._
 
 ## Topluluk Araçları
 
-3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
+3x-ui çevresindeki topluluk tarafından oluşturulmuş araçlar ve entegrasyonlar.
 
-- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
+- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Gelen bağlantılarnı, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak (as code) yönetin._
 
 ## Projeyi Destekleyin
 
-**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
+**Eğer bu proje size faydalı olduysa, bir yıldız verebilirsiniz**:star2:
 
 <a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
-<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
+<img src="./media/default-yellow.png" alt="Bana Bir Kahve Ismarla" style="height: 70px !important;width: 277px !important;" >
 </a>
 
 </br>
 <a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
-   <img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
+   <img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
 </a>
 
-## Zaman İçindeki Yıldız Sayısı
+## Yıldız Tablosu
 
-[![Zaman İçindeki Yıldız Sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+[![Zaman içerisindeki yıldız sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

+ 1 - 1
config/version

@@ -1 +1 @@
-3.2.8
+3.3.0

+ 1 - 0
database/db.go

@@ -73,6 +73,7 @@ func initModels() error {
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
+		&model.OutboundSubscription{},
 	}
 	for _, mdl := range models {
 		if err := db.AutoMigrate(mdl); err != nil {

+ 0 - 20
database/dialect.go

@@ -2,9 +2,6 @@ package database
 
 import "fmt"
 
-// JSONClientsFromInbound returns the FROM clause that yields one row per element
-// of inbounds.settings -> clients, with a column named `client.value` whose text
-// fields can be read with JSONFieldText("client.value", "<key>").
 func JSONClientsFromInbound() string {
 	if IsPostgres() {
 		return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
@@ -27,26 +24,9 @@ func GreatestExpr(a, b string) string {
 	return fmt.Sprintf("MAX(%s, %s)", a, b)
 }
 
-// ClientTrafficEnableMergeExpr returns the SQL expression used in the
-// node traffic merge to update client_traffics.enable.
-//
-// The intent is: only allow the remote node to *disable* a client
-// (never re-enable one that the central panel has disabled).
-//
-// We use a dialect-specific expression because:
-// - On PostgreSQL we want strict boolean typing and casts to avoid
-//   "CASE types boolean and integer cannot be matched" errors
-//   (and similar internal expansions of AND/GREATEST).
-// - On SQLite, enable is stored with INTEGER affinity (0/1), there is
-//   no :: cast syntax, and we must produce a numeric-compatible result.
-//
-// The expression must be valid SQL for tx.Exec with a boolean parameter
-// as the first ?.
 func ClientTrafficEnableMergeExpr() string {
 	if IsPostgres() {
 		return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
 	}
-	// SQLite: no :: casts. Use numeric CASE. 1/0 work as true/false
-	// thanks to SQLite's affinity and how GORM/drivers bind bools.
 	return "CASE WHEN ? THEN enable ELSE 0 END"
 }

+ 15 - 3
database/migrate_data.go

@@ -20,9 +20,20 @@ import (
 	"gorm.io/gorm/logger"
 )
 
-// migrationModels is the FK-aware order in which tables are created and copied.
-// Parents come before their children so foreign-key constraints stay satisfied
-// even when checks are not explicitly disabled.
+// migrationModels is the FK-aware order in which tables are created and copied
+// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
+// related tests.
+//
+// Important: When adding a new top-level model (like OutboundSubscription),
+// you must add it here **in addition to** the list in database/db.go:initModels().
+// This list is used for:
+//   - Creating the destination schema during cross-DB migration
+//   - Truncating tables
+//   - Copying data row-by-row
+//   - Resyncing Postgres sequences after bulk insert
+//
+// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
+// so they do not need manual updates.
 func migrationModels() []any {
 	return []any{
 		&model.User{},
@@ -39,6 +50,7 @@ func migrationModels() []any {
 		&model.ClientInbound{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
+		&model.OutboundSubscription{},
 	}
 }
 

+ 28 - 0
database/model/model.go

@@ -478,6 +478,12 @@ type Node struct {
 	UptimeSecs    uint64  `json:"uptimeSecs" example:"86400"`
 	LastError     string  `json:"lastError"`
 
+	// XrayState and XrayError are captured from the remote node's /panel/api/server/status
+	// during heartbeats. They let the central panel distinguish "panel API reachable"
+	// (status=online) from "Xray core itself has failed on the node" for monitoring.
+	XrayState string `json:"xrayState" gorm:"column:xray_state"`
+	XrayError string `json:"xrayError" gorm:"column:xray_error"`
+
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 
@@ -514,6 +520,9 @@ type NodeSummary struct {
 	LatencyMs     int    `json:"latencyMs"`
 	PanelVersion  string `json:"panelVersion"`
 	XrayVersion   string `json:"xrayVersion"`
+	// XrayState/XrayError forwarded so masters can surface xray failure on transitive sub-nodes too.
+	XrayState string `json:"xrayState"`
+	XrayError string `json:"xrayError,omitempty"`
 }
 
 type CustomGeoResource struct {
@@ -705,6 +714,25 @@ type ClientMergeConflict struct {
 	Kept  any
 }
 
+type OutboundSubscription struct {
+	Id                   int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
+	Remark               string `json:"remark" form:"remark"`
+	Url                  string `json:"url" form:"url"`
+	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
+	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
+	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
+	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
+	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)
+	Prepend              bool   `json:"prepend" form:"prepend" gorm:"default:false"`             // place this subscription's outbounds before the manual template outbounds
+	LastUpdated          int64  `json:"lastUpdated" form:"lastUpdated"`
+	LastError            string `json:"lastError" form:"lastError"`
+	LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
+	LinkIdentities       string `json:"-" gorm:"type:text;column:link_identities"`
+	CreatedAt            int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt            int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+	OutboundCount        int    `json:"outboundCount" gorm:"-"`
+}
+
 func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
 	var conflicts []ClientMergeConflict
 	keep := func(field string, oldV, newV, kept any) {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 283 - 265
frontend/package-lock.json


+ 8 - 1
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.2.7",
+  "version": "0.3.0",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -65,5 +65,12 @@
     "react-debounce-input": {
       "react": "^19.0.0"
     }
+  },
+  "allowScripts": {
+    "@scarf/scarf": false,
+    "@tree-sitter-grammars/tree-sitter-yaml": false,
+    "tree-sitter": false,
+    "core-js-pure": false,
+    "tree-sitter-json": false
   }
 }

+ 408 - 0
frontend/public/openapi.json

@@ -243,6 +243,10 @@
             "description": "Subscription support URL",
             "type": "string"
           },
+          "subThemeDir": {
+            "description": "Absolute path to a folder containing a custom subscription page template",
+            "type": "string"
+          },
           "subTitle": {
             "description": "Subscription title",
             "type": "string"
@@ -321,6 +325,11 @@
             "description": "Two-factor authentication token",
             "type": "string"
           },
+          "warpUpdateInterval": {
+            "description": "WARP",
+            "minimum": 0,
+            "type": "integer"
+          },
           "webBasePath": {
             "description": "Base path for web panel URLs",
             "type": "string"
@@ -404,6 +413,7 @@
           "subRoutingRules",
           "subShowInfo",
           "subSupportUrl",
+          "subThemeDir",
           "subTitle",
           "subURI",
           "subUpdates",
@@ -422,6 +432,7 @@
           "trustedProxyCIDRs",
           "twoFactorEnable",
           "twoFactorToken",
+          "warpUpdateInterval",
           "webBasePath",
           "webCertFile",
           "webDomain",
@@ -666,6 +677,10 @@
             "description": "Subscription support URL",
             "type": "string"
           },
+          "subThemeDir": {
+            "description": "Absolute path to a folder containing a custom subscription page template",
+            "type": "string"
+          },
           "subTitle": {
             "description": "Subscription title",
             "type": "string"
@@ -744,6 +759,11 @@
             "description": "Two-factor authentication token",
             "type": "string"
           },
+          "warpUpdateInterval": {
+            "description": "WARP",
+            "minimum": 0,
+            "type": "integer"
+          },
           "webBasePath": {
             "description": "Base path for web panel URLs",
             "type": "string"
@@ -833,6 +853,7 @@
           "subRoutingRules",
           "subShowInfo",
           "subSupportUrl",
+          "subThemeDir",
           "subTitle",
           "subURI",
           "subUpdates",
@@ -851,6 +872,7 @@
           "trustedProxyCIDRs",
           "twoFactorEnable",
           "twoFactorToken",
+          "warpUpdateInterval",
           "webBasePath",
           "webCertFile",
           "webDomain",
@@ -1656,6 +1678,13 @@
             "example": 86400,
             "type": "integer"
           },
+          "xrayError": {
+            "type": "string"
+          },
+          "xrayState": {
+            "description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
+            "type": "string"
+          },
           "xrayVersion": {
             "example": "25.10.31",
             "type": "string"
@@ -1691,6 +1720,8 @@
           "tlsVerifyMode",
           "updatedAt",
           "uptimeSecs",
+          "xrayError",
+          "xrayState",
           "xrayVersion"
         ],
         "type": "object"
@@ -1752,6 +1783,13 @@
             "example": 86400,
             "type": "integer"
           },
+          "xrayError": {
+            "type": "string"
+          },
+          "xrayState": {
+            "description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
+            "type": "string"
+          },
           "xrayVersion": {
             "example": "25.10.31",
             "type": "string"
@@ -1765,6 +1803,8 @@
           "panelVersion",
           "status",
           "uptimeSecs",
+          "xrayError",
+          "xrayState",
           "xrayVersion"
         ],
         "type": "object"
@@ -4032,6 +4072,79 @@
         }
       }
     },
+    "/panel/api/server/clientIps": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.",
+        "operationId": "get_panel_api_server_clientIps",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "type": "array",
+                      "items": {
+                        "$ref": "#/components/schemas/InboundClientIps"
+                      }
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "clientEmail": "",
+                      "id": 0,
+                      "ips": null
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.",
+        "operationId": "post_panel_api_server_clientIps",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/list": {
       "get": {
         "tags": [
@@ -5894,6 +6007,8 @@
                       "transitive": false,
                       "updatedAt": 1700000000,
                       "uptimeSecs": 86400,
+                      "xrayError": "",
+                      "xrayState": "",
                       "xrayVersion": "25.10.31"
                     }
                   ]
@@ -6254,6 +6369,8 @@
                     "panelVersion": "v3.x.x",
                     "status": "online",
                     "uptimeSecs": 86400,
+                    "xrayError": "",
+                    "xrayState": "",
                     "xrayVersion": "25.10.31"
                   }
                 }
@@ -7552,6 +7669,297 @@
         }
       }
     },
+    "/panel/api/xray/outbound-subs": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.",
+        "operationId": "get_panel_api_xray_outbound_subs",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.",
+        "operationId": "post_panel_api_xray_outbound_subs",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Update an existing outbound subscription by id. Accepts the same form fields as create.",
+        "operationId": "post_panel_api_xray_outbound_subs_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Delete an outbound subscription by id.",
+        "operationId": "delete_panel_api_xray_outbound_subs_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/del": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).",
+        "operationId": "post_panel_api_xray_outbound_subs_id_del",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/refresh": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.",
+        "operationId": "post_panel_api_xray_outbound_subs_id_refresh",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/move": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).",
+        "operationId": "post_panel_api_xray_outbound_subs_id_move",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/parse": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Preview a subscription URL: fetch and parse it into outbounds without persisting anything.",
+        "operationId": "post_panel_api_xray_outbound_subs_parse",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/{subPath}{subid}": {
       "get": {
         "tags": [

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

@@ -56,6 +56,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subRoutingRules": "",
     "subShowInfo": false,
     "subSupportUrl": "",
+    "subThemeDir": "",
     "subTitle": "",
     "subURI": "",
     "subUpdates": 0,
@@ -74,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "trustedProxyCIDRs": "",
     "twoFactorEnable": false,
     "twoFactorToken": "",
+    "warpUpdateInterval": 0,
     "webBasePath": "",
     "webCertFile": "",
     "webDomain": "",
@@ -143,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subRoutingRules": "",
     "subShowInfo": false,
     "subSupportUrl": "",
+    "subThemeDir": "",
     "subTitle": "",
     "subURI": "",
     "subUpdates": 0,
@@ -161,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "trustedProxyCIDRs": "",
     "twoFactorEnable": false,
     "twoFactorToken": "",
+    "warpUpdateInterval": 0,
     "webBasePath": "",
     "webCertFile": "",
     "webDomain": "",
@@ -364,6 +368,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "transitive": false,
     "updatedAt": 1700000000,
     "uptimeSecs": 86400,
+    "xrayError": "",
+    "xrayState": "",
     "xrayVersion": "25.10.31"
   },
   "OutboundTraffics": {
@@ -381,6 +387,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "panelVersion": "v3.x.x",
     "status": "online",
     "uptimeSecs": 86400,
+    "xrayError": "",
+    "xrayState": "",
     "xrayVersion": "25.10.31"
   },
   "Setting": {

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

@@ -217,6 +217,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Subscription support URL",
         "type": "string"
       },
+      "subThemeDir": {
+        "description": "Absolute path to a folder containing a custom subscription page template",
+        "type": "string"
+      },
       "subTitle": {
         "description": "Subscription title",
         "type": "string"
@@ -295,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Two-factor authentication token",
         "type": "string"
       },
+      "warpUpdateInterval": {
+        "description": "WARP",
+        "minimum": 0,
+        "type": "integer"
+      },
       "webBasePath": {
         "description": "Base path for web panel URLs",
         "type": "string"
@@ -378,6 +387,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules",
       "subShowInfo",
       "subSupportUrl",
+      "subThemeDir",
       "subTitle",
       "subURI",
       "subUpdates",
@@ -396,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "trustedProxyCIDRs",
       "twoFactorEnable",
       "twoFactorToken",
+      "warpUpdateInterval",
       "webBasePath",
       "webCertFile",
       "webDomain",
@@ -640,6 +651,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Subscription support URL",
         "type": "string"
       },
+      "subThemeDir": {
+        "description": "Absolute path to a folder containing a custom subscription page template",
+        "type": "string"
+      },
       "subTitle": {
         "description": "Subscription title",
         "type": "string"
@@ -718,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Two-factor authentication token",
         "type": "string"
       },
+      "warpUpdateInterval": {
+        "description": "WARP",
+        "minimum": 0,
+        "type": "integer"
+      },
       "webBasePath": {
         "description": "Base path for web panel URLs",
         "type": "string"
@@ -807,6 +827,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subRoutingRules",
       "subShowInfo",
       "subSupportUrl",
+      "subThemeDir",
       "subTitle",
       "subURI",
       "subUpdates",
@@ -825,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "trustedProxyCIDRs",
       "twoFactorEnable",
       "twoFactorToken",
+      "warpUpdateInterval",
       "webBasePath",
       "webCertFile",
       "webDomain",
@@ -1630,6 +1652,13 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 86400,
         "type": "integer"
       },
+      "xrayError": {
+        "type": "string"
+      },
+      "xrayState": {
+        "description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
+        "type": "string"
+      },
       "xrayVersion": {
         "example": "25.10.31",
         "type": "string"
@@ -1665,6 +1694,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "tlsVerifyMode",
       "updatedAt",
       "uptimeSecs",
+      "xrayError",
+      "xrayState",
       "xrayVersion"
     ],
     "type": "object"
@@ -1726,6 +1757,13 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 86400,
         "type": "integer"
       },
+      "xrayError": {
+        "type": "string"
+      },
+      "xrayState": {
+        "description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
+        "type": "string"
+      },
       "xrayVersion": {
         "example": "25.10.31",
         "type": "string"
@@ -1739,6 +1777,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "panelVersion",
       "status",
       "uptimeSecs",
+      "xrayError",
+      "xrayState",
       "xrayVersion"
     ],
     "type": "object"

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

@@ -61,6 +61,7 @@ export interface AllSetting {
   subRoutingRules: string;
   subShowInfo: boolean;
   subSupportUrl: string;
+  subThemeDir: string;
   subTitle: string;
   subURI: string;
   subUpdates: number;
@@ -79,6 +80,7 @@ export interface AllSetting {
   trustedProxyCIDRs: string;
   twoFactorEnable: boolean;
   twoFactorToken: string;
+  warpUpdateInterval: number;
   webBasePath: string;
   webCertFile: string;
   webDomain: string;
@@ -149,6 +151,7 @@ export interface AllSettingView {
   subRoutingRules: string;
   subShowInfo: boolean;
   subSupportUrl: string;
+  subThemeDir: string;
   subTitle: string;
   subURI: string;
   subUpdates: number;
@@ -167,6 +170,7 @@ export interface AllSettingView {
   trustedProxyCIDRs: string;
   twoFactorEnable: boolean;
   twoFactorToken: string;
+  warpUpdateInterval: number;
   webBasePath: string;
   webCertFile: string;
   webDomain: string;
@@ -371,6 +375,8 @@ export interface Node {
   transitive?: boolean;
   updatedAt: number;
   uptimeSecs: number;
+  xrayError: string;
+  xrayState: string;
   xrayVersion: string;
 }
 
@@ -390,6 +396,8 @@ export interface ProbeResultUI {
   panelVersion: string;
   status: string;
   uptimeSecs: number;
+  xrayError: string;
+  xrayState: string;
   xrayVersion: string;
 }
 

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

@@ -71,6 +71,7 @@ export const AllSettingSchema = z.object({
   subRoutingRules: z.string(),
   subShowInfo: z.boolean(),
   subSupportUrl: z.string(),
+  subThemeDir: z.string(),
   subTitle: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
@@ -89,6 +90,7 @@ export const AllSettingSchema = z.object({
   trustedProxyCIDRs: z.string(),
   twoFactorEnable: z.boolean(),
   twoFactorToken: z.string(),
+  warpUpdateInterval: z.number().int().min(0),
   webBasePath: z.string(),
   webCertFile: z.string(),
   webDomain: z.string(),
@@ -160,6 +162,7 @@ export const AllSettingViewSchema = z.object({
   subRoutingRules: z.string(),
   subShowInfo: z.boolean(),
   subSupportUrl: z.string(),
+  subThemeDir: z.string(),
   subTitle: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
@@ -178,6 +181,7 @@ export const AllSettingViewSchema = z.object({
   trustedProxyCIDRs: z.string(),
   twoFactorEnable: z.boolean(),
   twoFactorToken: z.string(),
+  warpUpdateInterval: z.number().int().min(0),
   webBasePath: z.string(),
   webCertFile: z.string(),
   webDomain: z.string(),
@@ -398,6 +402,8 @@ export const NodeSchema = z.object({
   transitive: z.boolean().optional(),
   updatedAt: z.number().int(),
   uptimeSecs: z.number().int(),
+  xrayError: z.string(),
+  xrayState: z.string(),
   xrayVersion: z.string(),
 });
 export type Node = z.infer<typeof NodeSchema>;
@@ -419,6 +425,8 @@ export const ProbeResultUISchema = z.object({
   panelVersion: z.string(),
   status: z.string(),
   uptimeSecs: z.number().int(),
+  xrayError: z.string(),
+  xrayState: z.string(),
   xrayVersion: z.string(),
 });
 export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;

+ 62 - 30
frontend/src/hooks/useXraySetting.ts

@@ -51,9 +51,12 @@ export interface UseXraySettingResult {
   setOutboundTestUrl: (v: string) => void;
   inboundTags: string[];
   clientReverseTags: string[];
+  subscriptionOutbounds: unknown[];
+  subscriptionOutboundTags: string[];
   restartResult: string;
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
+  subscriptionTestStates: Record<string, OutboundTestState>;
   testingAll: boolean;
   fetchAll: () => Promise<void>;
   fetchOutboundsTraffic: () => Promise<void>;
@@ -63,6 +66,11 @@ export interface UseXraySettingResult {
     outbound: unknown,
     mode?: string,
   ) => Promise<OutboundTestResult | null>;
+  testSubscriptionOutbound: (
+    tag: string,
+    outbound: unknown,
+    mode?: string,
+  ) => Promise<OutboundTestResult | null>;
   testAllOutbounds: (mode?: string) => Promise<void>;
   saveAll: () => Promise<void>;
   resetToDefault: () => Promise<void>;
@@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult {
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
   const [inboundTags, setInboundTags] = useState<string[]>([]);
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
+  const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
+  const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
   const [restartResult, setRestartResult] = useState('');
   const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
+  // Subscription outbounds aren't in templateSettings.outbounds, so their test
+  // results are keyed by tag rather than by index.
+  const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
   const [testingAll, setTestingAll] = useState(false);
 
   const oldXraySettingRef = useRef('');
@@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult {
     syncingRef.current = false;
     setInboundTags(obj.inboundTags || []);
     setClientReverseTags(obj.clientReverseTags || []);
+    setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
+    setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
     const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
     setOutboundTestUrlState(nextUrl);
     oldOutboundTestUrlRef.current = nextUrl;
@@ -255,14 +270,10 @@ export function useXraySetting(): UseXraySettingResult {
 
   const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
 
-  const testOutbound = useCallback(
-    async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
-      if (!outbound) return null;
-      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
-      setOutboundTestStates((prev) => ({
-        ...prev,
-        [index]: { testing: true, result: null, mode: effMode },
-      }));
+  // Shared POST + parse for a single outbound test. Returns an OutboundTestResult
+  // (success or a failure-shaped result); callers store it under their own key.
+  const postOutboundTest = useCallback(
+    async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
       try {
         const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
@@ -270,34 +281,47 @@ export function useXraySetting(): UseXraySettingResult {
           mode: effMode,
         });
         const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
-        if (msg?.success && msg.obj) {
-          setOutboundTestStates((prev) => ({
-            ...prev,
-            [index]: { testing: false, result: msg.obj },
-          }));
-          return msg.obj;
-        }
-        setOutboundTestStates((prev) => ({
-          ...prev,
-          [index]: {
-            testing: false,
-            result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
-          },
-        }));
+        if (msg?.success && msg.obj) return msg.obj;
+        return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
       } catch (e) {
-        setOutboundTestStates((prev) => ({
-          ...prev,
-          [index]: {
-            testing: false,
-            result: { success: false, error: String(e), mode: effMode },
-          },
-        }));
+        return { success: false, error: String(e), mode: effMode };
       }
-      return null;
     },
     [],
   );
 
+  const testOutbound = useCallback(
+    async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
+      if (!outbound) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      setOutboundTestStates((prev) => ({
+        ...prev,
+        [index]: { testing: true, result: null, mode: effMode },
+      }));
+      const result = await postOutboundTest(outbound, effMode);
+      setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
+      return result.success ? result : null;
+    },
+    [postOutboundTest],
+  );
+
+  // Test a subscription outbound (not present in templateSettings.outbounds);
+  // results are keyed by tag in subscriptionTestStates.
+  const testSubscriptionOutbound = useCallback(
+    async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
+      if (!outbound || !tag) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      setSubscriptionTestStates((prev) => ({
+        ...prev,
+        [tag]: { testing: true, result: null, mode: effMode },
+      }));
+      const result = await postOutboundTest(outbound, effMode);
+      setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
+      return result.success ? result : null;
+    },
+    [postOutboundTest],
+  );
+
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
     const list = templateSettingsRef.current?.outbounds || [];
     if (list.length === 0 || testingAll) return;
@@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult {
       setOutboundTestUrl,
       inboundTags,
       clientReverseTags,
+      subscriptionOutbounds,
+      subscriptionOutboundTags,
       restartResult,
       outboundsTraffic,
       outboundTestStates,
+      subscriptionTestStates,
       testingAll,
       fetchAll,
       fetchOutboundsTraffic: fetchOutboundsTrafficCb,
       resetOutboundsTraffic,
       testOutbound,
+      testSubscriptionOutbound,
       testAllOutbounds,
       saveAll,
       resetToDefault,
@@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult {
       setOutboundTestUrl,
       inboundTags,
       clientReverseTags,
+      subscriptionOutbounds,
+      subscriptionOutboundTags,
       restartResult,
       outboundsTraffic,
       outboundTestStates,
+      subscriptionTestStates,
       testingAll,
       fetchAll,
       fetchOutboundsTrafficCb,
       resetOutboundsTraffic,
       testOutbound,
+      testSubscriptionOutbound,
       testAllOutbounds,
       saveAll,
       resetToDefault,

+ 6 - 3
frontend/src/layouts/AppSidebar.tsx

@@ -40,7 +40,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 
-type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
+type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
 
 const iconByName: Record<IconName, ComponentType> = {
   dashboard: DashboardOutlined,
@@ -52,6 +52,7 @@ const iconByName: Record<IconName, ComponentType> = {
   cluster: ClusterOutlined,
   logout: LogoutOutlined,
   apidocs: ApiOutlined,
+  outbound: UploadOutlined,
 };
 
 function readCollapsed(): boolean {
@@ -137,6 +138,7 @@ export default function AppSidebar() {
     { key: '/clients', icon: 'team', title: t('menu.clients') },
     { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
+    { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },
     { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
@@ -162,7 +164,6 @@ export default function AppSidebar() {
   const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
     { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
     { key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
-    { key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
     { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
     { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
     { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
@@ -176,7 +177,9 @@ export default function AppSidebar() {
       ? `/xray${hash || '#basic'}`
       : (pathname === '' ? '/' : pathname);
 
-  const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
+  // The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
+  // Xray Configs submenu for it.
+  const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
   const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
   useEffect(() => {
     if (openSubmenu) {

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

@@ -60,6 +60,7 @@ export class AllSetting {
   subJsonMux = '';
   subJsonRules = '';
   subJsonFinalMask = '';
+  subThemeDir = '';
 
   timeLocation = 'Local';
 

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

@@ -442,6 +442,21 @@ export const sections: readonly Section[] = [
         body: 'sni=example.com',
         response: '{\n  "success": true,\n  "obj": {\n    "echKeySet": "...",\n    "echServerKeys": [...],\n    "echConfigList": "..."\n  }\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/clientIps',
+        summary: 'Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.',
+        responseSchema: 'InboundClientIps',
+        responseSchemaArray: true,
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/clientIps',
+        summary: 'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
+        params: [
+          { name: 'ips', in: 'body (json)', type: 'object[]', desc: 'Array of InboundClientIps to merge.' },
+        ],
+      },
     ],
   },
 
@@ -1085,6 +1100,74 @@ export const sections: readonly Section[] = [
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/outbound-subs',
+        summary: 'List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs',
+        summary: 'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
+        params: [
+          { name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
+          { name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.' },
+          { name: 'tagPrefix', in: 'body (form)', type: 'string', desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".' },
+          { name: 'updateInterval', in: 'body (form)', type: 'integer', desc: 'Seconds between auto-refreshes. Default 600.' },
+          { name: 'enabled', in: 'body (form)', type: 'boolean', desc: 'Whether the subscription is active. Default true.' },
+          { name: 'allowPrivate', in: 'body (form)', type: 'boolean', desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).' },
+          { name: 'prepend', in: 'body (form)', type: 'boolean', desc: 'Place this subscription\'s outbounds before the manual template outbounds (so one can become the default). Default false.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id',
+        summary: 'Update an existing outbound subscription by id. Accepts the same form fields as create.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'DELETE',
+        path: '/panel/api/xray/outbound-subs/:id',
+        summary: 'Delete an outbound subscription by id.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/del',
+        summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/refresh',
+        summary: 'Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/move',
+        summary: 'Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+          { name: 'dir', in: 'body (form)', type: 'string', desc: '"up" to raise priority, anything else to lower it.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/parse',
+        summary: 'Preview a subscription URL: fetch and parse it into outbounds without persisting anything.',
+        params: [
+          { name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL to preview (required).' },
+        ],
+      },
     ],
   },
 

+ 13 - 5
frontend/src/pages/groups/GroupsPage.tsx

@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { usePageTitle } from '@/hooks/usePageTitle';
 import { useClients } from '@/hooks/useClients';
-import { HttpUtil } from '@/utils';
+import { HttpUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
 import AppSidebar from '@/layouts/AppSidebar';
 import { LazyMount } from '@/components/utility';
@@ -161,8 +161,8 @@ export default function GroupsPage() {
     () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
     [groups],
   );
-  const emptyGroups = useMemo(
-    () => groups.filter((g) => (g.clientCount || 0) === 0).length,
+  const totalTraffic = useMemo(
+    () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
     [groups],
   );
 
@@ -417,6 +417,13 @@ export default function GroupsPage() {
       width: 180,
       render: (count: number) => <span>{count || 0}</span>,
     },
+    {
+      title: t('pages.groups.trafficUsed'),
+      dataIndex: 'trafficUsed',
+      key: 'trafficUsed',
+      width: 160,
+      render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
+    },
   ];
 
   const pageClass = useMemo(() => {
@@ -465,8 +472,9 @@ export default function GroupsPage() {
                         </Col>
                         <Col xs={12} sm={8} md={6}>
                           <Statistic
-                            title={t('pages.groups.emptyGroups')}
-                            value={String(emptyGroups)}
+                            title={t('pages.groups.totalTraffic')}
+                            value={SizeFormatter.sizeFormat(totalTraffic)}
+                            prefix={<RetweetOutlined />}
                           />
                         </Col>
                       </Row>

+ 3 - 3
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -171,7 +171,7 @@ export function useInboundColumns({
                     </div>
                   )}
                 >
-                  <Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.active.length}</Tag>
+                  <Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
                 </Popover>
               )}
               {cc.deactive.length > 0 && (
@@ -183,7 +183,7 @@ export function useInboundColumns({
                     </div>
                   )}
                 >
-                  <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
+                  <Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.deactive.length}</Tag>
                 </Popover>
               )}
               {cc.depleted.length > 0 && (
@@ -195,7 +195,7 @@ export function useInboundColumns({
                     </div>
                   )}
                 >
-                  <Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
+                  <Tag color="red" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.depleted.length}</Tag>
                 </Popover>
               )}
               {cc.online.length > 0 && (

+ 79 - 28
frontend/src/pages/nodes/NodeList.tsx

@@ -68,18 +68,63 @@ function badgeStatus(status?: string): BadgeProps['status'] {
   }
 }
 
-function StatusDot({ status }: { status?: string }) {
-  if (status === 'online') return <span className="online-dot" />;
+interface HealthProps {
+  status?: string;
+  xrayState?: string;
+  xrayError?: string;
+}
+
+// Purple: the node's panel API is reachable (status=online) but its Xray core
+// has failed or been stopped. Distinct from a normal offline/unknown node.
+const XRAY_ERROR_COLOR = '#722ED1';
+
+// True when the panel is online but Xray itself reports error/stop.
+function hasXrayProblem(status?: string, xrayState?: string): boolean {
+  if (status !== 'online') return false;
+  const xs = (xrayState || '').toLowerCase().trim();
+  return xs === 'error' || xs === 'stop';
+}
+
+// Tooltip text + icon color for the status cell. A real probe error (lastError)
+// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
+function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
+  const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
+  const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
+    ? XRAY_ERROR_COLOR
+    : 'var(--ant-color-warning)';
+  return { tip, iconColor };
+}
+
+function StatusDot({ status, xrayState }: HealthProps) {
+  if (status === 'online') {
+    return hasXrayProblem(status, xrayState)
+      ? <span className="xray-error-dot" />
+      : <span className="online-dot" />;
+  }
   return <Badge status={badgeStatus(status)} />;
 }
 
-function StatusLabel({ status }: { status?: string }) {
+function StatusLabel({ status, xrayState }: HealthProps) {
   const { t } = useTranslation();
-  return (
-    <span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
-      {t(`pages.nodes.statusValues.${status || 'unknown'}`)}
-    </span>
-  );
+  if (status === 'online') {
+    const xs = (xrayState || '').toLowerCase().trim();
+    if (xs === 'error' || xs === 'stop') {
+      const detail = xs === 'error'
+        ? t('pages.nodes.statusValues.xrayError')
+        : t('pages.nodes.statusValues.xrayStopped');
+      return (
+        <span style={{ color: XRAY_ERROR_COLOR }}>
+          {t('pages.nodes.statusValues.online')} ({detail})
+        </span>
+      );
+    }
+    return (
+      <span style={{ color: 'var(--ant-color-success)' }}>
+        {t('pages.nodes.statusValues.online')}
+      </span>
+    );
+  }
+  return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
 }
 
 function formatPct(p?: number): string {
@@ -271,17 +316,20 @@ export default function NodeList({
       title: t('pages.nodes.status'),
       dataIndex: 'status',
       align: 'center',
-      render: (_value, record) => (
-        <Space size={4}>
-          <StatusDot status={record.status} />
-          <StatusLabel status={record.status} />
-          {record.lastError && (
-            <Tooltip title={record.lastError}>
-              <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
-            </Tooltip>
-          )}
-        </Space>
-      ),
+      render: (_value, record) => {
+        const { tip, iconColor } = statusIssue(record);
+        return (
+          <Space size={4}>
+            <StatusDot status={record.status} xrayState={record.xrayState} />
+            <StatusLabel status={record.status} xrayState={record.xrayState} />
+            {tip && (
+              <Tooltip title={tip}>
+                <ExclamationCircleOutlined style={{ color: iconColor }} />
+              </Tooltip>
+            )}
+          </Space>
+        );
+      },
     },
     {
       title: t('pages.nodes.cpu'),
@@ -389,7 +437,7 @@ export default function NodeList({
                 <div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
                   <div className="card-head">
                     <ApartmentOutlined style={{ opacity: 0.6 }} />
-                    <StatusDot status={record.status} />
+                    <StatusDot status={record.status} xrayState={record.xrayState} />
                     <span className="node-name">{record.name}</span>
                     <div className="card-actions">
                       <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
@@ -400,7 +448,7 @@ export default function NodeList({
                 <div key={record.id} className="node-card">
                   <div className="card-head" onClick={() => toggleExpanded(record.id)}>
                     <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
-                    <StatusDot status={record.status} />
+                    <StatusDot status={record.status} xrayState={record.xrayState} />
                     <span className="node-name">{record.name}</span>
                     <div className="card-actions" onClick={(e) => e.stopPropagation()}>
                       <Tooltip title={t('info')}>
@@ -494,13 +542,16 @@ export default function NodeList({
                 </div>
                 <div className="stat-row">
                   <span className="stat-label">{t('pages.nodes.status')}</span>
-                  <StatusDot status={statsNode.status} />
-                  <StatusLabel status={statsNode.status} />
-                  {statsNode.lastError && (
-                    <Tooltip title={statsNode.lastError}>
-                      <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
-                    </Tooltip>
-                  )}
+                  <StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
+                  <StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
+                  {(() => {
+                    const { tip, iconColor } = statusIssue(statsNode);
+                    return tip ? (
+                      <Tooltip title={tip}>
+                        <ExclamationCircleOutlined style={{ color: iconColor }} />
+                      </Tooltip>
+                    ) : null;
+                  })()}
                 </div>
                 <div className="stat-row">
                   <span className="stat-label">{t('pages.nodes.cpu')}</span>

+ 4 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -83,12 +83,15 @@ export default function NodesPage() {
     const msg = await probe(node.id);
     if (msg?.success && msg.obj) {
       if (msg.obj.status === 'online') {
+        // Even if xray is in error/stop on the node we still reached its panel API.
         messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
       } else {
         messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
       }
     }
-  }, [probe, t, messageApi]);
+    // Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
+    refetch();
+  }, [probe, t, messageApi, refetch]);
 
   const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
     await setEnable(node.id, next);

+ 5 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
                 onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
+              <Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
+                onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
+            </SettingListItem>
+
             <Divider>Happ</Divider>
 
             <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>

+ 18 - 5
frontend/src/pages/xray/XrayPage.tsx

@@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form';
 import { setMessageInstance } from '@/utils/messageBus';
 
 import { BasicsTab } from './basics';
+import { propagateOutboundTagRename } from './basics/helpers';
 import { RoutingTab } from './routing';
 import { OutboundsTab } from './outbounds';
 import { BalancersTab } from './balancers';
@@ -60,13 +61,17 @@ export default function XrayPage() {
     setOutboundTestUrl,
     inboundTags,
     clientReverseTags,
+    subscriptionOutbounds,
+    subscriptionOutboundTags,
     restartResult,
     outboundsTraffic,
     outboundTestStates,
+    subscriptionTestStates,
     testingAll,
     fetchAll,
     resetOutboundsTraffic,
     testOutbound,
+    testSubscriptionOutbound,
     testAllOutbounds,
     saveAll,
     resetToDefault,
@@ -99,6 +104,11 @@ export default function XrayPage() {
     if (outbound) await testOutbound(idx, outbound, mode);
   }
 
+  async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
+    const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
+    if (tag) await testSubscriptionOutbound(tag, outbound, mode);
+  }
+
   function onAddOutbound(outbound: Record<string, unknown>) {
     mutate((tt) => {
       if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -109,11 +119,8 @@ export default function XrayPage() {
     mutate((tt) => {
       if (!tt.outbounds || payload.index < 0) return;
       tt.outbounds[payload.index] = payload.outbound as never;
-      if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) {
-        const rules = tt.routing?.rules || [];
-        for (const r of rules) {
-          if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
-        }
+      if (payload.oldTag && payload.newTag) {
+        propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
       }
     });
   }
@@ -214,6 +221,7 @@ export default function XrayPage() {
             setTemplateSettings={setTemplateSettings}
             inboundTags={inboundTags}
             clientReverseTags={clientReverseTags}
+            subscriptionOutboundTags={subscriptionOutboundTags}
             isMobile={isMobile}
           />
         );
@@ -224,14 +232,18 @@ export default function XrayPage() {
             setTemplateSettings={setTemplateSettings}
             outboundsTraffic={outboundsTraffic}
             outboundTestStates={outboundTestStates}
+            subscriptionTestStates={subscriptionTestStates}
             testingAll={testingAll}
             inboundTags={inboundTags}
+            subscriptionOutbounds={subscriptionOutbounds}
             isMobile={isMobile}
             onResetTraffic={resetOutboundsTraffic}
             onTest={onTestOutbound}
+            onTestSubscription={onTestSubscription}
             onTestAll={testAllOutbounds}
             onShowWarp={() => setWarpOpen(true)}
             onShowNord={() => setNordOpen(true)}
+            onRefreshXrayData={fetchAll}
           />
         );
       case 'balancer':
@@ -240,6 +252,7 @@ export default function XrayPage() {
             templateSettings={templateSettings}
             setTemplateSettings={setTemplateSettings}
             clientReverseTags={clientReverseTags}
+            subscriptionOutboundTags={subscriptionOutboundTags}
             isMobile={isMobile}
           />
         );

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

@@ -18,6 +18,7 @@ interface BalancersTabProps {
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
   clientReverseTags: string[];
+  subscriptionOutboundTags?: string[];
   isMobile: boolean;
 }
 
@@ -90,6 +91,7 @@ export default function BalancersTab({
   templateSettings,
   setTemplateSettings,
   clientReverseTags,
+  subscriptionOutboundTags,
   isMobile,
 }: BalancersTabProps) {
   const { t } = useTranslation();
@@ -118,8 +120,11 @@ export default function BalancersTab({
     for (const tag of clientReverseTags || []) {
       if (tag) tags.add(tag);
     }
+    for (const tag of subscriptionOutboundTags || []) {
+      if (tag) tags.add(tag);
+    }
     return [...tags];
-  }, [templateSettings?.outbounds, clientReverseTags]);
+  }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
 
   const otherTags = useMemo(() => {
     if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);

+ 33 - 0
frontend/src/pages/xray/basics/helpers.ts

@@ -54,3 +54,36 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record
   if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
   if (haveRules && idx < 0) t.outbounds.push(settings as never);
 }
+
+export function propagateOutboundTagRename(
+  t: XraySettingsValue,
+  oldTag: string,
+  newTag: string,
+): void {
+  if (!oldTag || !newTag || oldTag === newTag) return;
+
+  const rules = t.routing?.rules;
+  if (Array.isArray(rules)) {
+    for (const rule of rules) {
+      if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
+    }
+  }
+
+  const balancers = t.routing?.balancers;
+  if (Array.isArray(balancers)) {
+    for (const balancer of balancers) {
+      if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
+      if (Array.isArray(balancer?.selector)) {
+        balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
+      }
+    }
+  }
+
+  if (Array.isArray(t.outbounds)) {
+    for (const outbound of t.outbounds) {
+      const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
+        ?.streamSettings?.sockopt;
+      if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
+    }
+  }
+}

+ 14 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.css

@@ -209,3 +209,17 @@
 .outbound-test-popover .dot-fail {
   color: #e04141;
 }
+
+.subscription-outbounds-head {
+  margin-bottom: 8px;
+}
+
+.subscription-outbounds-title {
+  font-weight: 600;
+  margin-bottom: 2px;
+}
+
+.subscription-outbounds-desc {
+  font-size: 12px;
+  opacity: 0.7;
+}

+ 428 - 6
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -3,43 +3,82 @@ import { useTranslation } from 'react-i18next';
 import {
   Button,
   Col,
+  Dropdown,
+  Form,
+  Input,
+  InputNumber,
   Modal,
   Popconfirm,
   Radio,
   Row,
   Space,
+  Switch,
   Table,
+  Tag,
   Tooltip,
+  message,
 } from 'antd';
 import {
   PlusOutlined,
   CloudOutlined,
   ApiOutlined,
+  MoreOutlined,
   RetweetOutlined,
   PlayCircleOutlined,
+  ReloadOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+  CheckCircleOutlined,
+  WarningOutlined,
 } from '@ant-design/icons';
 
+import { HttpUtil } from '@/utils';
+
 import OutboundFormModal from './OutboundFormModal';
+import { propagateOutboundTagRename } from '../basics/helpers';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 
 import type { OutboundRow } from './outbounds-tab-types';
 import { useOutboundColumns } from './useOutboundColumns';
 import OutboundCardList from './OutboundCardList';
+import SubscriptionOutbounds from './SubscriptionOutbounds';
+
+interface OutboundSub {
+  id: number;
+  remark?: string;
+  url?: string;
+  enabled?: boolean;
+  allowPrivate?: boolean;
+  prepend?: boolean;
+  priority?: number;
+  tagPrefix?: string;
+  updateInterval?: number;
+  lastUpdated?: number;
+  lastError?: string;
+  outboundCount?: number;
+}
 
 interface OutboundsTabProps {
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
+  subscriptionTestStates: Record<string, OutboundTestState>;
   testingAll: boolean;
   inboundTags: string[];
+  subscriptionOutbounds?: unknown[];
   isMobile: boolean;
   onResetTraffic: (tag: string) => void;
   onTest: (index: number, mode: string) => void;
+  onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
   onTestAll: (mode: string) => void;
   onShowWarp: () => void;
   onShowNord: () => void;
+  onRefreshXrayData?: () => void;
 }
 
 export default function OutboundsTab({
@@ -47,23 +86,49 @@ export default function OutboundsTab({
   setTemplateSettings,
   outboundsTraffic,
   outboundTestStates,
+  subscriptionTestStates,
   testingAll,
   inboundTags: _inboundTags,
+  subscriptionOutbounds,
   isMobile,
   onResetTraffic,
   onTest,
+  onTestSubscription,
   onTestAll,
   onShowWarp,
   onShowNord,
+  onRefreshXrayData,
 }: OutboundsTabProps) {
   const { t } = useTranslation();
   const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
   const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
   const [modalOpen, setModalOpen] = useState(false);
   const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
   const [editingIndex, setEditingIndex] = useState<number | null>(null);
   const [existingTags, setExistingTags] = useState<string[]>([]);
 
+  // Subscription manager (CRUD + reorder + refresh + preview)
+  const [subDrawerOpen, setSubDrawerOpen] = useState(false);
+  const [subs, setSubs] = useState<OutboundSub[]>([]);
+  const [subsLoading, setSubsLoading] = useState(false);
+  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+  const [editingSubId, setEditingSubId] = useState<number | null>(null);
+  const [savingSub, setSavingSub] = useState(false);
+  const [refreshingId, setRefreshingId] = useState<number | null>(null);
+  const [refreshingAll, setRefreshingAll] = useState(false);
+  const [busyId, setBusyId] = useState<number | null>(null);
+  const [previewing, setPreviewing] = useState(false);
+  const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
+
+  // Convenience: expose hours/minutes for the interval input
+  const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
+  const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
+  function setIntervalHM(h: number, m: number) {
+    const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
+    setNewSub((prev) => ({ ...prev, updateInterval: secs }));
+  }
+
   const outbounds = useMemo(
     () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     [templateSettings?.outbounds],
@@ -89,6 +154,11 @@ export default function OutboundsTab({
     setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
     setModalOpen(true);
   }
+
+  function openSubManager() {
+    setSubDrawerOpen(true);
+    loadSubs();
+  }
   function openEdit(idx: number) {
     setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
     setEditingIndex(idx);
@@ -103,11 +173,16 @@ export default function OutboundsTab({
   function onConfirm(outbound: Record<string, unknown>) {
     mutate((tt) => {
       if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
+      const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
       if (editingIndex == null) {
-        if (!outbound.tag) return;
+        if (!newTag) return;
         tt.outbounds.push(outbound as never);
       } else {
+        const oldTag = tt.outbounds[editingIndex]?.tag;
         tt.outbounds[editingIndex] = outbound as never;
+        if (oldTag && newTag && oldTag !== newTag) {
+          propagateOutboundTagRename(tt, oldTag, newTag);
+        }
       }
     });
     setModalOpen(false);
@@ -147,6 +222,169 @@ export default function OutboundsTab({
     });
   }
 
+  // --- Subscription management (minimal inline UI) ---
+  async function loadSubs() {
+    setSubsLoading(true);
+    try {
+      const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
+      if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
+    } finally {
+      setSubsLoading(false);
+    }
+  }
+  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
+    return {
+      remark: src.remark ?? '',
+      url: src.url ?? '',
+      tagPrefix: src.tagPrefix ?? '',
+      updateInterval: src.updateInterval ?? 600,
+      enabled: src.enabled ?? true,
+      allowPrivate: src.allowPrivate ?? false,
+      prepend: src.prepend ?? false,
+    };
+  }
+  function resetSubForm() {
+    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+    setEditingSubId(null);
+    setPreviewData(null);
+  }
+  function openEditSub(sub: OutboundSub) {
+    setNewSub({
+      remark: sub.remark ?? '',
+      url: sub.url ?? '',
+      tagPrefix: sub.tagPrefix ?? '',
+      updateInterval: sub.updateInterval ?? 600,
+      enabled: sub.enabled ?? true,
+      allowPrivate: sub.allowPrivate ?? false,
+      prepend: sub.prepend ?? false,
+    });
+    setEditingSubId(sub.id);
+    setPreviewData(null);
+  }
+  async function saveSub() {
+    if (!newSub.url.trim()) {
+      messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
+      return;
+    }
+    setSavingSub(true);
+    try {
+      const url = editingSubId != null
+        ? `/panel/api/xray/outbound-subs/${editingSubId}`
+        : '/panel/api/xray/outbound-subs';
+      const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
+      if (r?.success) {
+        messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
+        const createdId = editingSubId == null ? r.obj?.id : undefined;
+        resetSubForm();
+        await loadSubs();
+        if (createdId) await refreshOne(createdId);
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
+    } finally {
+      setSavingSub(false);
+    }
+  }
+  async function previewSub() {
+    if (!newSub.url.trim()) {
+      messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
+      return;
+    }
+    setPreviewing(true);
+    setPreviewData(null);
+    try {
+      const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
+      if (r?.success && Array.isArray(r.obj)) {
+        setPreviewData(r.obj);
+        if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
+    } finally {
+      setPreviewing(false);
+    }
+  }
+  async function toggleEnabled(sub: OutboundSub) {
+    setBusyId(sub.id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
+      if (r?.success) {
+        await loadSubs();
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
+    } finally {
+      setBusyId(null);
+    }
+  }
+  async function moveSub(id: number, dir: 'up' | 'down') {
+    setBusyId(id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
+      if (r?.success) {
+        await loadSubs();
+        onRefreshXrayData?.();
+      }
+    } catch {
+      /* ignore */
+    } finally {
+      setBusyId(null);
+    }
+  }
+  async function refreshOne(id: number) {
+    setRefreshingId(id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
+      if (r?.success) {
+        messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
+        await loadSubs();
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
+    } finally {
+      setRefreshingId(null);
+    }
+  }
+  async function refreshAllSubs() {
+    if (subs.length === 0) return;
+    setRefreshingAll(true);
+    try {
+      for (const s of subs) {
+        try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
+      }
+      messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
+      await loadSubs();
+      onRefreshXrayData?.();
+    } finally {
+      setRefreshingAll(false);
+    }
+  }
+  async function deleteOne(id: number) {
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
+      if (r?.success) {
+        messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
+        await loadSubs();
+        onRefreshXrayData?.();
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
+    }
+  }
+
   const columns = useOutboundColumns({
     testMode,
     rows,
@@ -164,6 +402,7 @@ export default function OutboundsTab({
   return (
     <>
       {modalContextHolder}
+      {messageContextHolder}
       <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
         <Row gutter={[12, 12]} align="middle" justify="space-between">
           <Col xs={24} sm={12}>
@@ -171,12 +410,20 @@ export default function OutboundsTab({
               <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
                 {!isMobile && t('pages.xray.Outbounds')}
               </Button>
-              <Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
-                WARP
-              </Button>
-              <Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
-                NordVPN
+              <Button icon={<CloudOutlined />} onClick={openSubManager}>
+                {t('pages.xray.outboundSub.manage')}
               </Button>
+              <Dropdown
+                trigger={['click']}
+                menu={{
+                  items: [
+                    { key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
+                    { key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
+                  ],
+                }}
+              >
+                <Button icon={<MoreOutlined />}>{t('more')}</Button>
+              </Dropdown>
             </Space>
           </Col>
           <Col xs={24} sm={12} className="toolbar-right">
@@ -232,7 +479,182 @@ export default function OutboundsTab({
           onClose={() => setModalOpen(false)}
           onConfirm={onConfirm}
         />
+
+        {/* Subscription outbounds (read-only, merged at runtime) */}
+        {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
+          <SubscriptionOutbounds
+            subscriptionOutbounds={subscriptionOutbounds}
+            outboundsTraffic={outboundsTraffic}
+            subscriptionTestStates={subscriptionTestStates}
+            testMode={testMode}
+            isMobile={isMobile}
+            onTestSubscription={onTestSubscription}
+          />
+        )}
       </Space>
+
+      <Modal
+        title={t('pages.xray.outboundSub.title')}
+        open={subDrawerOpen}
+        onCancel={() => setSubDrawerOpen(false)}
+        footer={null}
+        width={isMobile ? '100%' : 640}
+        destroyOnHidden
+      >
+        <Space orientation="vertical" style={{ width: '100%' }} size="large">
+          <div>
+            {editingSubId != null && (
+              <div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
+                <Tag color="blue">{t('edit')}</Tag>
+                <span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
+              </div>
+            )}
+            <Form layout="vertical" size="small">
+              <Form.Item label={t('pages.xray.outboundSub.remark')}>
+                <Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.url')} required>
+                <Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
+                <Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.interval')}>
+                <Space>
+                  <InputNumber
+                    min={0}
+                    value={intervalHours}
+                    onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
+                    style={{ width: 80 }}
+                  /> {t('pages.xray.outboundSub.hours')}
+                  <InputNumber
+                    min={0}
+                    max={59}
+                    value={intervalMinutes}
+                    onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
+                    style={{ width: 80 }}
+                  /> {t('pages.xray.outboundSub.minutes')}
+                </Space>
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.intervalHint')}
+                </div>
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.enabled')}>
+                <Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
+                <Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.allowPrivateHint')}
+                </div>
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.prepend')}>
+                <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.prependHint')}
+                </div>
+              </Form.Item>
+              <Space wrap>
+                <Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
+                  {editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
+                </Button>
+                <Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
+                  {t('pages.xray.outboundSub.preview')}
+                </Button>
+                {editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
+              </Space>
+              {previewData && previewData.length > 0 && (
+                <div style={{ marginTop: 8 }}>
+                  <div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
+                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
+                    {previewData.map((o, i) => (
+                      <Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </Form>
+          </div>
+
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
+              {t('pages.xray.outboundSub.active')}
+              <Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
+              {subs.length > 0 && (
+                <Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
+                  {t('pages.xray.outboundSub.refreshAll')}
+                </Button>
+              )}
+            </div>
+            {subs.length === 0 ? (
+              <div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
+            ) : (
+              <Table
+                size="small"
+                dataSource={subs}
+                rowKey={(r) => r.id}
+                pagination={false}
+                scroll={{ x: true }}
+                columns={[
+                  {
+                    title: '',
+                    key: 'order',
+                    width: 56,
+                    render: (_: unknown, r: OutboundSub, index: number) => (
+                      <Space size={0}>
+                        <Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
+                        <Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
+                      </Space>
+                    ),
+                  },
+                  {
+                    title: t('pages.xray.outboundSub.colRemark'),
+                    key: 'remark',
+                    render: (_: unknown, r: OutboundSub) => (
+                      <div>
+                        <div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
+                        {r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
+                      </div>
+                    ),
+                  },
+                  { title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
+                  {
+                    title: t('status'),
+                    key: 'status',
+                    align: 'center',
+                    render: (_: unknown, r: OutboundSub) => (r.lastError
+                      ? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
+                      : <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
+                  },
+                  { title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
+                  {
+                    title: t('pages.xray.outboundSub.colEnabled'),
+                    key: 'enabled',
+                    align: 'center',
+                    render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
+                  },
+                  {
+                    title: '',
+                    key: 'actions',
+                    render: (_: unknown, r: OutboundSub) => (
+                      <Space>
+                        <Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
+                        <Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
+                        <Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
+                          <Button size="small" danger icon={<DeleteOutlined />} />
+                        </Popconfirm>
+                      </Space>
+                    ),
+                  },
+                ]}
+              />
+            )}
+            <div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
+              {t('pages.xray.outboundSub.restartHint')}
+            </div>
+          </div>
+        </Space>
+      </Modal>
     </>
   );
 }

+ 207 - 0
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -0,0 +1,207 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Popover, Table, Tag, Tooltip } from 'antd';
+import {
+  ThunderboltOutlined,
+  CheckCircleFilled,
+  CloseCircleFilled,
+  LoadingOutlined,
+} from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+
+import { SizeFormatter } from '@/utils';
+import { OutboundProtocols as Protocols } from '@/schemas/primitives';
+import { isUdpOutbound } from '@/hooks/useXraySetting';
+import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+
+import type { OutboundRow } from './outbounds-tab-types';
+import {
+  hasBreakdown,
+  isTesting,
+  isUntestable,
+  outboundAddresses,
+  showSecurity,
+  testResult,
+  trafficFor,
+} from './outbounds-tab-helpers';
+
+interface SubscriptionOutboundsProps {
+  subscriptionOutbounds: unknown[];
+  outboundsTraffic: OutboundTrafficRow[];
+  subscriptionTestStates: Record<string, OutboundTestState>;
+  testMode: 'tcp' | 'http';
+  isMobile: boolean;
+  onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
+}
+
+// Read-only view of outbounds imported from active subscriptions. They are not
+// part of the editable template (so no edit/delete/move), but traffic is matched
+// by tag and they can be latency-tested via the same backend endpoint.
+export default function SubscriptionOutbounds({
+  subscriptionOutbounds,
+  outboundsTraffic,
+  subscriptionTestStates,
+  testMode,
+  isMobile,
+  onTestSubscription,
+}: SubscriptionOutboundsProps) {
+  const { t } = useTranslation();
+
+  const rows = useMemo<OutboundRow[]>(
+    () => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
+    [subscriptionOutbounds],
+  );
+
+  if (rows.length === 0) return null;
+
+  const identityCell = (record: OutboundRow) => (
+    <div className="identity-cell">
+      <Tooltip title={record.tag}>
+        <span className="tag-name">{record.tag || '—'}</span>
+      </Tooltip>
+      <div className="protocol-line">
+        <Tag color="green">{record.protocol}</Tag>
+        {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
+          <>
+            <Tag>{record.streamSettings?.network}</Tag>
+            {showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
+          </>
+        )}
+      </div>
+    </div>
+  );
+
+  const addressCell = (record: OutboundRow) => {
+    const addrs = outboundAddresses(record);
+    return (
+      <div className="address-list">
+        {addrs.length === 0 ? (
+          <span className="empty">—</span>
+        ) : (
+          addrs.map((addr) => (
+            <Tooltip key={addr} title={addr}>
+              <span className="address-pill">{addr}</span>
+            </Tooltip>
+          ))
+        )}
+      </div>
+    );
+  };
+
+  const trafficCell = (record: OutboundRow) => {
+    const tr = trafficFor(outboundsTraffic, record);
+    return (
+      <>
+        <span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
+        <span className="traffic-sep" />
+        <span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
+      </>
+    );
+  };
+
+  const latencyCell = (record: OutboundRow) => {
+    const key = record.tag || '';
+    const r = testResult(subscriptionTestStates, key);
+    if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
+    return (
+      <Popover
+        placement="topLeft"
+        rootClassName="outbound-test-popover"
+        content={
+          <div className="timing-breakdown">
+            <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
+              {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
+              {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
+            </div>
+            {hasBreakdown(r) && (
+              <>
+                {(r.endpoints || []).map((ep) => (
+                  <div key={ep.address} className="endpoint-row">
+                    <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
+                    <span className="ep-addr">{ep.address}</span>
+                    <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
+                  </div>
+                ))}
+              </>
+            )}
+          </div>
+        }
+      >
+        <span className={r.success ? 'pill-ok' : 'pill-fail'}>
+          {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
+          {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
+        </span>
+      </Popover>
+    );
+  };
+
+  const testButton = (record: OutboundRow) => {
+    const key = record.tag || '';
+    return (
+      <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
+        <Button
+          type="primary"
+          shape="circle"
+          size={isMobile ? 'small' : undefined}
+          loading={isTesting(subscriptionTestStates, key)}
+          disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
+          icon={<ThunderboltOutlined />}
+          onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
+        />
+      </Tooltip>
+    );
+  };
+
+  const header = (
+    <div className="subscription-outbounds-head">
+      <div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
+      <div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
+    </div>
+  );
+
+  if (isMobile) {
+    return (
+      <div className="subscription-outbounds" style={{ marginTop: 16 }}>
+        {header}
+        {rows.map((record, index) => (
+          <div key={record.key} className="outbound-card">
+            <div className="card-head">
+              <div className="card-identity">
+                <span className="card-num">{index + 1}</span>
+                {identityCell(record)}
+              </div>
+              {testButton(record)}
+            </div>
+            {outboundAddresses(record).length > 0 && addressCell(record)}
+            <div className="card-foot">
+              {trafficCell(record)}
+              <span className="card-test">{latencyCell(record)}</span>
+            </div>
+          </div>
+        ))}
+      </div>
+    );
+  }
+
+  const columns: ColumnsType<OutboundRow> = [
+    {
+      title: '#',
+      key: 'num',
+      align: 'center',
+      width: 60,
+      render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
+    },
+    { title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
+    { title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
+    { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
+    { title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
+    { title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
+  ];
+
+  return (
+    <div className="subscription-outbounds" style={{ marginTop: 16 }}>
+      {header}
+      <Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
+    </div>
+  );
+}

+ 2 - 2
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -53,10 +53,10 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
   return { up: tr?.up || 0, down: tr?.down || 0 };
 }
 
-export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
+export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
   return !!states?.[idx]?.testing;
 }
 
-export function testResult(states: Record<number, OutboundTestState>, idx: number) {
+export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
   return states?.[idx]?.result || null;
 }

+ 89 - 21
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -80,6 +80,7 @@ export default function WarpModal({
   const [warpData, setWarpData] = useState<WarpData | null>(null);
   const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
   const [warpPlus, setWarpPlus] = useState('');
+  const [updateInterval, setUpdateInterval] = useState<number>(0);
   const [licenseError, setLicenseError] = useState('');
   const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
 
@@ -89,24 +90,29 @@ export default function WarpModal({
     return list.findIndex((o) => o?.tag === 'warp');
   }, [templateSettings?.outbounds]);
 
-  const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => {
-    const cfg = config?.config;
-    if (!cfg?.peers?.length) return;
-    const peer = cfg.peers[0];
-    setStagedOutbound({
-      tag: 'warp',
-      protocol: 'wireguard',
-      settings: {
-        mtu: 1420,
-        secretKey: data?.private_key,
-        address: addressesFor(cfg.interface?.addresses || {}),
-        reserved: reservedFor(cfg.client_id ?? data?.client_id),
-        domainStrategy: 'ForceIP',
-        peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
-        noKernelTun: false,
-      },
-    });
-  }, []);
+  const collectConfig = useCallback(
+    (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
+      const cfg = config?.config;
+      if (!cfg?.peers?.length) return null;
+      const peer = cfg.peers[0];
+      const outbound: Record<string, unknown> = {
+        tag: 'warp',
+        protocol: 'wireguard',
+        settings: {
+          mtu: 1420,
+          secretKey: data?.private_key,
+          address: addressesFor(cfg.interface?.addresses || {}),
+          reserved: reservedFor(cfg.client_id ?? data?.client_id),
+          domainStrategy: 'ForceIP',
+          peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
+          noKernelTun: false,
+        },
+      };
+      setStagedOutbound(outbound);
+      return outbound;
+    },
+    [],
+  );
 
   const fetchData = useCallback(async () => {
     setLoading(true);
@@ -116,6 +122,10 @@ export default function WarpModal({
         const raw = msg.obj;
         setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
       }
+      const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
+      if (settingMsg?.success && settingMsg.obj) {
+        setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
+      }
     } finally {
       setLoading(false);
     }
@@ -159,6 +169,40 @@ export default function WarpModal({
     }
   }
 
+  async function changeIp() {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
+      if (msg?.success && msg.obj) {
+        const parsed = JSON.parse(msg.obj);
+        setWarpData(parsed.data);
+        setWarpConfig(parsed.config);
+        const built = collectConfig(parsed.data, parsed.config);
+        // The backend already persisted the new keys into the saved Xray
+        // template; keep the in-memory editor in sync so a later template
+        // save doesn't revert them to the old keys.
+        if (built && warpOutboundIndex >= 0) {
+          onResetOutbound({ index: warpOutboundIndex, outbound: built });
+        }
+        messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
+      }
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function saveInterval() {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
+      if (msg?.success) {
+        messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
+      }
+    } finally {
+      setLoading(false);
+    }
+  }
+
   async function updateLicense() {
     if (warpPlus.length < 26) return;
     setLoading(true);
@@ -281,13 +325,37 @@ export default function WarpModal({
                   </Form>
                 ),
               },
+              {
+                key: '2',
+                label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
+                children: (
+                  <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
+                    <Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} extra={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
+                      <Input
+                        type="number"
+                        min={0}
+                        value={updateInterval}
+                        onChange={(e) => setUpdateInterval(Number(e.target.value))}
+                      />
+                      <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
+                        {t('save', 'Save')}
+                      </Button>
+                    </Form.Item>
+                  </Form>
+                ),
+              },
             ]}
           />
 
           <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
-          <Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
-            {t('refresh')}
-          </Button>
+          <div className="my-8">
+            <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
+              {t('refresh')}
+            </Button>
+            <Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
+              {t('pages.xray.warp.changeIp', 'Change IP')}
+            </Button>
+          </div>
 
           {hasConfig && (
             <>

+ 6 - 1
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -20,6 +20,7 @@ interface RoutingTabProps {
   setTemplateSettings: SetTemplate;
   inboundTags: string[];
   clientReverseTags: string[];
+  subscriptionOutboundTags?: string[];
   isMobile: boolean;
 }
 
@@ -28,6 +29,7 @@ export default function RoutingTab({
   setTemplateSettings,
   inboundTags,
   clientReverseTags,
+  subscriptionOutboundTags,
   isMobile,
 }: RoutingTabProps) {
   const { t } = useTranslation();
@@ -116,8 +118,11 @@ export default function RoutingTab({
     for (const tag of clientReverseTags || []) {
       if (tag) out.add(tag);
     }
+    for (const tag of subscriptionOutboundTags || []) {
+      if (tag) out.add(tag);
+    }
     return [...out];
-  }, [templateSettings?.outbounds, clientReverseTags]);
+  }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
 
   const balancerTagOptions = useMemo(() => {
     const out: string[] = [''];

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

@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
 export const GroupSummarySchema = z.object({
   name: z.string(),
   clientCount: z.number(),
+  trafficUsed: z.number().nullable().transform((v) => v ?? 0),
 });
 
 export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);

+ 8 - 0
frontend/src/schemas/node.ts

@@ -23,6 +23,11 @@ export const NodeRecordSchema = z.object({
   depletedCount: z.number().optional(),
   lastHeartbeat: z.number().optional(),
   lastError: z.string().optional(),
+  // Xray state captured from the remote node's own /panel/api/server/status.
+  // Lets the nodes list show a distinct indicator when the panel API is reachable
+  // (status=online) but the Xray core on that node has failed.
+  xrayState: z.string().optional(),
+  xrayError: z.string().optional(),
   allowPrivateAddress: z.boolean().optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   pinnedCertSha256: z.string().optional(),
@@ -40,6 +45,9 @@ export const ProbeResultSchema = z.object({
   latencyMs: z.number().optional(),
   xrayVersion: z.string().optional(),
   error: z.string().optional(),
+  // Present on successful probe; used to surface "connected to panel, but xray failed on node".
+  xrayState: z.string().optional(),
+  xrayError: z.string().optional(),
 }).loose();
 
 export const NodeFormSchema = z.object({

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

@@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({
   inboundTags: z.array(z.string()).optional(),
   clientReverseTags: z.array(z.string()).optional(),
   outboundTestUrl: z.string().optional(),
+  // Subscription outbounds are injected at runtime (not persisted in xraySetting).
+  // They are provided here so the UI can display them and use their tags in
+  // balancers / routing rules.
+  subscriptionOutbounds: z.array(z.unknown()).optional(),
+  subscriptionOutboundTags: z.array(z.string()).optional(),
 }).loose();
 
 export const OutboundTrafficRowSchema = z.object({

+ 1 - 0
frontend/src/styles/page-cards.css

@@ -45,6 +45,7 @@
 .nodes-page .ant-card.ant-card-hoverable:hover,
 .groups-page .ant-card.ant-card-hoverable:hover,
 .api-docs-page .ant-card.ant-card-hoverable:hover {
+  cursor: default;
   box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
 }
 

+ 23 - 0
frontend/src/styles/utils.css

@@ -41,3 +41,26 @@
 @media (prefers-reduced-motion: reduce) {
   .online-dot { animation: none; }
 }
+
+/* Purple indicator for nodes that are reachable via the panel API (status=online)
+   but have Xray core in "error" or "stop" state. This is the new "xray failed on node"
+   monitoring state. */
+.xray-error-dot {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-inline-end: 5px;
+  vertical-align: middle;
+  background: #722ED1;
+  animation: xray-error-blink 1.1s ease-in-out infinite;
+}
+
+@keyframes xray-error-blink {
+  0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(114, 46, 209, 0.55); }
+  50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(114, 46, 209, 0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .xray-error-dot { animation: none; }
+}

+ 61 - 0
frontend/src/test/outbound-tag-rename.test.ts

@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers';
+
+function baseTemplate(): XraySettingsValue {
+  return {
+    outbounds: [
+      { tag: 'To-External-Proxy', protocol: 'vless' },
+      { tag: 'direct', protocol: 'freedom' },
+    ],
+    routing: {
+      rules: [
+        {
+          type: 'field',
+          inboundTag: ['iran-in'],
+          outboundTag: 'To-External-Proxy',
+        },
+      ],
+      balancers: [
+        {
+          tag: 'lb-1',
+          selector: ['To-External-Proxy', 'direct'],
+          fallbackTag: 'To-External-Proxy',
+        },
+      ],
+    },
+  } as XraySettingsValue;
+}
+
+describe('propagateOutboundTagRename', () => {
+  it('updates routing rule outboundTag when outbound is renamed', () => {
+    const t = baseTemplate();
+    propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
+    expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps');
+  });
+
+  it('updates balancer selector and fallbackTag', () => {
+    const t = baseTemplate();
+    propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
+    expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']);
+    expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps');
+  });
+
+  it('updates sockopt dialerProxy references in other outbounds', () => {
+    const t = baseTemplate();
+    (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = {
+      sockopt: { dialerProxy: 'To-External-Proxy' },
+    };
+    propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
+    const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
+      .streamSettings?.sockopt?.dialerProxy;
+    expect(dialerProxy).toBe('external-vps');
+  });
+
+  it('is a no-op when old and new tags are equal', () => {
+    const t = baseTemplate();
+    propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy');
+    expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy');
+  });
+});

+ 16 - 0
frontend/src/test/setup.components.ts

@@ -74,3 +74,19 @@ afterEach(async () => {
     await new Promise((resolve) => setTimeout(resolve, 0));
   }
 });
+
+import { HttpUtil } from '@/utils';
+
+vi.mock('axios', () => {
+  return {
+    default: {
+      get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
+      post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
+    }
+  };
+});
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);

+ 14 - 14
go.mod

@@ -23,9 +23,9 @@ require (
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
 	go.uber.org/atomic v1.11.0
-	golang.org/x/crypto v0.52.0
-	golang.org/x/sys v0.45.0
-	golang.org/x/text v0.37.0
+	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
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.0
@@ -34,10 +34,10 @@ require (
 )
 
 require (
-	github.com/pion/dtls/v3 v3.1.2 // indirect
+	github.com/pion/dtls/v3 v3.1.4 // indirect
 	github.com/pion/logging v0.2.4 // indirect
-	github.com/pion/stun/v3 v3.1.2 // indirect
-	github.com/pion/transport/v4 v4.0.1 // indirect
+	github.com/pion/stun/v3 v3.1.5 // indirect
+	github.com/pion/transport/v4 v4.0.2 // indirect
 	github.com/wlynxg/anet v0.0.5 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 )
@@ -47,7 +47,7 @@ require (
 	github.com/andybalholm/brotli v1.2.1 // indirect
 	github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
-	github.com/bytedance/sonic v1.15.1 // indirect
+	github.com/bytedance/sonic v1.15.2 // indirect
 	github.com/bytedance/sonic/loader v0.5.1 // indirect
 	github.com/cloudflare/circl v1.6.3 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
@@ -76,7 +76,7 @@ require (
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
 	github.com/mattn/go-isatty v0.0.22 // indirect
-	github.com/mattn/go-sqlite3 v1.14.44 // indirect
+	github.com/mattn/go-sqlite3 v1.14.45 // indirect
 	github.com/miekg/dns v1.1.72 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -84,7 +84,7 @@ require (
 	github.com/pires/go-proxyproto v0.12.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
-	github.com/quic-go/quic-go v0.59.1 // indirect
+	github.com/quic-go/quic-go v0.60.0 // indirect
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/sagernet/sing v0.8.10 // indirect
@@ -101,16 +101,16 @@ require (
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
-	golang.org/x/arch v0.27.0 // indirect
-	golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
-	golang.org/x/mod v0.36.0 // indirect
+	golang.org/x/arch v0.28.0 // indirect
+	golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
+	golang.org/x/mod v0.37.0 // indirect
 	golang.org/x/net v0.55.0
-	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sync v0.21.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.45.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 30 - 28
go.sum

@@ -10,8 +10,8 @@ github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkd
 github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
 github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
-github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
-github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
+github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
+github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
 github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
 github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
 github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
 github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
-github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
+github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
 github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -148,24 +148,26 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
 github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
-github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
+github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
+github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
 github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
 github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
-github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
-github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
-github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
-github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
+github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
+github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
+github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
+github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
 github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
 github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
-github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
+github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -244,27 +246,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
-golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
-golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
-golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
-golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
-golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
-golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
+golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
+golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
 golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
 golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
@@ -277,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

+ 111 - 21
sub/subController.go

@@ -5,15 +5,19 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"html/template"
 	"net/http"
 	"net/url"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strings"
-
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"sync"
+	"time"
 
 	"github.com/gin-gonic/gin"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
 )
 
 // writeSubError translates a service-layer result into an HTTP response.
@@ -28,6 +32,14 @@ func writeSubError(c *gin.Context, err error) {
 	c.Status(http.StatusInternalServerError)
 }
 
+// cachedSubTemplate holds a parsed custom subscription template together with
+// the modification time of the file it was parsed from, so the cache can be
+// invalidated when an admin edits the template on disk.
+type cachedSubTemplate struct {
+	tmpl    *template.Template
+	modTime time.Time
+}
+
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 type SUBController struct {
 	subTitle         string
@@ -48,6 +60,9 @@ type SUBController struct {
 	subJsonService  *SubJsonService
 	subClashService *SubClashService
 	settingService  service.SettingService
+
+	subTemplateMu    sync.RWMutex
+	subTemplateCache map[string]*cachedSubTemplate
 }
 
 // NewSUBController creates a new subscription controller with the given configuration.
@@ -93,6 +108,8 @@ func NewSUBController(
 		subService:      sub,
 		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
 		subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
+
+		subTemplateCache: map[string]*cachedSubTemplate{},
 	}
 	a.initRouter(g)
 	return a
@@ -202,25 +219,49 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 	}
 
 	subData := map[string]any{
-		"sId":          page.SId,
-		"enabled":      page.Enabled,
-		"download":     page.Download,
-		"upload":       page.Upload,
-		"total":        page.Total,
-		"used":         page.Used,
-		"remained":     page.Remained,
-		"expire":       page.Expire,
-		"lastOnline":   page.LastOnline,
-		"downloadByte": page.DownloadByte,
-		"uploadByte":   page.UploadByte,
-		"totalByte":    page.TotalByte,
-		"subUrl":       page.SubUrl,
-		"subJsonUrl":   page.SubJsonUrl,
-		"subClashUrl":  page.SubClashUrl,
-		"links":        page.Result,
-		"emails":       page.Emails,
-		"datepicker":   datepicker,
+		"sId":           page.SId,
+		"enabled":       page.Enabled,
+		"download":      page.Download,
+		"upload":        page.Upload,
+		"total":         page.Total,
+		"used":          page.Used,
+		"remained":      page.Remained,
+		"expire":        page.Expire,
+		"lastOnline":    page.LastOnline,
+		"downloadByte":  page.DownloadByte,
+		"uploadByte":    page.UploadByte,
+		"totalByte":     page.TotalByte,
+		"subUrl":        page.SubUrl,
+		"subJsonUrl":    page.SubJsonUrl,
+		"subClashUrl":   page.SubClashUrl,
+		"subTitle":      page.SubTitle,
+		"subSupportUrl": page.SubSupportUrl,
+		"links":         page.Result,
+		"emails":        page.Emails,
+		"datepicker":    datepicker,
+	}
+
+	// When an admin has configured a custom subscription theme, render it
+	// instead of the default SPA. We render into a buffer first so a template
+	// that fails mid-execution can't leave a partially-written (corrupt)
+	// response — on any error we log and fall through to the default page.
+	if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
+		if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
+			logger.Error("sub: custom template parse failed, using default page:", err)
+		} else if tmpl == nil {
+			logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
+		} else {
+			var buf bytes.Buffer
+			if execErr := tmpl.Execute(&buf, subData); execErr != nil {
+				logger.Error("sub: custom template execution failed, using default page:", execErr)
+			} else {
+				setNoCacheHeaders(c)
+				c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
+				return
+			}
+		}
 	}
+
 	subDataJSON, err := json.Marshal(subData)
 	if err != nil {
 		subDataJSON = []byte("{}")
@@ -243,10 +284,59 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 		`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)
 
+	setNoCacheHeaders(c)
+	c.Data(http.StatusOK, "text/html; charset=utf-8", out)
+}
+
+// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
+// clients and browsers always fetch fresh traffic/expiry data.
+func setNoCacheHeaders(c *gin.Context) {
 	c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
 	c.Header("Pragma", "no-cache")
 	c.Header("Expires", "0")
-	c.Data(http.StatusOK, "text/html; charset=utf-8", out)
+}
+
+// loadSubTemplate returns the parsed custom subscription template located in
+// themeDir, preferring sub.html over index.html. Parsed templates are cached and
+// only re-parsed when the underlying file's modification time changes, so admin
+// edits are picked up without paying a disk read + HTML parse on every request.
+//
+// It returns (nil, nil) when themeDir is not a usable directory or contains no
+// template file — the caller should fall back to the default page. A non-nil
+// error means a template file exists but failed to parse.
+func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
+	info, err := os.Stat(themeDir)
+	if err != nil || !info.IsDir() {
+		return nil, nil
+	}
+
+	templatePath := filepath.Join(themeDir, "index.html")
+	if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
+		templatePath = filepath.Join(themeDir, "sub.html")
+	}
+
+	fi, err := os.Stat(templatePath)
+	if err != nil {
+		return nil, nil
+	}
+	modTime := fi.ModTime()
+
+	a.subTemplateMu.RLock()
+	cached := a.subTemplateCache[templatePath]
+	a.subTemplateMu.RUnlock()
+	if cached != nil && cached.modTime.Equal(modTime) {
+		return cached.tmpl, nil
+	}
+
+	tmpl, err := template.ParseFiles(templatePath)
+	if err != nil {
+		return nil, err
+	}
+
+	a.subTemplateMu.Lock()
+	a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
+	a.subTemplateMu.Unlock()
+	return tmpl, nil
 }
 
 // subJsons handles HTTP requests for JSON subscription configurations.

+ 149 - 0
sub/subController_test.go

@@ -0,0 +1,149 @@
+package sub
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+// newTestSUBController builds a controller with just the bits loadSubTemplate
+// needs, so the template tests don't require a database.
+func newTestSUBController() *SUBController {
+	return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
+}
+
+func writeFile(t *testing.T, path, content string) {
+	t.Helper()
+	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+		t.Fatalf("write %s: %v", path, err)
+	}
+}
+
+func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
+	t.Helper()
+	tmpl, err := a.loadSubTemplate(dir)
+	if err != nil {
+		t.Fatalf("loadSubTemplate: unexpected error: %v", err)
+	}
+	if tmpl == nil {
+		t.Fatal("loadSubTemplate: expected a template, got nil")
+	}
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, data); err != nil {
+		t.Fatalf("execute: %v", err)
+	}
+	return buf.String()
+}
+
+func TestLoadSubTemplate_RendersIndex(t *testing.T) {
+	dir := t.TempDir()
+	writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
+
+	got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
+	if want := `<h1>abc-123</h1>`; got != want {
+		t.Fatalf("rendered = %q, want %q", got, want)
+	}
+}
+
+func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
+	dir := t.TempDir()
+	writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
+	writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
+
+	got := renderTemplate(t, newTestSUBController(), dir, nil)
+	if got != "from-sub" {
+		t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
+	}
+}
+
+func TestLoadSubTemplate_FallbackCases(t *testing.T) {
+	a := newTestSUBController()
+
+	t.Run("missing dir", func(t *testing.T) {
+		tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
+		if tmpl != nil || err != nil {
+			t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
+		}
+	})
+
+	t.Run("path is a file not a dir", func(t *testing.T) {
+		file := filepath.Join(t.TempDir(), "index.html")
+		writeFile(t, file, `whatever`)
+		tmpl, err := a.loadSubTemplate(file)
+		if tmpl != nil || err != nil {
+			t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
+		}
+	})
+
+	t.Run("dir without template file", func(t *testing.T) {
+		tmpl, err := a.loadSubTemplate(t.TempDir())
+		if tmpl != nil || err != nil {
+			t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
+		}
+	})
+}
+
+func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
+	dir := t.TempDir()
+	// Unterminated action — html/template fails to parse this.
+	writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
+
+	tmpl, err := newTestSUBController().loadSubTemplate(dir)
+	if err == nil {
+		t.Fatal("expected a parse error for a malformed template, got nil")
+	}
+	if tmpl != nil {
+		t.Fatalf("expected nil template on parse error, got %v", tmpl)
+	}
+}
+
+func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
+	a := newTestSUBController()
+	dir := t.TempDir()
+	path := filepath.Join(dir, "index.html")
+
+	// v1 with a fixed mtime.
+	writeFile(t, path, `v1`)
+	t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	if err := os.Chtimes(path, t1, t1); err != nil {
+		t.Fatalf("chtimes: %v", err)
+	}
+
+	first, err := a.loadSubTemplate(dir)
+	if err != nil || first == nil {
+		t.Fatalf("first load: (%v, %v)", first, err)
+	}
+
+	// Same mtime → cache hit returns the identical parsed template.
+	second, err := a.loadSubTemplate(dir)
+	if err != nil {
+		t.Fatalf("second load: %v", err)
+	}
+	if second != first {
+		t.Fatal("expected cache hit to return the same *template.Template pointer")
+	}
+
+	// New content + newer mtime → cache invalidated, fresh content served.
+	writeFile(t, path, `v2`)
+	t2 := t1.Add(time.Hour)
+	if err := os.Chtimes(path, t2, t2); err != nil {
+		t.Fatalf("chtimes: %v", err)
+	}
+
+	third, err := a.loadSubTemplate(dir)
+	if err != nil || third == nil {
+		t.Fatalf("third load: (%v, %v)", third, err)
+	}
+	if third == first {
+		t.Fatal("expected cache invalidation to re-parse the template after mtime change")
+	}
+	var buf bytes.Buffer
+	if err := third.Execute(&buf, nil); err != nil {
+		t.Fatalf("execute: %v", err)
+	}
+	if buf.String() != "v2" {
+		t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
+	}
+}

+ 44 - 0
sub_templates/README.md

@@ -0,0 +1,44 @@
+# 3x-ui Custom Subscription Templates
+
+This directory allows you to use custom HTML templates for your users' subscription pages.
+
+## How to use a Custom Template
+
+1. Go to the 3x-ui panel settings.
+2. Under **Settings → Subscription → Information**, locate the **Sub Theme Directory** field.
+3. Provide the absolute path to the folder containing your template (e.g. `/etc/3x-ui/sub_templates/my-theme/`).
+4. Save the settings.
+
+> **Note:** 3x-ui does not ship any templates by default. Create your own template folder anywhere
+> on the server, put an `index.html` (or `sub.html`) inside it, and point **Sub Theme Directory** at
+> that absolute path. Leave the field empty to use the default built-in page.
+
+## Creating a Template
+
+A custom template must be an HTML file named `index.html` or `sub.html` located within the directory you specified in the settings.
+The panel uses standard Go `html/template` to render the subscription page.
+
+### Available Variables
+
+When rendering the template, the following variables are injected into the template context (`{{ .variable }}`):
+
+* `{{ .sId }}`: Subscription ID (UUID).
+* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
+* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
+* `{{ .upload }}`: Formatted upload traffic.
+* `{{ .total }}`: Formatted total traffic limit.
+* `{{ .used }}`: Formatted used traffic (download + upload).
+* `{{ .remained }}`: Formatted remaining traffic.
+* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
+* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
+* `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
+* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
+* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
+* `{{ .subUrl }}`: The URL of the subscription page.
+* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
+* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
+* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
+* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
+* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
+* `{{ .emails }}`: A list (slice) of emails related to the subscription.
+* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").

+ 809 - 0
util/link/outbound.go

@@ -0,0 +1,809 @@
+// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
+// and subscription bodies (typically base64-encoded newline lists of such links).
+// The output shape matches the wire format used by the panel's Xray template
+// outbounds array so that parsed objects can be injected directly.
+package link
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// Outbound is the minimal shape we emit for each parsed link.
+// Extra fields (mux, etc.) are carried inside settings/streamSettings.
+type Outbound map[string]any
+
+// ParseResult holds a parsed outbound together with a stable identity string
+// that can be used to correlate the same logical server across refreshes
+// (even if the remark changes).
+type ParseResult struct {
+	Outbound Outbound
+	Identity string
+}
+
+// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
+// It handles the common case where the body is a base64-encoded blob of
+// newline-separated links, and also tolerates an already-decoded text body.
+// It returns the list of successfully parsed outbounds (in order) and their
+// corresponding identities.
+func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
+	text := strings.TrimSpace(string(body))
+	if text == "" {
+		return nil, nil, nil
+	}
+
+	// Try base64 decode first (standard and URL-safe variants).
+	if decoded, ok := tryBase64(text); ok {
+		text = strings.TrimSpace(decoded)
+	}
+
+	lines := splitLines(text)
+	var outbounds []Outbound
+	var identities []string
+
+	for _, ln := range lines {
+		ln = strings.TrimSpace(ln)
+		if ln == "" || strings.HasPrefix(ln, "#") {
+			continue
+		}
+		res, err := ParseLink(ln)
+		if err != nil || res == nil {
+			// Ignore unparseable lines (comments, unsupported protocols, etc.)
+			continue
+		}
+		outbounds = append(outbounds, res.Outbound)
+		identities = append(identities, res.Identity)
+	}
+	return outbounds, identities, nil
+}
+
+func tryBase64(s string) (string, bool) {
+	// Remove whitespace that some providers insert.
+	clean := strings.Map(func(r rune) rune {
+		if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
+			return -1
+		}
+		return r
+	}, s)
+
+	// Common padding fix
+	for len(clean)%4 != 0 {
+		clean += "="
+	}
+
+	// Standard
+	if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	// URL-safe (no padding)
+	if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	// URL-safe with padding
+	if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	return "", false
+}
+
+func splitLines(s string) []string {
+	// Accept \n, \r\n, and also some providers use literal \n in the text.
+	s = strings.ReplaceAll(s, `\n`, "\n")
+	return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
+}
+
+// ParseLink parses a single share link and returns the outbound object plus
+// a stable identity for tag correlation. Supported schemes:
+//   - vmess://
+//   - vless://
+//   - trojan://
+//   - ss:// (modern and legacy)
+//   - hysteria2:// (also hy2://)
+//   - wireguard:// (also wg://)
+func ParseLink(link string) (*ParseResult, error) {
+	link = strings.TrimSpace(link)
+	switch {
+	case strings.HasPrefix(link, "vmess://"):
+		return parseVmess(link)
+	case strings.HasPrefix(link, "vless://"):
+		return parseVless(link)
+	case strings.HasPrefix(link, "trojan://"):
+		return parseTrojan(link)
+	case strings.HasPrefix(link, "ss://"):
+		return parseShadowsocks(link)
+	case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
+		return parseHysteria2(link)
+	case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
+		return parseWireguard(link)
+	default:
+		return nil, fmt.Errorf("unsupported link scheme")
+	}
+}
+
+// --- vmess ---
+
+func parseVmess(link string) (*ParseResult, error) {
+	b64 := strings.TrimPrefix(link, "vmess://")
+	// vmess:// base64(json)
+	raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
+	if err != nil {
+		// Some providers use raw URL-safe
+		raw, err = base64.RawURLEncoding.DecodeString(b64)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("vmess decode: %w", err)
+	}
+	var j map[string]any
+	if err := json.Unmarshal(raw, &j); err != nil {
+		return nil, fmt.Errorf("vmess json: %w", err)
+	}
+
+	identity := vmessIdentity(j)
+
+	network := getString(j, "net", "tcp")
+	security := "none"
+	if tls, _ := j["tls"].(string); tls == "tls" {
+		security = "tls"
+	}
+	stream := buildStream(network, security)
+
+	// Map known fields (best effort, matching frontend parser coverage)
+	switch network {
+	case "ws":
+		if host, ok := j["host"].(string); ok {
+			setWS(stream, host, getString(j, "path", "/"))
+		}
+	case "grpc":
+		svc := getString(j, "path", "")
+		if auth, ok := j["authority"].(string); ok && auth != "" {
+			(stream["grpcSettings"].(map[string]any))["authority"] = auth
+		}
+		(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
+		(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
+	case "httpupgrade":
+		setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
+	case "xhttp":
+		xh := stream["xhttpSettings"].(map[string]any)
+		xh["host"] = getString(j, "host", "")
+		xh["path"] = getString(j, "path", "/")
+		if m := getString(j, "mode", ""); m != "" {
+			xh["mode"] = m
+		}
+		// xhttp advanced keys are passed through if present in the json
+		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
+			if v, ok := j[k]; ok {
+				xh[k] = v
+			}
+		}
+	case "tcp":
+		if getString(j, "type", "") == "http" {
+			stream["tcpSettings"] = map[string]any{
+				"header": map[string]any{
+					"type": "http",
+					"request": map[string]any{
+						"version": "1.1",
+						"method":  "GET",
+						"path":    splitComma(getString(j, "path", "/")),
+						"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
+					},
+				},
+			}
+		}
+	}
+
+	if security == "tls" {
+		tls := stream["tlsSettings"].(map[string]any)
+		tls["serverName"] = getString(j, "sni", "")
+		tls["fingerprint"] = getString(j, "fp", "")
+		if alpn := getString(j, "alpn", ""); alpn != "" {
+			tls["alpn"] = splitComma(alpn)
+		}
+	}
+
+	port := num(j["port"])
+	ob := Outbound{
+		"protocol": "vmess",
+		"tag":      getString(j, "ps", ""),
+		"settings": map[string]any{
+			"vnext": []any{
+				map[string]any{
+					"address": getString(j, "add", ""),
+					"port":    port,
+					"users": []any{
+						map[string]any{
+							"id":       getString(j, "id", ""),
+							"security": getString(j, "scy", "auto"),
+						},
+					},
+				},
+			},
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func vmessIdentity(j map[string]any) string {
+	// Remove ps (remark) for identity
+	core := map[string]any{}
+	for k, v := range j {
+		if k == "ps" {
+			continue
+		}
+		core[k] = v
+	}
+	b, _ := json.Marshal(core)
+	return "vmess:" + string(b)
+}
+
+// --- vless / trojan (URL forms) ---
+
+func parseVless(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "vless" {
+		return nil, fmt.Errorf("not vless")
+	}
+	id := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+	network := params.Get("type")
+	if network == "" {
+		network = "tcp"
+	}
+	security := params.Get("security")
+	if security == "" {
+		security = "none"
+	}
+	stream := buildStream(network, security)
+	applyTransport(stream, params)
+	applySecurity(stream, params)
+	applyFinalMask(stream, params)
+
+	identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "vless",
+		"tag":      decodeHash(u.Fragment),
+		"settings": map[string]any{
+			"address":    host,
+			"port":       port,
+			"id":         id,
+			"flow":       params.Get("flow"),
+			"encryption": firstNonEmpty(params.Get("encryption"), "none"),
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func parseTrojan(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "trojan" {
+		return nil, fmt.Errorf("not trojan")
+	}
+	pw := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+	network := params.Get("type")
+	if network == "" {
+		network = "tcp"
+	}
+	security := params.Get("security")
+	if security == "" {
+		security = "tls"
+	}
+	stream := buildStream(network, security)
+	applyTransport(stream, params)
+	applySecurity(stream, params)
+	applyFinalMask(stream, params)
+
+	identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "trojan",
+		"tag":      decodeHash(u.Fragment),
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{"address": host, "port": port, "password": pw},
+			},
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- shadowsocks ---
+
+func parseShadowsocks(link string) (*ParseResult, error) {
+	// Two shapes:
+	//   ss://base64(method:pass)@host:port#remark
+	//   ss://base64(method:pass@host:port)#remark
+	remark := ""
+	if i := strings.Index(link, "#"); i >= 0 {
+		remark, _ = url.QueryUnescape(link[i+1:])
+		link = link[:i]
+	}
+	core := strings.TrimPrefix(link, "ss://")
+	at := strings.Index(core, "@")
+	if at >= 0 {
+		// modern
+		userB64 := core[:at]
+		hp := core[at+1:]
+		userInfo, err := base64DecodeFlexible(userB64)
+		if err != nil {
+			userInfo = userB64 // not b64, rare
+		}
+		colon := strings.LastIndex(hp, ":")
+		if colon < 0 {
+			return nil, fmt.Errorf("bad ss host:port")
+		}
+		host := hp[:colon]
+		port, _ := strconv.Atoi(hp[colon+1:])
+		method, pass := splitMethodPass(userInfo)
+		identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+		ob := Outbound{
+			"protocol": "shadowsocks",
+			"tag":      remark,
+			"settings": map[string]any{
+				"servers": []any{
+					map[string]any{"address": host, "port": port, "password": pass, "method": method},
+				},
+			},
+		}
+		return &ParseResult{Outbound: ob, Identity: identity}, nil
+	}
+	// legacy: whole thing b64
+	dec, err := base64DecodeFlexible(core)
+	if err != nil {
+		return nil, err
+	}
+	at = strings.Index(dec, "@")
+	if at < 0 {
+		return nil, fmt.Errorf("bad legacy ss")
+	}
+	userInfo := dec[:at]
+	hp := dec[at+1:]
+	colon := strings.LastIndex(hp, ":")
+	if colon < 0 {
+		return nil, fmt.Errorf("bad legacy ss hp")
+	}
+	host := hp[:colon]
+	port, _ := strconv.Atoi(hp[colon+1:])
+	method, pass := splitMethodPass(userInfo)
+	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+	ob := Outbound{
+		"protocol": "shadowsocks",
+		"tag":      remark,
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{"address": host, "port": port, "password": pass, "method": method},
+			},
+		},
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func splitMethodPass(userInfo string) (string, string) {
+	colon := strings.Index(userInfo, ":")
+	if colon < 0 {
+		return "2022-blake3-aes-128-gcm", userInfo // guess
+	}
+	return userInfo[:colon], userInfo[colon+1:]
+}
+
+// --- hysteria2 ---
+
+func parseHysteria2(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
+		return nil, fmt.Errorf("not hysteria2")
+	}
+	auth := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+
+	stream := map[string]any{
+		"network":  "hysteria",
+		"security": "tls",
+		"hysteriaSettings": map[string]any{
+			"version":        2,
+			"auth":           auth,
+			"udpIdleTimeout": 60,
+		},
+		"tlsSettings": map[string]any{
+			"serverName":           params.Get("sni"),
+			"alpn":                 splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
+			"fingerprint":          params.Get("fp"),
+			"echConfigList":        params.Get("ech"),
+			"verifyPeerCertByName": "",
+			"pinnedPeerCertSha256": params.Get("pinSHA256"),
+		},
+	}
+	applyFinalMask(stream, params)
+
+	identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol":       "hysteria",
+		"tag":            decodeHash(u.Fragment),
+		"settings":       map[string]any{"address": host, "port": port, "version": 2},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- wireguard ---
+
+func parseWireguard(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "wireguard" && u.Scheme != "wg" {
+		return nil, fmt.Errorf("not wireguard")
+	}
+	secret, _ := url.QueryUnescape(u.User.Username())
+	params := u.Query()
+	host := u.Hostname()
+	portStr := u.Port()
+	endpoint := host
+	if portStr != "" {
+		endpoint = host + ":" + portStr
+	}
+
+	addrRaw := firstParam(params, "address", "ip")
+	allowedRaw := firstParam(params, "allowedips", "allowed_ips")
+	addrs := splitComma(addrRaw)
+	if len(addrs) == 0 {
+		addrs = []string{"0.0.0.0/0", "::/0"}
+	}
+	allowed := splitComma(allowedRaw)
+	if len(allowed) == 0 {
+		allowed = []string{"0.0.0.0/0", "::/0"}
+	}
+
+	peer := map[string]any{
+		"publicKey":  firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
+		"endpoint":   endpoint,
+		"allowedIPs": allowed,
+	}
+	if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
+		peer["preSharedKey"] = psk
+	}
+	if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
+		if n, err := strconv.Atoi(ka); err == nil {
+			peer["keepAlive"] = n
+		}
+	}
+
+	settings := map[string]any{
+		"secretKey": secret,
+		"address":   addrs,
+		"peers":     []any{peer},
+	}
+	if mtu := params.Get("mtu"); mtu != "" {
+		if n, err := strconv.Atoi(mtu); err == nil {
+			settings["mtu"] = n
+		}
+	}
+	if res := params.Get("reserved"); res != "" {
+		parts := splitComma(res)
+		var iv []int
+		for _, p := range parts {
+			if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+				iv = append(iv, n)
+			}
+		}
+		if len(iv) > 0 {
+			settings["reserved"] = iv
+		}
+	}
+
+	identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "wireguard",
+		"tag":      decodeHash(u.Fragment),
+		"settings": settings,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- helpers ---
+
+func buildStream(network, security string) map[string]any {
+	stream := map[string]any{"network": network, "security": security}
+	switch network {
+	case "tcp":
+		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
+	case "kcp":
+		stream["kcpSettings"] = map[string]any{
+			"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
+			"cwndMultiplier": 1, "maxSendingWindow": 2097152,
+		}
+	case "ws":
+		stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
+	case "grpc":
+		stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
+	case "httpupgrade":
+		stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
+	case "xhttp":
+		stream["xhttpSettings"] = map[string]any{
+			"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
+			"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
+		}
+	default:
+		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
+	}
+	if security == "tls" {
+		stream["tlsSettings"] = map[string]any{
+			"serverName": "", "alpn": []any{}, "fingerprint": "",
+			"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
+		}
+	} else if security == "reality" {
+		stream["realitySettings"] = map[string]any{
+			"publicKey": "", "fingerprint": "chrome", "serverName": "",
+			"shortId": "", "spiderX": "", "mldsa65Verify": "",
+		}
+	}
+	return stream
+}
+
+func setWS(stream map[string]any, host, path string) {
+	ws := stream["wsSettings"].(map[string]any)
+	ws["host"] = host
+	ws["path"] = path
+}
+
+func setHTTPUpgrade(stream map[string]any, host, path string) {
+	h := stream["httpupgradeSettings"].(map[string]any)
+	h["host"] = host
+	h["path"] = path
+}
+
+func applyTransport(stream map[string]any, p url.Values) {
+	net := stream["network"].(string)
+	host := p.Get("host")
+	path := firstNonEmpty(p.Get("path"), "/")
+	switch net {
+	case "ws":
+		setWS(stream, host, path)
+	case "grpc":
+		gs := stream["grpcSettings"].(map[string]any)
+		gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
+		gs["authority"] = p.Get("authority")
+		gs["multiMode"] = p.Get("mode") == "multi"
+	case "httpupgrade":
+		setHTTPUpgrade(stream, host, path)
+	case "xhttp":
+		xh := stream["xhttpSettings"].(map[string]any)
+		xh["host"] = host
+		xh["path"] = path
+		if m := p.Get("mode"); m != "" {
+			xh["mode"] = m
+		}
+		// A few advanced xhttp fields that are commonly carried
+		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
+			if v := p.Get(k); v != "" {
+				xh[k] = v
+			}
+		}
+	case "tcp":
+		if p.Get("headerType") == "http" || p.Get("type") == "http" {
+			stream["tcpSettings"] = map[string]any{
+				"header": map[string]any{
+					"type": "http",
+					"request": map[string]any{
+						"version": "1.1",
+						"method":  "GET",
+						"path":    splitComma(path),
+						"headers": map[string]any{"Host": splitComma(host)},
+					},
+				},
+			}
+		}
+	}
+}
+
+func applySecurity(stream map[string]any, p url.Values) {
+	sec := stream["security"].(string)
+	if sec == "tls" {
+		tls := stream["tlsSettings"].(map[string]any)
+		tls["serverName"] = p.Get("sni")
+		tls["fingerprint"] = p.Get("fp")
+		if alpn := p.Get("alpn"); alpn != "" {
+			tls["alpn"] = splitComma(alpn)
+		}
+		tls["echConfigList"] = p.Get("ech")
+		tls["pinnedPeerCertSha256"] = p.Get("pcs")
+	} else if sec == "reality" {
+		re := stream["realitySettings"].(map[string]any)
+		re["serverName"] = p.Get("sni")
+		re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
+		re["publicKey"] = p.Get("pbk")
+		re["shortId"] = p.Get("sid")
+		re["spiderX"] = p.Get("spx")
+		re["mldsa65Verify"] = p.Get("pqv")
+	}
+}
+
+func applyFinalMask(stream map[string]any, p url.Values) {
+	if fm := p.Get("fm"); fm != "" {
+		var parsed any
+		if json.Unmarshal([]byte(fm), &parsed) == nil {
+			stream["finalmask"] = parsed
+		}
+	}
+}
+
+func firstNonEmpty(a, b string) string {
+	if a != "" {
+		return a
+	}
+	return b
+}
+
+func firstParam(p url.Values, keys ...string) string {
+	for _, k := range keys {
+		if v := p.Get(k); v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+func canonicalQuery(p url.Values) string {
+	// Sort keys for stable identity
+	keys := make([]string, 0, len(p))
+	for k := range p {
+		keys = append(keys, k)
+	}
+	// simple sort
+	for i := 0; i < len(keys); i++ {
+		for j := i + 1; j < len(keys); j++ {
+			if keys[j] < keys[i] {
+				keys[i], keys[j] = keys[j], keys[i]
+			}
+		}
+	}
+	parts := make([]string, 0, len(keys))
+	for _, k := range keys {
+		for _, v := range p[k] {
+			parts = append(parts, k+"="+v)
+		}
+	}
+	return strings.Join(parts, "&")
+}
+
+func decodeHash(h string) string {
+	if h == "" {
+		return ""
+	}
+	if dec, err := url.QueryUnescape(h); err == nil {
+		return dec
+	}
+	return h
+}
+
+func defaultPort(p string, def int) int {
+	if p == "" {
+		return def
+	}
+	n, err := strconv.Atoi(p)
+	if err != nil || n <= 0 {
+		return def
+	}
+	return n
+}
+
+func num(v any) int {
+	switch x := v.(type) {
+	case float64:
+		return int(x)
+	case int:
+		return x
+	case int64:
+		return int(x)
+	case string:
+		n, _ := strconv.Atoi(x)
+		return n
+	}
+	return 0
+}
+
+func getString(m map[string]any, key, def string) string {
+	if v, ok := m[key]; ok {
+		if s, ok := v.(string); ok {
+			return s
+		}
+	}
+	return def
+}
+
+func splitComma(s string) []string {
+	if s == "" {
+		return nil
+	}
+	parts := strings.Split(s, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+func splitCommaOrDefault(s string, def []string) []string {
+	if s == "" {
+		return def
+	}
+	return splitComma(s)
+}
+
+func padBase64(s string) string {
+	for len(s)%4 != 0 {
+		s += "="
+	}
+	return s
+}
+
+func base64DecodeFlexible(s string) (string, error) {
+	s = padBase64(s)
+	if b, err := base64.StdEncoding.DecodeString(s); err == nil {
+		return string(b), nil
+	}
+	if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
+		return string(b), nil
+	}
+	return "", fmt.Errorf("base64 decode failed")
+}
+
+// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
+var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
+
+func SlugRemark(remark string) string {
+	s := strings.ToLower(strings.TrimSpace(remark))
+	s = slugRe.ReplaceAllString(s, "-")
+	s = strings.Trim(s, "-")
+	if s == "" {
+		return ""
+	}
+	// collapse runs of dashes
+	for strings.Contains(s, "--") {
+		s = strings.ReplaceAll(s, "--", "-")
+	}
+	return s
+}
+
+// SuggestTag builds a tag from a prefix and a remark (or index fallback).
+// It is intended for initial assignment; stability is handled by the service layer.
+func SuggestTag(prefix, remark string, idx int) string {
+	base := SlugRemark(remark)
+	if base == "" {
+		base = fmt.Sprintf("%d", idx)
+	}
+	p := strings.TrimSuffix(prefix, "-")
+	if p != "" {
+		return p + "-" + base
+	}
+	return base
+}

+ 62 - 0
util/link/outbound_test.go

@@ -0,0 +1,62 @@
+package link
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestParseVmessLink(t *testing.T) {
+	// vmess:// + base64 of:
+	// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
+	link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
+	res, err := ParseLink(link)
+	if err != nil {
+		t.Fatalf("parse vmess: %v", err)
+	}
+	if res.Outbound["protocol"] != "vmess" {
+		t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
+	}
+	if res.Outbound["tag"] != "test" {
+		t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
+	}
+}
+
+func TestParseVlessLink(t *testing.T) {
+	link := "vless://[email protected]:443?type=ws&security=tls&path=/&host=ex.com#node1"
+	res, err := ParseLink(link)
+	if err != nil {
+		t.Fatalf("parse vless: %v", err)
+	}
+	if res.Outbound["protocol"] != "vless" {
+		t.Fatalf("bad protocol")
+	}
+	if res.Outbound["tag"] != "node1" {
+		t.Errorf("tag mismatch: %v", res.Outbound["tag"])
+	}
+}
+
+func TestParseSubscriptionBody_Base64(t *testing.T) {
+	// base64 of the two joined links:
+	// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
+	b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
+	obs, ids, err := ParseSubscriptionBody([]byte(b64))
+	if err != nil {
+		t.Fatalf("parse sub body: %v", err)
+	}
+	if len(obs) != 2 {
+		t.Fatalf("expected 2 outbounds, got %d", len(obs))
+	}
+	if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
+		t.Errorf("bad identities: %v", ids)
+	}
+}
+
+func TestSlugAndSuggest(t *testing.T) {
+	if SlugRemark("Hello World!") != "hello-world" {
+		t.Errorf("slug failed")
+	}
+	tag := SuggestTag("hk-", "  SG 01 !! ", 0)
+	if tag != "hk-sg-01" {
+		t.Errorf("suggest tag got %q", tag)
+	}
+}

+ 24 - 0
util/wireguard.go

@@ -0,0 +1,24 @@
+package util
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+
+	"golang.org/x/crypto/curve25519"
+)
+
+// GenerateWireguardKeypair generates a base64 encoded private and public key pair for Wireguard.
+func GenerateWireguardKeypair() (privateKey string, publicKey string, err error) {
+	var priv [32]byte
+	if _, err := rand.Read(priv[:]); err != nil {
+		return "", "", err
+	}
+	priv[0] &= 248
+	priv[31] &= 127
+	priv[31] |= 64
+
+	var pub [32]byte
+	curve25519.ScalarBaseMult(&pub, &priv)
+
+	return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
+}

+ 10 - 2
web/controller/inbound.go

@@ -350,8 +350,16 @@ func (a *InboundController) importInbound(c *gin.Context) {
 	user := session.GetLoginUser(c)
 	inbound.Id = 0
 	inbound.UserId = user.Id
-	if inbound.NodeID != nil && *inbound.NodeID == 0 {
-		inbound.NodeID = nil
+	// Node IDs are panel-local and not portable across panels. Drop a node
+	// reference that is zero or that points to a node which doesn't exist on
+	// this panel, so a cross-panel export imports as a local inbound instead of
+	// failing with "record not found" when nodePushPlan looks the node up.
+	if inbound.NodeID != nil {
+		if *inbound.NodeID == 0 {
+			inbound.NodeID = nil
+		} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
+			inbound.NodeID = nil
+		}
 	}
 
 	for index := range inbound.ClientStats {

+ 18 - 0
web/controller/server.go

@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/web/global"
@@ -61,6 +62,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getNewmldsa65", a.getNewmldsa65)
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
 	g.GET("/getNewVlessEnc", a.getNewVlessEnc)
+	g.GET("/clientIps", a.getClientIps)
 
 	g.POST("/stopXrayService", a.stopXrayService)
 	g.POST("/restartXrayService", a.restartXrayService)
@@ -72,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.POST("/xraylogs/:count", a.getXrayLogs)
 	g.POST("/importDB", a.importDB)
 	g.POST("/getNewEchCert", a.getNewEchCert)
+	g.POST("/clientIps", a.setClientIps)
 }
 
 // startTask registers the @2s ticker that refreshes server status, samples
@@ -420,3 +423,18 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) {
 	}
 	jsonObj(c, out, nil)
 }
+
+func (a *ServerController) getClientIps(c *gin.Context) {
+	ips, err := (&service.InboundService{}).GetAllInboundClientIps()
+	jsonObj(c, ips, err)
+}
+
+func (a *ServerController) setClientIps(c *gin.Context) {
+	var ips []model.InboundClientIps
+	if err := c.ShouldBindJSON(&ips); err != nil {
+		jsonMsg(c, "invalid data", err)
+		return
+	}
+	err := (&service.InboundService{}).MergeInboundClientIps(ips)
+	jsonMsg(c, "Client IPs merged", err)
+}

+ 195 - 7
web/controller/xray_setting.go

@@ -2,6 +2,9 @@ package controller
 
 import (
 	"encoding/json"
+	"fmt"
+	"strconv"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -11,13 +14,14 @@ import (
 
 // XraySettingController handles Xray configuration and settings operations.
 type XraySettingController struct {
-	XraySettingService service.XraySettingService
-	SettingService     service.SettingService
-	InboundService     service.InboundService
-	OutboundService    service.OutboundService
-	XrayService        service.XrayService
-	WarpService        service.WarpService
-	NordService        service.NordService
+	XraySettingService          service.XraySettingService
+	SettingService              service.SettingService
+	InboundService              service.InboundService
+	OutboundService             service.OutboundService
+	XrayService                 service.XrayService
+	WarpService                 service.WarpService
+	NordService                 service.NordService
+	OutboundSubscriptionService service.OutboundSubscriptionService
 }
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -40,6 +44,16 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
+
+	// Outbound subscription (remote outbound lists)
+	g.GET("/outbound-subs", a.listOutboundSubs)
+	g.POST("/outbound-subs", a.createOutboundSub)
+	g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
+	g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
+	g.POST("/outbound-subs/:id", a.updateOutboundSub)
+	g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
+	g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
+	g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
 }
 
 // getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
@@ -85,6 +99,17 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
 		"clientReverseTags": json.RawMessage(clientReverseTags),
 		"outboundTestUrl":   outboundTestUrl,
 	}
+
+	// Surface subscription outbounds (and their tags) so the frontend can:
+	// - show them as read-only items in the Outbounds tab
+	// - let users pick them in balancers and routing rules
+	// These are not part of the editable template; they are injected at runtime.
+	if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
+		xrayResponse["subscriptionOutbounds"] = subObs
+	}
+	if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
+		xrayResponse["subscriptionOutboundTags"] = subTags
+	}
 	result, err := json.Marshal(xrayResponse)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
@@ -142,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
 		skey := c.PostForm("privateKey")
 		pkey := c.PostForm("publicKey")
 		resp, err = a.WarpService.RegWarp(skey, pkey)
+	case "changeIp":
+		resp, err = a.WarpService.ChangeWarpIP()
+		if err == nil {
+			a.XrayService.SetToNeedRestart()
+			// Restart the auto-update clock so a scheduled rotation
+			// doesn't fire right after this manual one.
+			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+		}
 	case "license":
 		license := c.PostForm("license")
 		resp, err = a.WarpService.SetWarpLicense(license)
+	case "interval":
+		interval, convErr := strconv.Atoi(c.PostForm("interval"))
+		if convErr != nil || interval < 0 {
+			err = common.NewError("invalid warp update interval")
+		} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
+			// Count the interval from now rather than from epoch 0,
+			// otherwise the job would rotate on its next tick.
+			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+		}
 	}
 
 	jsonObj(c, resp, err)
@@ -227,3 +269,149 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 
 	jsonObj(c, result, nil)
 }
+
+// --- Outbound Subscription handlers ---
+
+func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
+	list, err := a.OutboundSubscriptionService.List()
+	if err != nil {
+		jsonMsg(c, "Failed to list outbound subscriptions", err)
+		return
+	}
+	jsonObj(c, list, nil)
+}
+
+func (a *XraySettingController) createOutboundSub(c *gin.Context) {
+	remark := c.PostForm("remark")
+	rawURL := c.PostForm("url")
+	prefix := c.PostForm("tagPrefix")
+	enabled := c.PostForm("enabled") != "false"
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	prepend := c.PostForm("prepend") == "true"
+	intervalStr := c.PostForm("updateInterval")
+	interval := 600
+	if intervalStr != "" {
+		if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
+			interval = v
+		}
+	}
+	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
+	if err != nil {
+		jsonMsg(c, "Failed to create outbound subscription", err)
+		return
+	}
+	jsonObj(c, sub, nil)
+}
+
+func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	remark := c.PostForm("remark")
+	rawURL := c.PostForm("url")
+	prefix := c.PostForm("tagPrefix")
+	enabled := c.PostForm("enabled") != "false"
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	prepend := c.PostForm("prepend") == "true"
+	intervalStr := c.PostForm("updateInterval")
+	interval := 600
+	if intervalStr != "" {
+		if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
+			interval = v
+		}
+	}
+	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
+		jsonMsg(c, "Failed to update outbound subscription", err)
+		return
+	}
+	jsonObj(c, "", nil)
+}
+
+func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
+		jsonMsg(c, "Failed to delete outbound subscription", err)
+		return
+	}
+	// Signal that xray should drop this subscription's outbounds on next reload.
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, "", nil)
+}
+
+func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	obs, err := a.OutboundSubscriptionService.Refresh(subID)
+	if err != nil {
+		jsonMsg(c, "Refresh failed", err)
+		return
+	}
+	// Signal that xray should pick up the new outbounds on next restart/reload
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, obs, nil)
+}
+
+func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	up := c.PostForm("dir") == "up"
+	if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
+		jsonMsg(c, "Failed to reorder outbound subscription", err)
+		return
+	}
+	// Order affects the merged outbounds, so xray needs a reload.
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, "", nil)
+}
+
+// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
+// URL but does not persist anything. Useful for the "add subscription" flow
+// so the user can see the resulting outbounds (and assigned tags) before saving.
+func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
+	rawURL := c.PostForm("url")
+	if rawURL == "" {
+		jsonMsg(c, "url is required", common.NewError("missing url"))
+		return
+	}
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	// Use a throw-away service instance; it only needs the settingService for proxy.
+	svc := service.OutboundSubscriptionService{}
+	// We don't have a direct "fetch once" that returns without storing, so we
+	// temporarily create a disabled row, refresh it, then delete. Cleaner would
+	// be to expose a pure ParseURL on the service, but this keeps the surface small.
+	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
+	if err != nil {
+		jsonMsg(c, "Failed to preview subscription", err)
+		return
+	}
+	obs, err := svc.Refresh(tmp.Id)
+	// best-effort cleanup
+	_ = svc.Delete(tmp.Id)
+	if err != nil {
+		jsonMsg(c, "Failed to fetch/parse subscription", err)
+		return
+	}
+	jsonObj(c, obs, nil)
+}
+
+func parseIntSafe(s string) (int, error) {
+	var v int
+	_, err := fmt.Sscanf(s, "%d", &v)
+	return v, err
+}

+ 4 - 0
web/entity/entity.go

@@ -88,6 +88,7 @@ type AllSetting struct {
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
+	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`           // Absolute path to a folder containing a custom subscription page template
 
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`
@@ -112,6 +113,9 @@ type AllSetting struct {
 	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
 	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
 	// JSON subscription routing rules
+
+	// WARP
+	WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
 }
 
 // AllSettingView is the browser-safe settings read model. Secret values

+ 5 - 1
web/job/check_client_ip_job.go

@@ -242,9 +242,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
 	live = make([]IPWithTimestamp, 0, len(observedThisScan))
 	historical = make([]IPWithTimestamp, 0, len(ipMap))
+	now := time.Now().Unix()
 	for ip, ts := range ipMap {
 		entry := IPWithTimestamp{IP: ip, Timestamp: ts}
-		if observedThisScan[ip] {
+		// Consider an IP "live" if it was seen locally in this scan, OR if its
+		// timestamp from the synced database is very recent (e.g. within 2 minutes).
+		// This ensures cluster-wide limits work even if the IP was seen on another node.
+		if observedThisScan[ip] || now-ts < 120 {
 			live = append(live, entry)
 		} else {
 			historical = append(historical, entry)

+ 21 - 0
web/job/check_client_ip_job_test.go

@@ -6,6 +6,7 @@ import (
 	"reflect"
 	"runtime"
 	"testing"
+	"time"
 )
 
 func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
@@ -149,6 +150,26 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
 	}
 }
 
+func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
+	// Synced IPs from other nodes within 2 minutes should be counted as live
+	// even if they weren't observed in the local scan.
+	now := time.Now().Unix()
+	ipMap := map[string]int64{
+		"A": now - 30,  // synced 30s ago -> live
+		"B": now - 150, // synced 2m30s ago -> historical
+	}
+	observed := map[string]bool{}
+
+	live, historical := partitionLiveIps(ipMap, observed)
+
+	if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
+		t.Fatalf("recent IP should be live\ngot:  %v\nwant: [A]", got)
+	}
+	if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
+		t.Fatalf("older IP should be historical\ngot:  %v\nwant: [B]", got)
+	}
+}
+
 func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
 	t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
 	marker := fakeFail2BanClient(t)

+ 39 - 2
web/job/node_traffic_sync_job.go

@@ -16,6 +16,7 @@ const (
 	nodeTrafficSyncConcurrency    = 8
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
 	nodeReconcileTimeout          = 30 * time.Second
+	nodeClientIpSyncInterval      = 10 * time.Second
 )
 
 type NodeTrafficSyncJob struct {
@@ -25,6 +26,8 @@ type NodeTrafficSyncJob struct {
 	xrayService    service.XrayService
 	running        sync.Mutex
 	structural     atomicBool
+	ipSyncMu       sync.Mutex
+	lastIpSync     int64
 }
 
 type atomicBool struct {
@@ -70,6 +73,16 @@ func (j *NodeTrafficSyncJob) Run() {
 		return
 	}
 
+	// Decide once per tick whether this run also syncs client IPs, and stamp the
+	// clock before the loop so two back-to-back 5s ticks can't both qualify.
+	doIpSync := false
+	j.ipSyncMu.Lock()
+	if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
+		doIpSync = true
+		j.lastIpSync = now
+	}
+	j.ipSyncMu.Unlock()
+
 	sem := make(chan struct{}, nodeTrafficSyncConcurrency)
 	var wg sync.WaitGroup
 	for _, n := range nodes {
@@ -81,7 +94,7 @@ func (j *NodeTrafficSyncJob) Run() {
 		go func(n *model.Node) {
 			defer wg.Done()
 			defer func() { <-sem }()
-			j.syncOne(mgr, n)
+			j.syncOne(mgr, n, doIpSync)
 		}(n)
 	}
 	wg.Wait()
@@ -151,7 +164,7 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 }
 
-func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
+func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
 	rt, err := mgr.RemoteFor(n)
 	if err != nil {
 		logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
@@ -190,4 +203,28 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
 	if changed {
 		j.structural.set()
 	}
+
+	if !doIpSync {
+		return
+	}
+
+	nodeIps, err := rt.FetchAllClientIps(ctx)
+	if err == nil && len(nodeIps) > 0 {
+		if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
+			logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
+		}
+	} else if err != nil {
+		logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
+	}
+
+	masterIps, err := j.inboundService.GetAllInboundClientIps()
+	if err != nil {
+		logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
+		return
+	}
+	if len(masterIps) > 0 {
+		if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
+			logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
+		}
+	}
 }

+ 48 - 0
web/job/outbound_subscription_job.go

@@ -0,0 +1,48 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+)
+
+// OutboundSubscriptionJob periodically re-fetches enabled outbound subscriptions,
+// updates the stored outbounds (with stable tags), and signals that xray
+// should be reloaded so the new outbounds take effect.
+type OutboundSubscriptionJob struct {
+	subService *service.OutboundSubscriptionService
+	xraySvc    *service.XrayService
+}
+
+// NewOutboundSubscriptionJob creates the job (zero-value services are populated
+// on first Run via method calls, same pattern as other jobs).
+func NewOutboundSubscriptionJob() *OutboundSubscriptionJob {
+	return &OutboundSubscriptionJob{
+		subService: &service.OutboundSubscriptionService{},
+		xraySvc:    &service.XrayService{},
+	}
+}
+
+// Run is invoked by the cron scheduler.
+func (j *OutboundSubscriptionJob) Run() {
+	if j.subService == nil {
+		j.subService = &service.OutboundSubscriptionService{}
+	}
+	if j.xraySvc == nil {
+		j.xraySvc = &service.XrayService{}
+	}
+
+	count, err := j.subService.RefreshAllEnabled()
+	if err != nil {
+		logger.Warning("outbound subscription auto-update error:", err)
+		return
+	}
+	if count > 0 {
+		logger.Infof("Refreshed %d outbound subscription(s)", count)
+		// Ask the xray manager to restart/reload on the next 30s check.
+		j.xraySvc.SetToNeedRestart()
+		// Also broadcast an invalidate so the UI can refresh the xray setting
+		// view (new outbounds will be visible after the reload cycle).
+		websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
+	}
+}

+ 52 - 0
web/job/warp_ip_job.go

@@ -0,0 +1,52 @@
+package job
+
+import (
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+)
+
+type WarpIpJob struct {
+	settingService service.SettingService
+	warpService    service.WarpService
+	xrayService    service.XrayService
+}
+
+func NewWarpIpJob() *WarpIpJob {
+	return &WarpIpJob{}
+}
+
+func (j *WarpIpJob) Run() {
+	allSetting, err := j.settingService.GetAllSetting()
+	if err != nil {
+		return
+	}
+	interval := allSetting.WarpUpdateInterval
+	if interval <= 0 {
+		return
+	}
+
+	lastUpdate, _ := j.settingService.GetWarpLastUpdate()
+	now := time.Now().Unix()
+
+	// First run after the feature is enabled (e.g. interval set via direct
+	// DB edit): establish a baseline instead of rotating immediately.
+	if lastUpdate == 0 {
+		_ = j.settingService.SetWarpLastUpdate(now)
+		return
+	}
+
+	if now-lastUpdate >= int64(interval*24*3600) {
+		logger.Info("Starting scheduled WARP IP update...")
+		_, err := j.warpService.ChangeWarpIP()
+		if err != nil {
+			logger.Warning("Failed to update WARP IP: ", err)
+			return
+		}
+
+		_ = j.settingService.SetWarpLastUpdate(now)
+		j.xrayService.SetToNeedRestart()
+		logger.Info("Successfully updated WARP IP and scheduled Xray restart")
+	}
+}

+ 4 - 0
web/runtime/local.go

@@ -159,3 +159,7 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
 func (l *Local) ResetAllTraffics(_ context.Context) error {
 	return nil
 }
+
+func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
+	return nil
+}

+ 24 - 0
web/runtime/remote.go

@@ -399,6 +399,11 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
 	return err
 }
 
+func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
+	_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
+	return err
+}
+
 type TrafficSnapshot struct {
 	Inbounds     []*model.Inbound
 	OnlineEmails []string
@@ -533,3 +538,22 @@ func isNonEmptySlice(v any) bool {
 	s, ok := v.([]any)
 	return ok && len(s) > 0
 }
+
+func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
+	if err != nil {
+		return nil, err
+	}
+	var ips []model.InboundClientIps
+	if len(env.Obj) > 0 {
+		if err := json.Unmarshal(env.Obj, &ips); err != nil {
+			return nil, fmt.Errorf("decode client ips: %w", err)
+		}
+	}
+	return ips, nil
+}
+
+func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
+	return err
+}

+ 1 - 0
web/runtime/runtime.go

@@ -26,5 +26,6 @@ type Runtime interface {
 	RestartXray(ctx context.Context) error
 
 	ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
+	ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error
 	ResetAllTraffics(ctx context.Context) error
 }

+ 17 - 9
web/service/client.go

@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 type GroupSummary struct {
 	Name        string `json:"name"`
 	ClientCount int    `json:"clientCount"`
+	TrafficUsed int64  `json:"trafficUsed"`
 }
 
 func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	db := database.GetDB()
+	// email is unique in both clients and client_traffics, so the LEFT JOIN
+	// never double-counts a client's traffic.
 	var derived []GroupSummary
-	if err := db.Model(&model.ClientRecord{}).
-		Select("group_name AS name, COUNT(*) AS client_count").
-		Where("group_name <> ''").
-		Group("group_name").
+	if err := db.Table("clients AS c").
+		Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
+		Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
+		Where("c.group_name <> ''").
+		Group("c.group_name").
 		Scan(&derived).Error; err != nil {
 		return nil, err
 	}
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	if err := db.Find(&stored).Error; err != nil {
 		return nil, err
 	}
-	merged := make(map[string]int, len(derived)+len(stored))
+	type groupAgg struct {
+		count   int
+		traffic int64
+	}
+	merged := make(map[string]groupAgg, len(derived)+len(stored))
 	for _, g := range stored {
-		merged[g.Name] = 0
+		merged[g.Name] = groupAgg{}
 	}
 	for _, g := range derived {
-		merged[g.Name] = g.ClientCount
+		merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
 	}
 	out := make([]GroupSummary, 0, len(merged))
-	for name, count := range merged {
-		out = append(out, GroupSummary{Name: name, ClientCount: count})
+	for name, agg := range merged {
+		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
 	}
 	sort.Slice(out, func(i, j int) bool {
 		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)

+ 198 - 39
web/service/inbound.go

@@ -399,6 +399,137 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	return out, nil
 }
 
+func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
+	db := database.GetDB()
+	var ips []model.InboundClientIps
+	err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
+	return ips, err
+}
+
+// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
+// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
+// the synced blob bounded and stops the master's push-back from resurrecting IPs that
+// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
+const clientIpStaleAfterSeconds = int64(30 * 60)
+
+// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
+// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
+type clientIpEntry struct {
+	IP        string `json:"ip"`
+	Timestamp int64  `json:"timestamp"`
+}
+
+// mergeClientIpEntries unions old and incoming IP observations, dropping anything
+// older than cutoff, keeping the most recent timestamp per IP, and returning the
+// result sorted newest-first.
+func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
+	ipMap := make(map[string]int64, len(old)+len(incoming))
+	for _, e := range old {
+		if e.Timestamp < cutoff {
+			continue
+		}
+		ipMap[e.IP] = e.Timestamp
+	}
+	for _, e := range incoming {
+		if e.Timestamp < cutoff {
+			continue
+		}
+		if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
+			ipMap[e.IP] = e.Timestamp
+		}
+	}
+	out := make([]clientIpEntry, 0, len(ipMap))
+	for ip, ts := range ipMap {
+		out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
+	return out
+}
+
+// MergeInboundClientIps folds client IPs synced from another node into the local
+// inbound_client_ips table without double-counting an IP seen on multiple nodes and
+// without resurrecting stale entries. Existing rows are updated in place; brand-new
+// clients (typically node-only clients with no local row) are created with a fresh
+// local id.
+func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
+	db := database.GetDB()
+	var currentIps []model.InboundClientIps
+	if err := db.Model(&model.InboundClientIps{}).Find(&currentIps).Error; err != nil {
+		return err
+	}
+
+	currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
+	for i := range currentIps {
+		currentMap[currentIps[i].ClientEmail] = &currentIps[i]
+	}
+
+	now := time.Now().Unix()
+	cutoff := now - clientIpStaleAfterSeconds
+
+	tx := db.Begin()
+	defer func() {
+		if r := recover(); r != nil {
+			tx.Rollback()
+		}
+	}()
+
+	for _, incoming := range incomingIps {
+		if incoming.ClientEmail == "" || incoming.Ips == "" {
+			continue
+		}
+
+		var incomingEntries []clientIpEntry
+		_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
+
+		current, exists := currentMap[incoming.ClientEmail]
+		if !exists {
+			// New client we've never seen locally. Drop stale entries up front and
+			// skip the row entirely if nothing is fresh, so we don't persist a row
+			// that is dead on arrival.
+			fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
+			if len(fresh) == 0 {
+				continue
+			}
+			b, _ := json.Marshal(fresh)
+			incoming.Ips = string(b)
+			// Never carry the remote node's primary key into the local table: id
+			// spaces are independent across nodes and the remote id would collide
+			// with an unrelated local row. OnConflict guards the race where
+			// check_client_ip_job creates the same brand-new email between the
+			// snapshot above and this insert.
+			incoming.Id = 0
+			if err := tx.Clauses(clause.OnConflict{
+				Columns:   []clause.Column{{Name: "client_email"}},
+				DoNothing: true,
+			}).Create(&incoming).Error; err != nil {
+				tx.Rollback()
+				return err
+			}
+			continue
+		}
+
+		var oldEntries []clientIpEntry
+		if current.Ips != "" {
+			_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
+		}
+
+		merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
+		b, _ := json.Marshal(merged)
+		mergedStr := string(b)
+
+		// A concurrent check_client_ip_job db.Save on the same row can interleave
+		// with this update (benign last-writer-wins; any dropped IP reappears on the
+		// next scan/sync), so only write when the blob actually changed.
+		if current.Ips != mergedStr {
+			if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
+				tx.Rollback()
+				return err
+			}
+		}
+	}
+	return tx.Commit().Error
+}
+
 // inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
 // the client UI can generate a valid PSK (base64 of the method's key length)
 // for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
@@ -1665,23 +1796,24 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				continue
 			}
 			newIb := model.Inbound{
-				UserId:         defaultUserId,
-				NodeID:         &nodeID,
-				OriginNodeGuid: originGuidFor(snapIb),
-				Tag:            chosenTag,
-				Listen:         snapIb.Listen,
-				Port:           snapIb.Port,
-				Protocol:       snapIb.Protocol,
-				Settings:       snapIb.Settings,
-				StreamSettings: snapIb.StreamSettings,
-				Sniffing:       snapIb.Sniffing,
-				TrafficReset:   snapIb.TrafficReset,
-				Enable:         snapIb.Enable,
-				Remark:         snapIb.Remark,
-				Total:          snapIb.Total,
-				ExpiryTime:     snapIb.ExpiryTime,
-				Up:             snapIb.Up,
-				Down:           snapIb.Down,
+				UserId:               defaultUserId,
+				NodeID:               &nodeID,
+				OriginNodeGuid:       originGuidFor(snapIb),
+				Tag:                  chosenTag,
+				Listen:               snapIb.Listen,
+				Port:                 snapIb.Port,
+				Protocol:             snapIb.Protocol,
+				Settings:             snapIb.Settings,
+				StreamSettings:       snapIb.StreamSettings,
+				Sniffing:             snapIb.Sniffing,
+				TrafficReset:         snapIb.TrafficReset,
+				LastTrafficResetTime: snapIb.LastTrafficResetTime,
+				Enable:               snapIb.Enable,
+				Remark:               snapIb.Remark,
+				Total:                snapIb.Total,
+				ExpiryTime:           snapIb.ExpiryTime,
+				Up:                   snapIb.Up,
+				Down:                 snapIb.Down,
 			}
 			if err := tx.Create(&newIb).Error; err != nil {
 				logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
@@ -1710,6 +1842,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["stream_settings"] = snapIb.StreamSettings
 			updates["sniffing"] = snapIb.Sniffing
 			updates["traffic_reset"] = snapIb.TrafficReset
+			updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
 		}
 		if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
 			updates["up"] = snapIb.Up
@@ -1755,9 +1888,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			return false, err
 		}
 		if len(goneEmails) > 0 {
-			if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails).
-				Delete(&model.NodeClientTraffic{}).Error; err != nil {
-				return false, err
+			// Chunk to avoid SQLite bind var limit when a node has many clients
+			// removed (e.g. after API bulk delete or structural change on node inbound).
+			for _, batch := range chunkStrings(goneEmails, sqliteMaxVars) {
+				if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch).
+					Delete(&model.NodeClientTraffic{}).Error; err != nil {
+					return false, err
+				}
 			}
 		}
 		if err := tx.Where("inbound_id = ?", c.Id).
@@ -1836,18 +1973,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				structuralChange = true
 			}
 
-			// Only allow the node to disable a client (cs.Enable=false), never
-			// to re-enable one the panel has already disabled. A stale snapshot
-			// from the node arriving after a central disable would otherwise
-			// overwrite enable=false back to true, letting the client accumulate
-			// far more traffic than their limit before being disabled again.
-			//
-			// We use a dialect-aware expression (see database.ClientTrafficEnableMergeExpr)
-			// because the old "enable AND ?" form (and naive CASE with :: casts)
-			// caused type mismatches on PostgreSQL after public API inbound updates
-			// (which go through updateClientTraffics + SyncInbound and can touch
-			// client_traffics rows) and would also break on SQLite due to PG-only
-			// ::boolean syntax.
 			enableExpr := database.ClientTrafficEnableMergeExpr()
 			if err := tx.Exec(
 				fmt.Sprintf(
@@ -3017,15 +3142,41 @@ func (s *InboundService) resetAllTrafficsLocked() error {
 		return err
 	}
 
+	nodes, err := (&NodeService{}).GetAll()
+	if err == nil {
+		for _, node := range nodes {
+			if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
+				if e := rt.ResetAllTraffics(context.Background()); e != nil {
+					logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
+				}
+			}
+		}
+	}
+
 	return nil
 }
 
 func (s *InboundService) ResetInboundTraffic(id int) error {
 	return submitTrafficWrite(func() error {
 		db := database.GetDB()
-		return db.Model(model.Inbound{}).
+		if err := db.Model(model.Inbound{}).
 			Where("id = ?", id).
-			Updates(map[string]any{"up": 0, "down": 0}).Error
+			Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
+			return err
+		}
+
+		inbound, err := s.GetInbound(id)
+		if err == nil && inbound != nil && inbound.NodeID != nil {
+			if rt, rterr := s.runtimeFor(inbound); rterr == nil {
+				if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
+					logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
+				}
+			} else {
+				logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
+			}
+		}
+
+		return nil
 	})
 }
 
@@ -3697,7 +3848,7 @@ func (s *InboundService) MigrationRequirements() {
 	var externalProxy []struct {
 		Id             int
 		Port           int
-		StreamSettings []byte
+		StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
 	}
 	externalProxyQuery := `select id, port, stream_settings
 	from inbounds
@@ -3719,7 +3870,7 @@ func (s *InboundService) MigrationRequirements() {
 	for _, ep := range externalProxy {
 		var reverses any
 		var stream map[string]any
-		json.Unmarshal(ep.StreamSettings, &stream)
+		json.Unmarshal([]byte(ep.StreamSettings), &stream)
 		if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
 			if settings, ok := tlsSettings["settings"].(map[string]any); ok {
 				if domains, ok := settings["domains"].([]any); ok {
@@ -3741,9 +3892,17 @@ func (s *InboundService) MigrationRequirements() {
 		tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
 	}
 
-	err = tx.Raw(`UPDATE inbounds
-	SET tag = REPLACE(tag, '0.0.0.0:', '')
-	WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error
+	// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
+	// Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
+	tagCleanup := `UPDATE inbounds
+		SET tag = REPLACE(tag, '0.0.0.0:', '')
+		WHERE INSTR(tag, '0.0.0.0:') > 0;`
+	if database.IsPostgres() {
+		tagCleanup = `UPDATE inbounds
+			SET tag = REPLACE(tag, '0.0.0.0:', '')
+			WHERE position('0.0.0.0:' in tag) > 0;`
+	}
+	err = tx.Raw(tagCleanup).Error
 	if err != nil {
 		return
 	}

+ 211 - 0
web/service/inbound_client_ips_merge_test.go

@@ -0,0 +1,211 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
+// for a single test, mirroring the harness used by the other service tests.
+func setupClientIpTestDB(t *testing.T) {
+	t.Helper()
+	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() })
+}
+
+func marshalIps(t *testing.T, entries ...clientIpEntry) string {
+	t.Helper()
+	b, err := json.Marshal(entries)
+	if err != nil {
+		t.Fatalf("marshal ips: %v", err)
+	}
+	return string(b)
+}
+
+// readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
+// plus whether the row exists at all.
+func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
+	t.Helper()
+	var row model.InboundClientIps
+	err := database.GetDB().Where("client_email = ?", email).First(&row).Error
+	if database.IsNotFound(err) {
+		return nil, false
+	}
+	if err != nil {
+		t.Fatalf("read client ips for %s: %v", email, err)
+	}
+	var entries []clientIpEntry
+	if row.Ips != "" {
+		if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
+			t.Fatalf("unmarshal stored ips for %s: %v", email, err)
+		}
+	}
+	out := make(map[string]int64, len(entries))
+	for _, e := range entries {
+		out[e.IP] = e.Timestamp
+	}
+	return out, true
+}
+
+func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
+	setupClientIpTestDB(t)
+	db := database.GetDB()
+	now := time.Now().Unix()
+
+	// Local client occupies id 1.
+	local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
+	if err := db.Create(local).Error; err != nil {
+		t.Fatalf("seed local row: %v", err)
+	}
+
+	// Incoming node-only client carries the remote node's id 1, which must not
+	// collide with the local row.
+	incoming := []model.InboundClientIps{{
+		Id:          1,
+		ClientEmail: "node@x",
+		Ips:         marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
+	}}
+	if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+
+	// Local row is untouched.
+	if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
+		t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
+	}
+
+	// Node row exists with its own ip and a freshly assigned id (not the remote 1).
+	var nodeRow model.InboundClientIps
+	if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
+		t.Fatalf("node@x not created: %v", err)
+	}
+	if nodeRow.Id == local.Id {
+		t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
+	}
+	if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
+		t.Fatalf("node@x missing expected ip: %v", ips)
+	}
+}
+
+func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
+	setupClientIpTestDB(t)
+	db := database.GetDB()
+	now := time.Now().Unix()
+
+	if err := db.Create(&model.InboundClientIps{
+		ClientEmail: "a@x",
+		Ips:         marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
+	}).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	incoming := []model.InboundClientIps{{
+		ClientEmail: "a@x",
+		Ips: marshalIps(t,
+			clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
+			clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
+		),
+	}}
+	if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+
+	ips, _ := readClientIps(t, "a@x")
+	if len(ips) != 2 {
+		t.Fatalf("want 2 ips, got %v", ips)
+	}
+	if ips["1.1.1.1"] != now-50 {
+		t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
+	}
+	if ips["2.2.2.2"] != now-10 {
+		t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
+	}
+}
+
+func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
+	setupClientIpTestDB(t)
+	db := database.GetDB()
+	now := time.Now().Unix()
+
+	if err := db.Create(&model.InboundClientIps{
+		ClientEmail: "a@x",
+		Ips: marshalIps(t,
+			clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
+			clientIpEntry{IP: "fresh", Timestamp: now - 60},
+		),
+	}).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	incoming := []model.InboundClientIps{{
+		ClientEmail: "a@x",
+		Ips: marshalIps(t,
+			clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
+			clientIpEntry{IP: "incFresh", Timestamp: now - 10},
+		),
+	}}
+	if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+
+	ips, _ := readClientIps(t, "a@x")
+	if len(ips) != 2 {
+		t.Fatalf("want only fresh ips, got %v", ips)
+	}
+	if _, ok := ips["old"]; ok {
+		t.Fatalf("stale local ip not dropped: %v", ips)
+	}
+	if _, ok := ips["incStale"]; ok {
+		t.Fatalf("stale incoming ip not dropped: %v", ips)
+	}
+	if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
+		t.Fatalf("fresh ips wrong: %v", ips)
+	}
+}
+
+func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+
+	incoming := []model.InboundClientIps{{
+		ClientEmail: "b@x",
+		Ips:         marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
+	}}
+	if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+
+	if _, ok := readClientIps(t, "b@x"); ok {
+		t.Fatalf("all-stale node-only client should not create a row")
+	}
+}
+
+func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
+	setupClientIpTestDB(t)
+	now := time.Now().Unix()
+
+	incoming := []model.InboundClientIps{
+		{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
+		{ClientEmail: "c@x", Ips: ""},
+	}
+	if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
+	}
+}

+ 55 - 15
web/service/node.go

@@ -35,6 +35,11 @@ type HeartbeatPatch struct {
 	MemPct        float64
 	UptimeSecs    uint64
 	LastError     string
+	// XrayState and XrayError come from the remote /panel/api/server/status when the
+	// panel API is reachable. They allow distinguishing panel connectivity from
+	// Xray core health on the node.
+	XrayState string
+	XrayError string
 }
 
 type NodeService struct{}
@@ -221,11 +226,20 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 	for id := range nodeByInbound {
 		inboundIDs = append(inboundIDs, id)
 	}
-	if err := db.Table("client_traffics").
-		Select("inbound_id, email, enable, total, up, down, expiry_time").
-		Where("inbound_id IN ?", inboundIDs).
-		Scan(&trafficRows).Error; err == nil {
-		depletedByNode := make(map[int]int)
+	// Chunk the IN clause to avoid "too many SQL variables" on SQLite
+	// when there are many node-owned inbounds (common with many nodes).
+	// sqliteMaxVars is defined in this package (inbound.go).
+	for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
+		var page []trafficRow
+		if err := db.Table("client_traffics").
+			Select("inbound_id, email, enable, total, up, down, expiry_time").
+			Where("inbound_id IN ?", batch).
+			Scan(&page).Error; err == nil {
+			trafficRows = append(trafficRows, page...)
+		}
+	}
+	depletedByNode := make(map[int]int)
+	if len(trafficRows) > 0 {
 		for _, row := range trafficRows {
 			nodeID, ok := nodeByInbound[row.InboundID]
 			if !ok {
@@ -237,15 +251,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 				depletedByNode[nodeID]++
 			}
 		}
-		onlineByGuid := s.onlineEmailsByGuid()
-		for _, n := range nodes {
-			n.InboundCount = len(inboundsByNode[n.Id])
-			n.DepletedCount = depletedByNode[n.Id]
-			// Online is attributed to the node that physically hosts the client
-			// (by GUID): a client on a sub-node counts under the sub-node, not
-			// the intermediate node it syncs through (#4983).
-			n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
-		}
+	}
+	onlineByGuid := s.onlineEmailsByGuid()
+	for _, n := range nodes {
+		n.InboundCount = len(inboundsByNode[n.Id])
+		n.DepletedCount = depletedByNode[n.Id]
+		// Online is attributed to the node that physically hosts the client
+		// (by GUID): a client on a sub-node counts under the sub-node, not
+		// the intermediate node it syncs through (#4983).
+		n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
 	}
 
 	return nodes, nil
@@ -284,6 +298,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
 	return n, nil
 }
 
+// NodeExists reports whether a node with the given id exists on this panel.
+// Used to drop stale, cross-panel node references on inbound import. A Count
+// query distinguishes "no such node" (count 0, no error) from a real DB error.
+func (s *NodeService) NodeExists(id int) (bool, error) {
+	if id <= 0 {
+		return false, nil
+	}
+	var count int64
+	if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).Count(&count).Error; err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}
+
 func normalizeBasePath(p string) string {
 	p = strings.TrimSpace(p)
 	if p == "" {
@@ -474,6 +502,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"mem_pct":        p.MemPct,
 		"uptime_secs":    p.UptimeSecs,
 		"last_error":     p.LastError,
+		"xray_state":     p.XrayState,
+		"xray_error":     p.XrayError,
 	}
 	// Only learn the GUID; never clear a known one if an old-build node (or a
 	// failed probe) reports none, so the stable identity survives blips.
@@ -607,7 +637,9 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 				Total   uint64 `json:"total"`
 			} `json:"mem"`
 			Xray struct {
-				Version string `json:"version"`
+				Version  string `json:"version"`
+				State    string `json:"state"`
+				ErrorMsg string `json:"errorMsg"`
 			} `json:"xray"`
 			PanelVersion string `json:"panelVersion"`
 			PanelGuid    string `json:"panelGuid"`
@@ -628,6 +660,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 		patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
 	}
 	patch.XrayVersion = o.Xray.Version
+	patch.XrayState = o.Xray.State
+	patch.XrayError = o.Xray.ErrorMsg
 	patch.PanelVersion = o.PanelVersion
 	patch.Guid = o.PanelGuid
 	patch.UptimeSecs = o.Uptime
@@ -643,6 +677,10 @@ type ProbeResultUI struct {
 	MemPct       float64 `json:"memPct" example:"45.2"`
 	UptimeSecs   uint64  `json:"uptimeSecs" example:"86400"`
 	Error        string  `json:"error"`
+	// XrayState/XrayError are populated on successful probes even when the node's
+	// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
+	XrayState string `json:"xrayState"`
+	XrayError string `json:"xrayError"`
 }
 
 func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
@@ -654,6 +692,8 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 		MemPct:       p.MemPct,
 		UptimeSecs:   p.UptimeSecs,
 		Error:        FriendlyProbeError(p.LastError),
+		XrayState:    p.XrayState,
+		XrayError:    p.XrayError,
 	}
 	if ok {
 		r.Status = "online"

+ 4 - 0
web/service/node_tree.go

@@ -40,6 +40,8 @@ func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
 			LatencyMs:     n.LatencyMs,
 			PanelVersion:  n.PanelVersion,
 			XrayVersion:   n.XrayVersion,
+			XrayState:     n.XrayState,
+			XrayError:     n.XrayError,
 		})
 	}
 	return out, nil
@@ -140,6 +142,8 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
 			LatencyMs:     sum.LatencyMs,
 			PanelVersion:  sum.PanelVersion,
 			XrayVersion:   sum.XrayVersion,
+			XrayState:     sum.XrayState,
+			XrayError:     sum.XrayError,
 			Transitive:    true,
 		})
 	}

+ 540 - 0
web/service/outbound_subscription.go

@@ -0,0 +1,540 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/link"
+)
+
+// OutboundSubscriptionService manages remote outbound subscriptions.
+type OutboundSubscriptionService struct {
+	settingService SettingService
+}
+
+// NewOutboundSubscriptionService returns a service for managing outbound subscriptions.
+func NewOutboundSubscriptionService() *OutboundSubscriptionService {
+	return &OutboundSubscriptionService{}
+}
+
+// List returns all subscriptions (newest first).
+func (s *OutboundSubscriptionService) List() ([]*model.OutboundSubscription, error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Model(&model.OutboundSubscription{}).Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return nil, err
+	}
+	for _, sub := range subs {
+		sub.OutboundCount = countOutbounds(sub.LastFetchedOutbounds)
+		// Don't ship the heavy raw blobs to the list view.
+		sub.LastFetchedOutbounds = ""
+		sub.LinkIdentities = ""
+	}
+	return subs, nil
+}
+
+// countOutbounds returns the number of outbounds in a stored LastFetchedOutbounds
+// JSON array (0 for empty/invalid).
+func countOutbounds(raw string) int {
+	if strings.TrimSpace(raw) == "" {
+		return 0
+	}
+	var arr []any
+	if json.Unmarshal([]byte(raw), &arr) != nil {
+		return 0
+	}
+	return len(arr)
+}
+
+// Get returns a single subscription by id.
+func (s *OutboundSubscriptionService) Get(id int) (*model.OutboundSubscription, error) {
+	db := database.GetDB()
+	var sub model.OutboundSubscription
+	if err := db.First(&sub, id).Error; err != nil {
+		return nil, err
+	}
+	return &sub, nil
+}
+
+// Create persists a new subscription. It does not fetch immediately; the caller
+// can call Refresh on the returned id if desired.
+var defaultPrefixRe = regexp.MustCompile(`^sub(\d+)-$`)
+
+// defaultPrefixNumber returns the smallest positive integer N that is not already
+// in use as a "subN-" tag prefix among the given subscriptions. This is used to
+// auto-name a subscription's outbounds when the user leaves the prefix blank, so
+// deleting a subscription frees its number for reuse instead of letting the
+// number grow forever with the auto-increment DB id. A subscription with a blank
+// prefix reserves its own id (it falls back to id-based "sub<id>-" tags).
+func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int {
+	used := map[int]bool{}
+	for _, sub := range subs {
+		if sub.Id == excludeId {
+			continue
+		}
+		if sub.TagPrefix == "" {
+			used[sub.Id] = true
+			continue
+		}
+		if m := defaultPrefixRe.FindStringSubmatch(sub.TagPrefix); m != nil {
+			if n, err := strconv.Atoi(m[1]); err == nil {
+				used[n] = true
+			}
+		}
+	}
+	n := 1
+	for used[n] {
+		n++
+	}
+	return n
+}
+
+// nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
+// subscription, picking the smallest free N (excludeId skips a subscription's
+// own current prefix when editing).
+func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
+	var subs []*model.OutboundSubscription
+	_ = database.GetDB().Find(&subs).Error
+	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
+}
+
+func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
+	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
+	if err != nil {
+		return nil, common.NewError("invalid subscription URL:", err)
+	}
+	if cleanURL == "" {
+		return nil, common.NewError("subscription URL is required")
+	}
+	if updateInterval <= 0 {
+		updateInterval = 600
+	}
+	prefix := strings.TrimSpace(tagPrefix)
+	if prefix == "" {
+		prefix = s.nextDefaultSubPrefix(0)
+	}
+	// New subscriptions go to the end of the priority order.
+	var count int64
+	database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
+	sub := &model.OutboundSubscription{
+		Remark:         strings.TrimSpace(remark),
+		Url:            cleanURL,
+		Enabled:        enabled,
+		AllowPrivate:   allowPrivate,
+		Prepend:        prepend,
+		Priority:       int(count),
+		TagPrefix:      prefix,
+		UpdateInterval: updateInterval,
+	}
+	if err := database.GetDB().Create(sub).Error; err != nil {
+		return nil, err
+	}
+	return sub, nil
+}
+
+// Update updates editable fields.
+func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
+	sub, err := s.Get(id)
+	if err != nil {
+		return err
+	}
+	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
+	if err != nil {
+		return common.NewError("invalid subscription URL:", err)
+	}
+	if cleanURL == "" {
+		return common.NewError("subscription URL is required")
+	}
+	if updateInterval <= 0 {
+		updateInterval = 600
+	}
+	prefix := strings.TrimSpace(tagPrefix)
+	if prefix == "" {
+		prefix = s.nextDefaultSubPrefix(sub.Id)
+	}
+	sub.Remark = strings.TrimSpace(remark)
+	sub.Url = cleanURL
+	sub.Enabled = enabled
+	sub.AllowPrivate = allowPrivate
+	sub.Prepend = prepend
+	sub.TagPrefix = prefix
+	sub.UpdateInterval = updateInterval
+	return database.GetDB().Save(sub).Error
+}
+
+// Delete removes a subscription.
+func (s *OutboundSubscriptionService) Delete(id int) error {
+	return database.GetDB().Delete(&model.OutboundSubscription{}, id).Error
+}
+
+// GetLastOutbounds returns the last successfully fetched outbounds for a subscription
+// (as raw interface slice ready for JSON merge). Returns nil slice when none.
+func (s *OutboundSubscriptionService) GetLastOutbounds(id int) ([]any, error) {
+	sub, err := s.Get(id)
+	if err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
+		return nil, nil
+	}
+	var arr []any
+	if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
+		return nil, err
+	}
+	return arr, nil
+}
+
+// Refresh fetches the subscription URL, parses the links, assigns stable tags,
+// persists the results, and returns the generated outbounds.
+func (s *OutboundSubscriptionService) Refresh(id int) ([]any, error) {
+	sub, err := s.Get(id)
+	if err != nil {
+		return nil, err
+	}
+	outbounds, err := s.fetchAndStore(sub)
+	return outbounds, err
+}
+
+// RefreshAllEnabled fetches every enabled subscription whose due time has passed
+// (lastUpdated + updateInterval <= now). It returns the number of subscriptions
+// that were actually refreshed.
+func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Where("enabled = ?", true).Find(&subs).Error; err != nil {
+		return 0, err
+	}
+	now := time.Now().Unix()
+	refreshed := 0
+	for _, sub := range subs {
+		due := sub.LastUpdated + int64(sub.UpdateInterval)
+		if sub.LastUpdated == 0 || due <= now {
+			if _, err := s.fetchAndStore(sub); err != nil {
+				logger.Warningf("outbound sub %d (%s) refresh failed: %v", sub.Id, sub.Remark, err)
+				// continue with others
+			} else {
+				refreshed++
+			}
+		}
+	}
+	return refreshed, nil
+}
+
+// fetchAndStore does the actual network + parse + stability + persist work.
+func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
+	// Re-sanitize on every fetch (handles legacy rows + defense in depth against
+	// any direct DB tampering). Private targets are blocked unless this
+	// subscription was explicitly created with AllowPrivate.
+	cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	if cleanURL == "" {
+		return nil, common.NewError("subscription has no valid URL")
+	}
+	sub.Url = cleanURL // persist the cleaned version
+
+	client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
+	// Re-validate every redirect hop: the initial host is checked above, but a
+	// redirect could still point at a private/internal address (SSRF). Cap the
+	// redirect chain as well.
+	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+		if len(via) >= 10 {
+			return fmt.Errorf("stopped after 10 redirects")
+		}
+		if sub.AllowPrivate {
+			return nil
+		}
+		ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
+		defer cancel()
+		return rejectPrivateHost(ctx, req.URL.Hostname())
+	}
+
+	req, err := http.NewRequest("GET", sub.Url, nil)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		err := fmt.Errorf("http %d", resp.StatusCode)
+		s.recordError(sub, err)
+		return nil, err
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+
+	parsed, identities, err := link.ParseSubscriptionBody(body)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+
+	// Load previous identities -> tags for stability
+	prev := map[string]string{}
+	if strings.TrimSpace(sub.LinkIdentities) != "" {
+		_ = json.Unmarshal([]byte(sub.LinkIdentities), &prev)
+	}
+
+	// Also load previous outbounds so we can reuse tags even for identities we
+	// temporarily lost (defensive).
+	prevTagByIndex := map[int]string{}
+	if strings.TrimSpace(sub.LastFetchedOutbounds) != "" {
+		var prevObs []any
+		if json.Unmarshal([]byte(sub.LastFetchedOutbounds), &prevObs) == nil {
+			for i, o := range prevObs {
+				if m, ok := o.(map[string]any); ok {
+					if tag, _ := m["tag"].(string); tag != "" {
+						prevTagByIndex[i] = tag
+					}
+				}
+			}
+		}
+	}
+
+	// Assign tags with stability (identity reuse, positional fallback, then a
+	// fresh allocation), keeping tags unique within this batch. Extracted into a
+	// pure function so it can be unit-tested without network/DB. Tags are written
+	// back into the parsed outbounds in place.
+	assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
+
+	// Persist identities for next time
+	newIdent := map[string]string{}
+	for i, id := range identities {
+		newIdent[id] = assigned[i]
+	}
+	identJSON, _ := json.Marshal(newIdent)
+
+	// Persist the outbounds (as compact JSON array)
+	obsJSON, _ := json.Marshal(parsed)
+
+	sub.LastFetchedOutbounds = string(obsJSON)
+	sub.LinkIdentities = string(identJSON)
+	sub.LastUpdated = time.Now().Unix()
+	sub.LastError = ""
+
+	if err := database.GetDB().Save(sub).Error; err != nil {
+		return nil, err
+	}
+
+	// Return as []any for the config merger
+	result := make([]any, len(parsed))
+	for i := range parsed {
+		result[i] = parsed[i]
+	}
+	return result, nil
+}
+
+func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
+	sub.LastError = err.Error()
+	_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
+}
+
+// assignStableTags assigns a tag to each parsed outbound, preferring stability:
+//  1. reuse the tag previously mapped to the link's identity (prev),
+//  2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
+//  3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
+//
+// Tags are kept unique within the batch by appending "-N" on collision, and are
+// written back into parsed[i]["tag"]. The returned slice holds the assigned tags
+// in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
+func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
+	used := map[string]bool{} // uniqueness within this refresh batch
+	assigned := make([]string, len(parsed))
+	for i := range parsed {
+		id := ""
+		if i < len(identities) {
+			id = identities[i]
+		}
+		candidate := ""
+		if old, ok := prev[id]; ok && old != "" {
+			candidate = old
+		}
+		if candidate == "" {
+			// try to reuse by rough positional match from previous fetch (best effort)
+			if old, ok := prevTagByIndex[i]; ok && old != "" {
+				candidate = old
+			}
+		}
+		if candidate == "" {
+			// fresh allocation
+			prefix := tagPrefix
+			if prefix == "" {
+				prefix = fmt.Sprintf("sub%d-", subID)
+			}
+			remark := ""
+			if m, ok := parsed[i]["tag"].(string); ok {
+				remark = m
+			}
+			candidate = link.SuggestTag(prefix, remark, i)
+		}
+		// ensure local uniqueness inside this batch
+		final := candidate
+		for k := 1; used[final]; k++ {
+			final = fmt.Sprintf("%s-%d", candidate, k)
+		}
+		used[final] = true
+		assigned[i] = final
+
+		// write back the tag into the outbound
+		parsed[i]["tag"] = final
+	}
+	return assigned
+}
+
+// AllActiveOutbounds returns the concatenation of the last-fetched outbounds
+// for every enabled subscription. This is the set that should be merged into
+// the final Xray config. Order: subscription creation order (by id asc) so
+// that later subscriptions can shadow earlier ones if the admin uses colliding
+// prefixes (last writer wins inside xray, but we try to keep tags unique).
+func (s *OutboundSubscriptionService) AllActiveOutbounds() ([]any, error) {
+	prepend, appendList, err := s.activeOutboundsSplit()
+	if err != nil {
+		return nil, err
+	}
+	return append(prepend, appendList...), nil
+}
+
+// activeOutboundsSplit returns the active subscription outbounds split into those
+// that should be placed BEFORE the manual template outbounds (Prepend) and those
+// placed AFTER. Within each group, subscriptions are ordered by Priority (then id)
+// so the admin can control the merged order.
+func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, appendList []any, err error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return nil, nil, err
+	}
+	for _, sub := range subs {
+		if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
+			continue
+		}
+		var arr []any
+		if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
+			logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
+			continue
+		}
+		if sub.Prepend {
+			prepend = append(prepend, arr...)
+		} else {
+			appendList = append(appendList, arr...)
+		}
+	}
+	return prepend, appendList, nil
+}
+
+// Move shifts a subscription one step up or down in the priority order and
+// re-normalizes all priorities to a 0..n-1 sequence.
+func (s *OutboundSubscriptionService) Move(id int, up bool) error {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return err
+	}
+	idx := -1
+	for i, sub := range subs {
+		if sub.Id == id {
+			idx = i
+			break
+		}
+	}
+	if idx == -1 {
+		return common.NewError("subscription not found")
+	}
+	swap := idx + 1
+	if up {
+		swap = idx - 1
+	}
+	if swap < 0 || swap >= len(subs) {
+		return nil // already at the edge
+	}
+	subs[idx], subs[swap] = subs[swap], subs[idx]
+	for i, sub := range subs {
+		if sub.Priority != i {
+			if err := db.Model(sub).Update("priority", i).Error; err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// AllActiveOutboundTags returns only the tags of active subscription outbounds.
+// Useful for populating balancer / routing selectors without shipping full objects.
+func (s *OutboundSubscriptionService) AllActiveOutboundTags() ([]string, error) {
+	obs, err := s.AllActiveOutbounds()
+	if err != nil {
+		return nil, err
+	}
+	tags := make([]string, 0, len(obs))
+	for _, o := range obs {
+		if m, ok := o.(map[string]any); ok {
+			if t, _ := m["tag"].(string); t != "" {
+				tags = append(tags, t)
+			}
+		}
+	}
+	return tags, nil
+}
+
+/*
+Tag stability strategy (important for balancers and routing rules)
+
+When a subscription is refreshed we try very hard to keep the *same* tag for the
+same logical outbound so that existing balancers and routing rules keep working.
+
+How we do it:
+- On every successful parse we compute a stable "identity" for each link
+  (the core of the URI with the remark fragment removed, or for vmess the inner
+  JSON without the "ps" field).
+- We persist a map identity -> tag in the LinkIdentities column.
+- On the next refresh, if we see the same identity again we reuse the previous tag,
+  even if the remark changed or minor parameters moved.
+- Only when we have never seen the identity before do we allocate a fresh tag
+  using the user-supplied TagPrefix + slug(remark) (or an index fallback).
+- Within one refresh we still deduplicate with -N suffixes.
+
+Consequences for balancers / routing:
+- If you use an *exact* tag in a balancer selector or a routing rule, that
+  specific server will continue to be used after refreshes (as long as the
+  provider still returns a link that produces the same identity).
+- If you use a *prefix/wildcard* selector (e.g. "hk-*", "sg-.*"), then any
+  *new* servers that the subscription later returns will automatically be
+  eligible for that balancer on the next Xray reload — this is the recommended
+  way to "subscribe to a pool".
+- When a server disappears from the subscription, its tag simply stops
+  existing in the final outbounds array. The balancer will have fewer
+  candidates. If you configured a `fallbackTag` on the balancer, Xray will use
+  it. Otherwise connections that would have used the missing member may fail
+  or be routed by the next rule.
+- If the provider rotates credentials/UUIDs/hosts for a server, the identity
+  changes → we treat it as a brand new outbound and give it a new tag. Any
+  balancer/rule that referenced the *old* tag will no longer see it. This is
+  an inherent limitation of subscription-based outbounds.
+
+We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
+outbounds are always injected at runtime in GetXrayConfig.
+*/

+ 117 - 0
web/service/outbound_subscription_test.go

@@ -0,0 +1,117 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/link"
+)
+
+func TestDefaultPrefixNumber(t *testing.T) {
+	mk := func(id int, prefix string) *model.OutboundSubscription {
+		return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
+	}
+	cases := []struct {
+		name      string
+		subs      []*model.OutboundSubscription
+		excludeId int
+		want      int
+	}{
+		{"no subscriptions starts at 1", nil, 0, 1},
+		{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
+		{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
+		{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
+		{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
+		{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
+				t.Fatalf("got %d, want %d", got, c.want)
+			}
+		})
+	}
+}
+
+func TestAssignStableTags(t *testing.T) {
+	t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
+		prev := map[string]string{"id-abc": "sub1-keepme"}
+		got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
+		if got[0] != "sub1-keepme" {
+			t.Fatalf("got %q, want sub1-keepme", got[0])
+		}
+		if parsed[0]["tag"] != "sub1-keepme" {
+			t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
+		}
+	})
+
+	t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
+		if got[0] != "sub1-oldpos" {
+			t.Fatalf("got %q, want sub1-oldpos", got[0])
+		}
+	})
+
+	t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
+		want := link.SuggestTag("sub7-", "Tokyo", 0)
+		if got[0] != want {
+			t.Fatalf("got %q, want %q", got[0], want)
+		}
+	})
+
+	t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
+		want := link.SuggestTag("hk-", "Tokyo", 0)
+		if got[0] != want {
+			t.Fatalf("got %q, want %q", got[0], want)
+		}
+	})
+
+	t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
+		got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
+		base := link.SuggestTag("p-", "Same", 0)
+		if got[0] != base {
+			t.Fatalf("got[0] = %q, want %q", got[0], base)
+		}
+		if got[1] != base+"-1" {
+			t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
+		}
+	})
+}
+
+// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
+// when fetching subscription URLs. All rejected cases use literal IPs or bad
+// schemes so the test never performs real DNS resolution.
+func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
+	rejected := []string{
+		"http://127.0.0.1/sub",                    // loopback
+		"http://10.0.0.1/x",                       // private
+		"http://192.168.1.1",                      // private
+		"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
+		"http://[::1]:8080/sub",                   // IPv6 loopback
+		"http://0.0.0.0",                          // unspecified
+		"ftp://example.com/x",                     // unsupported scheme
+		"file:///etc/passwd",                      // unsupported scheme
+	}
+	for _, raw := range rejected {
+		if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
+			t.Errorf("expected %q to be rejected, got nil error", raw)
+		}
+	}
+
+	t.Run("allows a public literal IP without DNS", func(t *testing.T) {
+		got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if got != "http://8.8.8.8/sub" {
+			t.Fatalf("got %q, want http://8.8.8.8/sub", got)
+		}
+	})
+}

+ 23 - 0
web/service/setting.go

@@ -86,8 +86,10 @@ var defaultValueMap = map[string]string{
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
 	"subJsonFinalMask":            "",
+	"subThemeDir":                 "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
+	"warpUpdateInterval":          "0",
 	"nord":                        "",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformURI":    "",
@@ -322,6 +324,22 @@ func (s *SettingService) setInt(key string, value int) error {
 	return s.setString(key, strconv.Itoa(value))
 }
 
+func (s *SettingService) GetWarpLastUpdate() (int64, error) {
+	val, err := s.getString("warpLastUpdate")
+	if err != nil || val == "" {
+		return 0, err
+	}
+	return strconv.ParseInt(val, 10, 64)
+}
+
+func (s *SettingService) SetWarpLastUpdate(val int64) error {
+	return s.saveSetting("warpLastUpdate", strconv.FormatInt(val, 10))
+}
+
+func (s *SettingService) SetWarpUpdateInterval(val int) error {
+	return s.setInt("warpUpdateInterval", val)
+}
+
 func (s *SettingService) GetXrayConfigTemplate() (string, error) {
 	return s.getString("xrayTemplateConfig")
 }
@@ -699,6 +717,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	return s.getString("subJsonFinalMask")
 }
 
+func (s *SettingService) GetSubThemeDir() (string, error) {
+	return s.getString("subThemeDir")
+}
+
 func (s *SettingService) GetDatepicker() (string, error) {
 	return s.getString("datepicker")
 }
@@ -973,6 +995,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		"defaultCert":    func() (any, error) { return s.GetCertFile() },
 		"defaultKey":     func() (any, error) { return s.GetKeyFile() },
 		"tgBotEnable":    func() (any, error) { return s.GetTgbotEnabled() },
+		"subThemeDir":    func() (any, error) { return s.GetSubThemeDir() },
 		"subEnable":      func() (any, error) { return s.GetSubEnable() },
 		"subJsonEnable":  func() (any, error) { return s.GetSubJsonEnable() },
 		"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },

+ 46 - 7
web/service/warp.go

@@ -9,6 +9,8 @@ import (
 	"os"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 )
 
@@ -23,8 +25,6 @@ const (
 	warpClientVer = "a-6.30-3596"
 )
 
-var warpHTTPClient = &http.Client{Timeout: 15 * time.Second}
-
 func (s *WarpService) GetWarpData() (string, error) {
 	return s.SettingService.GetWarp()
 }
@@ -46,7 +46,7 @@ func (s *WarpService) GetWarpConfig() (string, error) {
 	}
 	req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
 
-	body, err := doWarpRequest(req)
+	body, err := s.doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
@@ -73,7 +73,7 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
 	req.Header.Set("CF-Client-Version", warpClientVer)
 	req.Header.Set("Content-Type", "application/json")
 
-	body, err := doWarpRequest(req)
+	body, err := s.doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
@@ -148,7 +148,7 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
 	req.Header.Set("Content-Type", "application/json")
 
-	body, err := doWarpRequest(req)
+	body, err := s.doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
@@ -172,6 +172,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	return string(newWarpData), nil
 }
 
+func (s *WarpService) ChangeWarpIP() (string, error) {
+	warpDataMap, err := s.loadWarpCreds()
+	if err != nil {
+		return "", err
+	}
+
+	privKey, pubKey, err := util.GenerateWireguardKeypair()
+	if err != nil {
+		return "", err
+	}
+
+	result, err := s.RegWarp(privKey, pubKey)
+	if err != nil {
+		return "", err
+	}
+
+	var parsed struct {
+		Data   map[string]string      `json:"data"`
+		Config map[string]interface{} `json:"config"`
+	}
+	if err := json.Unmarshal([]byte(result), &parsed); err != nil {
+		return "", err
+	}
+
+	xraySvc := XraySettingService{}
+	if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil {
+		return "", err
+	}
+
+	if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 {
+		if _, licErr := s.SetWarpLicense(license); licErr != nil {
+			logger.Warning("ChangeWarpIP: failed to re-apply WARP license: ", licErr)
+		}
+	}
+
+	return result, nil
+}
+
 // loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
 func (s *WarpService) loadWarpCreds() (map[string]string, error) {
 	warp, err := s.SettingService.GetWarp()
@@ -190,8 +228,9 @@ func (s *WarpService) loadWarpCreds() (map[string]string, error) {
 
 // doWarpRequest sends the request and returns the response body on 2xx.
 // Non-2xx responses are returned as errors including the status code and body.
-func doWarpRequest(req *http.Request) ([]byte, error) {
-	resp, err := warpHTTPClient.Do(req)
+func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
+	client := s.NewProxiedHTTPClient(15 * time.Second)
+	resp, err := client.Do(req)
 	if err != nil {
 		return nil, err
 	}

+ 42 - 0
web/service/xray.go

@@ -254,9 +254,51 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		inboundConfig := inbound.GenXrayInboundConfig()
 		xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
 	}
+
+	// Merge subscription-derived outbounds (if any) into the final outbounds array.
+	// These are additive: each subscription is placed before or after the template
+	// outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
+	// subscription service are kept stable across refreshes so that balancers and
+	// routing rules continue to work.
+	subSvc := &OutboundSubscriptionService{}
+	if prepend, appendList, err := subSvc.activeOutboundsSplit(); err == nil && (len(prepend) > 0 || len(appendList) > 0) {
+		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
+	}
+
 	return xrayConfig, nil
 }
 
+// mergeSubscriptionOutbounds appends the subscription outbounds to the
+// OutboundConfigs array of the xray config. It works on the already-unmarshaled
+// template so that manually configured outbounds are never overwritten.
+//
+// Safety: if we cannot parse the template's outbounds array, we leave
+// OutboundConfigs exactly as it came from the template (we do not inject
+// subscription outbounds). This prevents us from accidentally dropping the
+// user's manually configured outbounds when the template is in a weird state.
+func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
+	if len(prepend) == 0 && len(appendList) == 0 {
+		return
+	}
+	var templateOutbounds []any
+	if len(cfg.OutboundConfigs) > 0 {
+		if err := json.Unmarshal(cfg.OutboundConfigs, &templateOutbounds); err != nil {
+			// Corrupt template outbounds — do not touch the field at all.
+			// The user will see problems on Xray start / next save.
+			return
+		}
+	}
+	merged := make([]any, 0, len(prepend)+len(templateOutbounds)+len(appendList))
+	merged = append(merged, prepend...)
+	merged = append(merged, templateOutbounds...)
+	merged = append(merged, appendList...)
+	combined, err := json.MarshalIndent(merged, "", "  ")
+	if err != nil {
+		return
+	}
+	cfg.OutboundConfigs = json_util.RawMessage(combined)
+}
+
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // alongside the panel's other logs regardless of the working directory the

+ 89 - 0
web/service/xray_setting.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	_ "embed"
+	"encoding/base64"
 	"encoding/json"
 	"slices"
 
@@ -40,6 +41,94 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	return nil
 }
 
+func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return err
+	}
+
+	var cfg map[string]interface{}
+	if err := json.Unmarshal([]byte(template), &cfg); err != nil {
+		return err
+	}
+
+	outbounds, ok := cfg["outbounds"].([]interface{})
+	if !ok {
+		return nil
+	}
+
+	updated := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]interface{})
+		if !ok {
+			continue
+		}
+		if tag, ok := out["tag"].(string); ok && tag == "warp" {
+			settings, ok := out["settings"].(map[string]interface{})
+			if !ok {
+				continue
+			}
+
+			settings["secretKey"] = warpData["private_key"]
+
+			if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
+				if iface, ok := conf["interface"].(map[string]interface{}); ok {
+					if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
+						var addrList []string
+						if v4, ok := addrs["v4"].(string); ok && v4 != "" {
+							addrList = append(addrList, v4+"/32")
+						}
+						if v6, ok := addrs["v6"].(string); ok && v6 != "" {
+							addrList = append(addrList, v6+"/128")
+						}
+						settings["address"] = addrList
+					}
+				}
+
+				var clientId string
+				if id, ok := conf["client_id"].(string); ok {
+					clientId = id
+				} else if id, ok := warpData["client_id"]; ok {
+					clientId = id
+				}
+				if clientId != "" {
+					decoded, _ := base64.StdEncoding.DecodeString(clientId)
+					var res []int
+					for _, b := range decoded {
+						res = append(res, int(b))
+					}
+					settings["reserved"] = res
+				}
+
+				if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
+					if peer, ok := peers[0].(map[string]interface{}); ok {
+						if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
+							if pSet, ok := pSettings[0].(map[string]interface{}); ok {
+								pSet["publicKey"] = peer["public_key"]
+								if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
+									pSet["endpoint"] = endpoint["host"]
+								}
+							}
+						}
+					}
+				}
+			}
+			updated = true
+			break
+		}
+	}
+
+	if updated {
+		outJSON, err := json.MarshalIndent(cfg, "", "  ")
+		if err != nil {
+			return err
+		}
+		return s.SaveXraySetting(string(outJSON))
+	}
+
+	return nil
+}
+
 // UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
 // peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
 // "xraySetting": <real config> }` response-shaped wrappers that may have

+ 64 - 2
web/translation/ar-EG.json

@@ -812,7 +812,8 @@
       "clientCount": "عملاء في المجموعة",
       "totalGroups": "إجمالي المجموعات",
       "totalGroupedClients": "العملاء بمجموعة",
-      "emptyGroups": "مجموعات فارغة",
+      "trafficUsed": "حركة المرور المستخدمة",
+      "totalTraffic": "إجمالي حركة المرور",
       "addGroup": "إضافة مجموعة",
       "createSuccess": "تم إنشاء المجموعة «{name}».",
       "rename": "إعادة تسمية",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "متصل",
         "offline": "غير متصل",
-        "unknown": "غير معروف"
+        "unknown": "غير معروف",
+        "xrayError": "خطأ Xray",
+        "xrayStopped": "متوقف"
       },
       "toasts": {
         "list": "فشل تحميل النودز",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN",
       "subAnnounce": "إعلان",
       "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
+      "subThemeDir": "مجلد قالب الاشتراك",
+      "subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
       "subEnableRouting": "تفعيل التوجيه",
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
@@ -1356,6 +1361,58 @@
         "privateKey": "المفتاح الخاص",
         "load": "الحمل"
       },
+      "OutboundSubscriptions": "اشتراكات الصادرات",
+      "OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
+      "outboundSub": {
+        "manage": "الاشتراكات",
+        "title": "اشتراكات الصادرات",
+        "remark": "ملاحظة (اختياري)",
+        "remarkPlaceholder": "مثلاً نودز هونج كونج",
+        "url": "رابط الاشتراك",
+        "urlPlaceholder": "https://... (قائمة روابط بصيغة base64)",
+        "tagPrefix": "بادئة الوسم",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "فاصل التحديث",
+        "hours": "س",
+        "minutes": "د",
+        "intervalHint": "الافتراضي 10 دقايق. المهمة اللي بتشتغل في الخلفية بتشيك بشكل متكرر؛ كل اشتراك بيعيد الجلب لما يعدّي الفاصل الخاص بيه بس.",
+        "enabled": "مفعّل",
+        "allowPrivate": "السماح بالعناوين الخاصة",
+        "allowPrivateHint": "اسمح بعناوين localhost / الشبكة المحلية (LAN) / عناوين IP الخاصة لرابط الاشتراك ده. متعطّل افتراضياً لدواعي الأمان — فعّله بس لو المصدر المحلي موثوق.",
+        "prepend": "قبل الصادرات اليدوية",
+        "prependHint": "حُط صادرات الاشتراك ده قبل الصادرات اللي ضبطتها بإيدك، عشان واحد منها يقدر يبقى الافتراضي.",
+        "preview": "معاينة",
+        "previewEmpty": "مفيش صادرات على الرابط ده.",
+        "refreshAll": "حدّث الكل",
+        "statusOk": "تمام",
+        "toastUpdated": "تم تحديث الاشتراك",
+        "addButton": "إضافة",
+        "active": "الاشتراكات النشطة",
+        "empty": "مفيش اشتراكات لسه. أضف واحد من فوق.",
+        "colRemark": "ملاحظة",
+        "colPrefix": "بادئة",
+        "colInterval": "الفاصل",
+        "colLastFetch": "آخر جلب",
+        "colEnabled": "مفعّل",
+        "auto": "تلقائي",
+        "never": "أبداً",
+        "yes": "نعم",
+        "no": "لا",
+        "refreshNow": "حدّث الآن",
+        "lastError": "آخر خطأ",
+        "deleteConfirm": "تحذف الاشتراك ده؟",
+        "restartHint": "بعد الإضافة أو التحديث، أعد تشغيل Xray (أو استنى إعادة التحميل التلقائي اللي جاية) عشان تفعّل الصادرات.",
+        "fromSubsTitle": "من اشتراكات الصادرات (للقراءة فقط)",
+        "fromSubsDesc": "مستوردة من اشتراكاتك النشطة. تقدر تديرها من لوحة الاشتراكات اللي فوق.",
+        "toastLoadFailed": "فشل تحميل الاشتراكات",
+        "toastUrlRequired": "رابط الاشتراك مطلوب",
+        "toastAdded": "تمت إضافة الاشتراك",
+        "toastAddFailed": "فشلت إضافة الاشتراك",
+        "toastRefreshed": "تم التحديث",
+        "toastRefreshFailed": "فشل التحديث",
+        "toastDeleted": "تم الحذف",
+        "toastDeleteFailed": "فشل الحذف"
+      },
       "balancer": {
         "addBalancer": "أضف موازن تحميل",
         "editBalancer": "عدل موازن التحميل",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "تم تحديث صادر NordVPN"
       },
       "warp": {
+        "changeIp": "تغيير الـ IP",
+        "changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
+        "autoUpdateIp": "التحديث التلقائي لعنوان IP",
+        "intervalDays": "الفاصل الزمني (أيام)",
+        "intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
         "licenseError": "فشل تعيين رخصة WARP.",
         "fetchFirst": "احصل على تكوين WARP أولاً.",
         "createAccount": "إنشاء حساب WARP",

+ 64 - 2
web/translation/en-US.json

@@ -813,7 +813,8 @@
       "clientCount": "Clients in group",
       "totalGroups": "Total groups",
       "totalGroupedClients": "Clients with a group",
-      "emptyGroups": "Empty groups",
+      "trafficUsed": "Traffic used",
+      "totalTraffic": "Total traffic",
       "addGroup": "Add Group",
       "createSuccess": "Group \"{name}\" created.",
       "rename": "Rename",
@@ -897,7 +898,9 @@
       "statusValues": {
         "online": "Online",
         "offline": "Offline",
-        "unknown": "Unknown"
+        "unknown": "Unknown",
+        "xrayError": "Xray Error",
+        "xrayStopped": "Stopped"
       },
       "toasts": {
         "list": "Failed to load nodes",
@@ -1010,6 +1013,8 @@
       "subProfileUrlDesc": "A link to your website displayed in the VPN client",
       "subAnnounce": "Announce",
       "subAnnounceDesc": "The announcement text displayed in the VPN client",
+      "subThemeDir": "Sub Theme Directory",
+      "subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
       "subEnableRouting": "Enable routing",
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
@@ -1199,6 +1204,8 @@
       "Inbounds": "Inbounds",
       "InboundsDesc": "Accepting the specific clients.",
       "Outbounds": "Outbounds",
+      "OutboundSubscriptions": "Outbound Subscriptions",
+      "OutboundSubscriptionsDesc": "Import outbounds from remote subscription URLs (vmess/vless/trojan/ss/...). Tags are kept stable for use in balancers and routing rules. Updates are automatic.",
       "Balancers": "Balancers",
       "balancerTagRequired": "Tag is required",
       "balancerSelectorRequired": "Pick at least one outbound",
@@ -1357,6 +1364,56 @@
         "privateKey": "Private Key",
         "load": "Load"
       },
+      "outboundSub": {
+        "manage": "Subscriptions",
+        "title": "Outbound Subscriptions",
+        "remark": "Remark (optional)",
+        "remarkPlaceholder": "e.g. HK nodes",
+        "url": "Subscription URL",
+        "urlPlaceholder": "https://... (base64 list of links)",
+        "tagPrefix": "Tag prefix",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Update interval",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Default 10 minutes. The background job checks frequently; each subscription only re-fetches when its own interval has passed.",
+        "enabled": "Enabled",
+        "allowPrivate": "Allow private address",
+        "allowPrivateHint": "Permit localhost / LAN / private IPs for this subscription's URL. Off by default for security — enable only for a trusted local source.",
+        "prepend": "Before manual outbounds",
+        "prependHint": "Place this subscription's outbounds before your manual ones, so one can become the default.",
+        "preview": "Preview",
+        "previewEmpty": "No outbounds found at this URL.",
+        "refreshAll": "Refresh all",
+        "statusOk": "OK",
+        "toastUpdated": "Subscription updated",
+        "addButton": "Add",
+        "active": "Active subscriptions",
+        "empty": "No subscriptions yet. Add one above.",
+        "colRemark": "Remark",
+        "colPrefix": "Prefix",
+        "colInterval": "Interval",
+        "colLastFetch": "Last fetch",
+        "colEnabled": "Enabled",
+        "auto": "auto",
+        "never": "never",
+        "yes": "Yes",
+        "no": "No",
+        "refreshNow": "Refresh now",
+        "lastError": "Last error",
+        "deleteConfirm": "Delete this subscription?",
+        "restartHint": "After adding or refreshing, restart Xray (or wait for the next auto-reload) to make the outbounds active.",
+        "fromSubsTitle": "From outbound subscriptions (read-only)",
+        "fromSubsDesc": "Imported from your active subscriptions. Manage them in the Subscriptions panel above.",
+        "toastLoadFailed": "Failed to load subscriptions",
+        "toastUrlRequired": "Subscription URL is required",
+        "toastAdded": "Subscription added",
+        "toastAddFailed": "Failed to add subscription",
+        "toastRefreshed": "Refreshed",
+        "toastRefreshFailed": "Refresh failed",
+        "toastDeleted": "Deleted",
+        "toastDeleteFailed": "Delete failed"
+      },
       "balancer": {
         "addBalancer": "Add Balancer",
         "editBalancer": "Edit Balancer",
@@ -1399,6 +1456,11 @@
         "outboundUpdated": "NordVPN outbound updated"
       },
       "warp": {
+        "changeIp": "Change IP",
+        "changeIpSuccess": "WARP IP changed successfully!",
+        "autoUpdateIp": "Auto Update IP Address",
+        "intervalDays": "Interval (Days)",
+        "intervalDesc": "0 to disable. Changes IP address automatically.",
         "licenseError": "Failed to set WARP license.",
         "fetchFirst": "Fetch the WARP config first.",
         "createAccount": "Create WARP account",

+ 64 - 2
web/translation/es-ES.json

@@ -812,7 +812,8 @@
       "clientCount": "Clientes en el grupo",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes con grupo",
-      "emptyGroups": "Grupos vacíos",
+      "trafficUsed": "Tráfico usado",
+      "totalTraffic": "Tráfico total",
       "addGroup": "Añadir grupo",
       "createSuccess": "Grupo «{name}» creado.",
       "rename": "Renombrar",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "En línea",
         "offline": "Sin conexión",
-        "unknown": "Desconocido"
+        "unknown": "Desconocido",
+        "xrayError": "Error de Xray",
+        "xrayStopped": "Detenido"
       },
       "toasts": {
         "list": "Error al cargar los nodos",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN",
       "subAnnounce": "Anuncio",
       "subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
+      "subThemeDir": "Directorio del tema de suscripción",
+      "subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
       "subEnableRouting": "Habilitar enrutamiento",
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
@@ -1356,6 +1361,58 @@
         "privateKey": "Clave privada",
         "load": "Carga"
       },
+      "OutboundSubscriptions": "Suscripciones de salida",
+      "OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
+      "outboundSub": {
+        "manage": "Suscripciones",
+        "title": "Suscripciones de salida",
+        "remark": "Notas (opcional)",
+        "remarkPlaceholder": "p. ej. nodos HK",
+        "url": "URL de suscripción",
+        "urlPlaceholder": "https://... (lista de enlaces en base64)",
+        "tagPrefix": "Prefijo de etiqueta",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Intervalo de actualización",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Por defecto 10 minutos. La tarea en segundo plano comprueba con frecuencia; cada suscripción solo vuelve a descargarse cuando ha transcurrido su propio intervalo.",
+        "enabled": "Habilitado",
+        "allowPrivate": "Permitir direcciones privadas",
+        "allowPrivateHint": "Permite localhost, la red local (LAN) y las IP privadas en la URL de esta suscripción. Desactivado por defecto por seguridad; actívalo solo para una fuente local de confianza.",
+        "prepend": "Antes de las salidas manuales",
+        "prependHint": "Coloca las salidas de esta suscripción antes de las configuradas manualmente, de modo que una de ellas pueda convertirse en la predeterminada.",
+        "preview": "Vista previa",
+        "previewEmpty": "No se encontraron salidas en esta URL.",
+        "refreshAll": "Actualizar todo",
+        "statusOk": "Correcto",
+        "toastUpdated": "Suscripción actualizada",
+        "addButton": "Añadir",
+        "active": "Suscripciones activas",
+        "empty": "Aún no hay suscripciones. Añade una arriba.",
+        "colRemark": "Notas",
+        "colPrefix": "Prefijo",
+        "colInterval": "Intervalo",
+        "colLastFetch": "Última descarga",
+        "colEnabled": "Habilitado",
+        "auto": "auto",
+        "never": "nunca",
+        "yes": "Sí",
+        "no": "No",
+        "refreshNow": "Actualizar ahora",
+        "lastError": "Último error",
+        "deleteConfirm": "¿Eliminar esta suscripción?",
+        "restartHint": "Después de añadir o actualizar, reinicia Xray (o espera a la próxima recarga automática) para activar las salidas.",
+        "fromSubsTitle": "Desde suscripciones de salida (solo lectura)",
+        "fromSubsDesc": "Importadas desde tus suscripciones activas. Gestiónalas en el panel de Suscripciones de arriba.",
+        "toastLoadFailed": "No se pudieron cargar las suscripciones",
+        "toastUrlRequired": "La URL de suscripción es obligatoria",
+        "toastAdded": "Suscripción añadida",
+        "toastAddFailed": "No se pudo añadir la suscripción",
+        "toastRefreshed": "Actualizada",
+        "toastRefreshFailed": "Error al actualizar",
+        "toastDeleted": "Eliminada",
+        "toastDeleteFailed": "Error al eliminar"
+      },
       "balancer": {
         "addBalancer": "Agregar equilibrador",
         "editBalancer": "Editar balanceador",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Salida NordVPN actualizada"
       },
       "warp": {
+        "changeIp": "Cambiar IP",
+        "changeIpSuccess": "¡IP de WARP cambiada correctamente!",
+        "autoUpdateIp": "Actualizar IP automáticamente",
+        "intervalDays": "Intervalo (días)",
+        "intervalDesc": "0 para desactivar. Cambia la dirección IP automáticamente.",
         "licenseError": "No se pudo establecer la licencia WARP.",
         "fetchFirst": "Obtén primero la configuración WARP.",
         "createAccount": "Crear cuenta WARP",

+ 64 - 2
web/translation/fa-IR.json

@@ -812,7 +812,8 @@
       "clientCount": "کاربران در گروه",
       "totalGroups": "تعداد گروه‌ها",
       "totalGroupedClients": "کاربران دارای گروه",
-      "emptyGroups": "گروه‌های خالی",
+      "trafficUsed": "ترافیک مصرف‌شده",
+      "totalTraffic": "مجموع ترافیک",
       "addGroup": "افزودن گروه",
       "createSuccess": "گروه «{name}» ایجاد شد.",
       "rename": "تغییر نام",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "آنلاین",
         "offline": "آفلاین",
-        "unknown": "نامشخص"
+        "unknown": "نامشخص",
+        "xrayError": "خطای Xray",
+        "xrayStopped": "متوقف"
       },
       "toasts": {
         "list": "بارگذاری نودها ناموفق",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود",
       "subAnnounce": "اعلان",
       "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود",
+      "subThemeDir": "پوشه قالب صفحه اشتراک",
+      "subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.",
       "subEnableRouting": "فعال‌سازی مسیریابی",
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
@@ -1356,6 +1361,58 @@
         "privateKey": "کلید خصوصی",
         "load": "فشار سرور"
       },
+      "OutboundSubscriptions": "سابسکریپشن‌های خروجی",
+      "OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
+      "outboundSub": {
+        "manage": "سابسکریپشن‌ها",
+        "title": "سابسکریپشن‌های خروجی",
+        "remark": "نام (اختیاری)",
+        "remarkPlaceholder": "مثلاً نودهای هنگ‌کنگ",
+        "url": "آدرس سابسکریپشن",
+        "urlPlaceholder": "https://... (فهرست base64 از لینک‌ها)",
+        "tagPrefix": "پیشوند تگ",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "بازه به‌روزرسانی",
+        "hours": "ساعت",
+        "minutes": "دقیقه",
+        "intervalHint": "پیش‌فرض ۱۰ دقیقه. وظیفهٔ پس‌زمینه به‌طور مکرر بررسی می‌کند؛ هر سابسکریپشن فقط زمانی دوباره دریافت می‌شود که بازهٔ خودش سپری شده باشد.",
+        "enabled": "فعال",
+        "allowPrivate": "اجازهٔ آدرس خصوصی",
+        "allowPrivateHint": "اجازه به localhost / شبکهٔ محلی (LAN) / IPهای خصوصی برای آدرس این سابسکریپشن. به‌دلایل امنیتی به‌طور پیش‌فرض غیرفعال است؛ فقط برای یک منبع محلی مورد اعتماد فعال کنید.",
+        "prepend": "پیش از خروجی‌های دستی",
+        "prependHint": "خروجی‌های این سابسکریپشن را پیش از خروجی‌های دستی شما قرار می‌دهد تا یکی از آن‌ها بتواند پیش‌فرض شود.",
+        "preview": "پیش‌نمایش",
+        "previewEmpty": "هیچ خروجی‌ای در این آدرس یافت نشد.",
+        "refreshAll": "تازه‌سازی همه",
+        "statusOk": "موفق",
+        "toastUpdated": "سابسکریپشن به‌روزرسانی شد",
+        "addButton": "افزودن",
+        "active": "سابسکریپشن‌های فعال",
+        "empty": "هنوز سابسکریپشنی وجود ندارد. از بالا یکی اضافه کنید.",
+        "colRemark": "نام",
+        "colPrefix": "پیشوند",
+        "colInterval": "بازه",
+        "colLastFetch": "آخرین دریافت",
+        "colEnabled": "فعال",
+        "auto": "خودکار",
+        "never": "هرگز",
+        "yes": "بله",
+        "no": "خیر",
+        "refreshNow": "تازه‌سازی اکنون",
+        "lastError": "آخرین خطا",
+        "deleteConfirm": "این سابسکریپشن حذف شود؟",
+        "restartHint": "پس از افزودن یا تازه‌سازی، برای فعال‌شدن خروجی‌ها Xray را راه‌اندازی مجدد کنید (یا منتظر بارگذاری مجدد خودکار بعدی بمانید).",
+        "fromSubsTitle": "از سابسکریپشن‌های خروجی (فقط‌خواندنی)",
+        "fromSubsDesc": "از سابسکریپشن‌های فعال شما وارد شده‌اند. آن‌ها را از پنل سابسکریپشن‌ها در بالا مدیریت کنید.",
+        "toastLoadFailed": "بارگذاری سابسکریپشن‌ها ناموفق بود",
+        "toastUrlRequired": "آدرس سابسکریپشن الزامی است",
+        "toastAdded": "سابسکریپشن افزوده شد",
+        "toastAddFailed": "افزودن سابسکریپشن ناموفق بود",
+        "toastRefreshed": "تازه‌سازی شد",
+        "toastRefreshFailed": "تازه‌سازی ناموفق بود",
+        "toastDeleted": "حذف شد",
+        "toastDeleteFailed": "حذف ناموفق بود"
+      },
       "balancer": {
         "addBalancer": "افزودن بالانسر",
         "editBalancer": "ویرایش بالانسر",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
       },
       "warp": {
+        "changeIp": "تغییر IP",
+        "changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
+        "autoUpdateIp": "به‌روزرسانی خودکار آدرس IP",
+        "intervalDays": "بازه (روز)",
+        "intervalDesc": "برای غیرفعال‌سازی ۰ بگذارید. آدرس IP را به‌صورت خودکار تغییر می‌دهد.",
         "licenseError": "تنظیم لایسنس WARP ناموفق بود.",
         "fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
         "createAccount": "ایجاد حساب WARP",

+ 64 - 2
web/translation/id-ID.json

@@ -812,7 +812,8 @@
       "clientCount": "Klien di grup",
       "totalGroups": "Total grup",
       "totalGroupedClients": "Klien dengan grup",
-      "emptyGroups": "Grup kosong",
+      "trafficUsed": "Trafik terpakai",
+      "totalTraffic": "Total trafik",
       "addGroup": "Tambah grup",
       "createSuccess": "Grup «{name}» dibuat.",
       "rename": "Ubah nama",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "Online",
         "offline": "Offline",
-        "unknown": "Tidak diketahui"
+        "unknown": "Tidak diketahui",
+        "xrayError": "Kesalahan Xray",
+        "xrayStopped": "Berhenti"
       },
       "toasts": {
         "list": "Gagal memuat node",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN",
       "subAnnounce": "Pengumuman",
       "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
+      "subThemeDir": "Direktori Tema Langganan",
+      "subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
       "subEnableRouting": "Aktifkan perutean",
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
@@ -1356,6 +1361,58 @@
         "privateKey": "Kunci Privat",
         "load": "Beban"
       },
+      "OutboundSubscriptions": "Langganan Outbound",
+      "OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
+      "outboundSub": {
+        "manage": "Langganan",
+        "title": "Langganan Outbound",
+        "remark": "Catatan (opsional)",
+        "remarkPlaceholder": "mis. node HK",
+        "url": "URL langganan",
+        "urlPlaceholder": "https://... (daftar tautan base64)",
+        "tagPrefix": "Awalan tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Interval pembaruan",
+        "hours": "j",
+        "minutes": "mnt",
+        "intervalHint": "Default 10 menit. Tugas latar belakang memeriksa secara berkala; setiap langganan hanya diambil ulang ketika intervalnya sendiri telah terlewati.",
+        "enabled": "Aktif",
+        "allowPrivate": "Izinkan alamat privat",
+        "allowPrivateHint": "Izinkan localhost / LAN / IP privat untuk URL langganan ini. Nonaktif secara default demi keamanan — aktifkan hanya untuk sumber lokal yang tepercaya.",
+        "prepend": "Sebelum outbound manual",
+        "prependHint": "Tempatkan outbound dari langganan ini sebelum outbound manual Anda, sehingga salah satunya dapat menjadi default.",
+        "preview": "Pratinjau",
+        "previewEmpty": "Tidak ada outbound yang ditemukan di URL ini.",
+        "refreshAll": "Segarkan semua",
+        "statusOk": "OK",
+        "toastUpdated": "Langganan diperbarui",
+        "addButton": "Tambah",
+        "active": "Langganan aktif",
+        "empty": "Belum ada langganan. Tambahkan satu di atas.",
+        "colRemark": "Catatan",
+        "colPrefix": "Awalan",
+        "colInterval": "Interval",
+        "colLastFetch": "Pengambilan terakhir",
+        "colEnabled": "Aktif",
+        "auto": "otomatis",
+        "never": "tidak pernah",
+        "yes": "Ya",
+        "no": "Tidak",
+        "refreshNow": "Segarkan sekarang",
+        "lastError": "Kesalahan terakhir",
+        "deleteConfirm": "Hapus langganan ini?",
+        "restartHint": "Setelah menambahkan atau menyegarkan, mulai ulang Xray (atau tunggu muat ulang otomatis berikutnya) agar outbound menjadi aktif.",
+        "fromSubsTitle": "Dari langganan outbound (hanya-baca)",
+        "fromSubsDesc": "Diimpor dari langganan aktif Anda. Kelola di panel Langganan di atas.",
+        "toastLoadFailed": "Gagal memuat langganan",
+        "toastUrlRequired": "URL langganan wajib diisi",
+        "toastAdded": "Langganan ditambahkan",
+        "toastAddFailed": "Gagal menambahkan langganan",
+        "toastRefreshed": "Disegarkan",
+        "toastRefreshFailed": "Gagal menyegarkan",
+        "toastDeleted": "Dihapus",
+        "toastDeleteFailed": "Gagal menghapus"
+      },
       "balancer": {
         "addBalancer": "Tambahkan Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Outbound NordVPN diperbarui"
       },
       "warp": {
+        "changeIp": "Ganti IP",
+        "changeIpSuccess": "IP WARP berhasil diganti!",
+        "autoUpdateIp": "Perbarui Alamat IP Otomatis",
+        "intervalDays": "Interval (Hari)",
+        "intervalDesc": "0 untuk menonaktifkan. Mengganti alamat IP secara otomatis.",
         "licenseError": "Gagal mengatur lisensi WARP.",
         "fetchFirst": "Ambil konfig WARP terlebih dahulu.",
         "createAccount": "Buat akun WARP",

+ 64 - 2
web/translation/ja-JP.json

@@ -812,7 +812,8 @@
       "clientCount": "グループ内のクライアント",
       "totalGroups": "グループ合計",
       "totalGroupedClients": "グループのあるクライアント",
-      "emptyGroups": "空のグループ",
+      "trafficUsed": "使用済みトラフィック",
+      "totalTraffic": "合計トラフィック",
       "addGroup": "グループ追加",
       "createSuccess": "グループ「{name}」を作成しました。",
       "rename": "名前変更",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "オンライン",
         "offline": "オフライン",
-        "unknown": "不明"
+        "unknown": "不明",
+        "xrayError": "Xray エラー",
+        "xrayStopped": "停止"
       },
       "toasts": {
         "list": "ノードの読み込みに失敗しました",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク",
       "subAnnounce": "お知らせ",
       "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
+      "subThemeDir": "サブスクリプションテーマディレクトリ",
+      "subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
       "subEnableRouting": "ルーティングを有効化",
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
@@ -1356,6 +1361,58 @@
         "privateKey": "秘密鍵",
         "load": "負荷"
       },
+      "OutboundSubscriptions": "アウトバウンドサブスクリプション",
+      "OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
+      "outboundSub": {
+        "manage": "サブスクリプション",
+        "title": "アウトバウンドサブスクリプション",
+        "remark": "備考(任意)",
+        "remarkPlaceholder": "例: 香港ノード",
+        "url": "サブスクリプションURL",
+        "urlPlaceholder": "https://...(リンクのbase64リスト)",
+        "tagPrefix": "タグのプレフィックス",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新間隔",
+        "hours": "時間",
+        "minutes": "分",
+        "intervalHint": "デフォルトは10分です。バックグラウンドジョブは頻繁にチェックしますが、各サブスクリプションは自身の間隔が経過したときにのみ再取得されます。",
+        "enabled": "有効",
+        "allowPrivate": "プライベートアドレスを許可",
+        "allowPrivateHint": "このサブスクリプションのURLに対して、localhost・LAN・プライベートIPへのアクセスを許可します。セキュリティのため既定では無効です。信頼できるローカルソースの場合のみ有効にしてください。",
+        "prepend": "手動アウトバウンドの前に配置",
+        "prependHint": "このサブスクリプションのアウトバウンドを、手動で設定したアウトバウンドより前に配置します。これにより、いずれかをデフォルトにできます。",
+        "preview": "プレビュー",
+        "previewEmpty": "このURLにはアウトバウンドが見つかりませんでした。",
+        "refreshAll": "すべて更新",
+        "statusOk": "OK",
+        "toastUpdated": "サブスクリプションを更新しました",
+        "addButton": "追加",
+        "active": "有効なサブスクリプション",
+        "empty": "サブスクリプションはまだありません。上から追加してください。",
+        "colRemark": "備考",
+        "colPrefix": "プレフィックス",
+        "colInterval": "間隔",
+        "colLastFetch": "最終取得",
+        "colEnabled": "有効",
+        "auto": "自動",
+        "never": "なし",
+        "yes": "はい",
+        "no": "いいえ",
+        "refreshNow": "今すぐ更新",
+        "lastError": "最後のエラー",
+        "deleteConfirm": "このサブスクリプションを削除しますか?",
+        "restartHint": "追加または更新した後、アウトバウンドを有効にするにはXrayを再起動してください(または次の自動リロードをお待ちください)。",
+        "fromSubsTitle": "アウトバウンドサブスクリプションから(読み取り専用)",
+        "fromSubsDesc": "有効なサブスクリプションからインポートされています。上のサブスクリプションパネルで管理してください。",
+        "toastLoadFailed": "サブスクリプションの読み込みに失敗しました",
+        "toastUrlRequired": "サブスクリプションURLは必須です",
+        "toastAdded": "サブスクリプションを追加しました",
+        "toastAddFailed": "サブスクリプションの追加に失敗しました",
+        "toastRefreshed": "更新しました",
+        "toastRefreshFailed": "更新に失敗しました",
+        "toastDeleted": "削除しました",
+        "toastDeleteFailed": "削除に失敗しました"
+      },
       "balancer": {
         "addBalancer": "負荷分散追加",
         "editBalancer": "負荷分散編集",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "NordVPN アウトバウンドを更新しました"
       },
       "warp": {
+        "changeIp": "IP を変更",
+        "changeIpSuccess": "WARP の IP を変更しました!",
+        "autoUpdateIp": "IP アドレスの自動更新",
+        "intervalDays": "間隔(日)",
+        "intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
         "licenseError": "WARP ライセンスの設定に失敗しました。",
         "fetchFirst": "先に WARP 構成を取得してください。",
         "createAccount": "WARP アカウントを作成",

+ 64 - 2
web/translation/pt-BR.json

@@ -812,7 +812,8 @@
       "clientCount": "Clientes no grupo",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes com grupo",
-      "emptyGroups": "Grupos vazios",
+      "trafficUsed": "Tráfego usado",
+      "totalTraffic": "Tráfego total",
       "addGroup": "Adicionar grupo",
       "createSuccess": "Grupo «{name}» criado.",
       "rename": "Renomear",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "Online",
         "offline": "Offline",
-        "unknown": "Desconhecido"
+        "unknown": "Desconhecido",
+        "xrayError": "Erro do Xray",
+        "xrayStopped": "Parado"
       },
       "toasts": {
         "list": "Falha ao carregar os nós",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN",
       "subAnnounce": "Anúncio",
       "subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
+      "subThemeDir": "Diretório do tema de assinatura",
+      "subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
       "subEnableRouting": "Ativar roteamento",
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
@@ -1356,6 +1361,58 @@
         "privateKey": "Chave Privada",
         "load": "Carga"
       },
+      "OutboundSubscriptions": "Assinaturas de Saída",
+      "OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
+      "outboundSub": {
+        "manage": "Assinaturas",
+        "title": "Assinaturas de Saída",
+        "remark": "Observação (opcional)",
+        "remarkPlaceholder": "ex.: nós de HK",
+        "url": "URL da assinatura",
+        "urlPlaceholder": "https://... (lista de links em base64)",
+        "tagPrefix": "Prefixo da tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Intervalo de atualização",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Padrão de 10 minutos. A tarefa em segundo plano verifica com frequência; cada assinatura só é buscada novamente quando o seu próprio intervalo é atingido.",
+        "enabled": "Ativado",
+        "allowPrivate": "Permitir endereço privado",
+        "allowPrivateHint": "Permite localhost / LAN / IPs privados para a URL desta assinatura. Desativado por padrão por segurança — ative apenas para uma fonte local confiável.",
+        "prepend": "Antes das saídas manuais",
+        "prependHint": "Coloca as saídas desta assinatura antes das suas saídas configuradas manualmente, para que uma delas possa se tornar a padrão.",
+        "preview": "Pré-visualizar",
+        "previewEmpty": "Nenhuma saída encontrada nesta URL.",
+        "refreshAll": "Atualizar todas",
+        "statusOk": "OK",
+        "toastUpdated": "Assinatura atualizada",
+        "addButton": "Adicionar",
+        "active": "Assinaturas ativas",
+        "empty": "Nenhuma assinatura ainda. Adicione uma acima.",
+        "colRemark": "Observação",
+        "colPrefix": "Prefixo",
+        "colInterval": "Intervalo",
+        "colLastFetch": "Última busca",
+        "colEnabled": "Ativado",
+        "auto": "auto",
+        "never": "nunca",
+        "yes": "Sim",
+        "no": "Não",
+        "refreshNow": "Atualizar agora",
+        "lastError": "Último erro",
+        "deleteConfirm": "Excluir esta assinatura?",
+        "restartHint": "Após adicionar ou atualizar, reinicie o Xray (ou aguarde o próximo recarregamento automático) para ativar as saídas.",
+        "fromSubsTitle": "De assinaturas de saída (somente leitura)",
+        "fromSubsDesc": "Importadas das suas assinaturas ativas. Gerencie-as no painel de Assinaturas acima.",
+        "toastLoadFailed": "Falha ao carregar as assinaturas",
+        "toastUrlRequired": "A URL da assinatura é obrigatória",
+        "toastAdded": "Assinatura adicionada",
+        "toastAddFailed": "Falha ao adicionar a assinatura",
+        "toastRefreshed": "Atualizado",
+        "toastRefreshFailed": "Falha na atualização",
+        "toastDeleted": "Excluído",
+        "toastDeleteFailed": "Falha ao excluir"
+      },
       "balancer": {
         "addBalancer": "Adicionar Balanceador",
         "editBalancer": "Editar Balanceador",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Saída NordVPN atualizada"
       },
       "warp": {
+        "changeIp": "Alterar IP",
+        "changeIpSuccess": "IP do WARP alterado com sucesso!",
+        "autoUpdateIp": "Atualizar endereço IP automaticamente",
+        "intervalDays": "Intervalo (dias)",
+        "intervalDesc": "0 para desativar. Altera o endereço IP automaticamente.",
         "licenseError": "Falha ao definir licença WARP.",
         "fetchFirst": "Obtenha primeiro a configuração WARP.",
         "createAccount": "Criar conta WARP",

+ 64 - 2
web/translation/ru-RU.json

@@ -812,7 +812,8 @@
       "clientCount": "Клиентов в группе",
       "totalGroups": "Всего групп",
       "totalGroupedClients": "Клиенты с группой",
-      "emptyGroups": "Пустые группы",
+      "trafficUsed": "Использованный трафик",
+      "totalTraffic": "Общий трафик",
       "addGroup": "Добавить группу",
       "createSuccess": "Группа «{name}» создана.",
       "rename": "Переименовать",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "В сети",
         "offline": "Не в сети",
-        "unknown": "Неизвестно"
+        "unknown": "Неизвестно",
+        "xrayError": "Ошибка Xray",
+        "xrayStopped": "Остановлен"
       },
       "toasts": {
         "list": "Не удалось загрузить узлы",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте",
       "subAnnounce": "Объявление",
       "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
+      "subThemeDir": "Каталог темы подписки",
+      "subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
       "subEnableRouting": "Включить маршрутизацию",
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
@@ -1356,6 +1361,58 @@
         "privateKey": "Приватный ключ",
         "load": "Нагрузка"
       },
+      "OutboundSubscriptions": "Подписки исходящих",
+      "OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
+      "outboundSub": {
+        "manage": "Подписки",
+        "title": "Подписки исходящих",
+        "remark": "Примечание (необязательно)",
+        "remarkPlaceholder": "напр. узлы HK",
+        "url": "URL подписки",
+        "urlPlaceholder": "https://... (список ссылок в base64)",
+        "tagPrefix": "Префикс тега",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Интервал обновления",
+        "hours": "ч",
+        "minutes": "мин",
+        "intervalHint": "По умолчанию 10 минут. Фоновая задача проверяет подписки часто, но каждая подписка обновляется только после того, как пройдёт её собственный интервал.",
+        "enabled": "Включено",
+        "allowPrivate": "Разрешить приватные адреса",
+        "allowPrivateHint": "Разрешить localhost, LAN и приватные IP-адреса для URL этой подписки. По умолчанию отключено в целях безопасности — включайте только для доверенного локального источника.",
+        "prepend": "Перед ручными исходящими",
+        "prependHint": "Размещать исходящие этой подписки перед вашими настроенными вручную, чтобы один из них мог стать исходящим по умолчанию.",
+        "preview": "Предпросмотр",
+        "previewEmpty": "По этому URL исходящих не найдено.",
+        "refreshAll": "Обновить все",
+        "statusOk": "OK",
+        "toastUpdated": "Подписка обновлена",
+        "addButton": "Добавить",
+        "active": "Активные подписки",
+        "empty": "Подписок пока нет. Добавьте одну выше.",
+        "colRemark": "Примечание",
+        "colPrefix": "Префикс",
+        "colInterval": "Интервал",
+        "colLastFetch": "Последнее обновление",
+        "colEnabled": "Включено",
+        "auto": "авто",
+        "never": "никогда",
+        "yes": "Да",
+        "no": "Нет",
+        "refreshNow": "Обновить сейчас",
+        "lastError": "Последняя ошибка",
+        "deleteConfirm": "Удалить эту подписку?",
+        "restartHint": "После добавления или обновления перезапустите Xray (или дождитесь следующей автоперезагрузки), чтобы исходящие стали активными.",
+        "fromSubsTitle": "Из подписок исходящих (только для чтения)",
+        "fromSubsDesc": "Импортировано из ваших активных подписок. Управление ими доступно на панели «Подписки» выше.",
+        "toastLoadFailed": "Не удалось загрузить подписки",
+        "toastUrlRequired": "Укажите URL подписки",
+        "toastAdded": "Подписка добавлена",
+        "toastAddFailed": "Не удалось добавить подписку",
+        "toastRefreshed": "Обновлено",
+        "toastRefreshFailed": "Не удалось обновить",
+        "toastDeleted": "Удалено",
+        "toastDeleteFailed": "Не удалось удалить"
+      },
       "balancer": {
         "addBalancer": "Создать балансировщик",
         "editBalancer": "Редактировать балансировщик",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Исходящий NordVPN обновлён"
       },
       "warp": {
+        "changeIp": "Сменить IP",
+        "changeIpSuccess": "IP-адрес WARP успешно изменён!",
+        "autoUpdateIp": "Автоматическое обновление IP-адреса",
+        "intervalDays": "Интервал (дни)",
+        "intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
         "licenseError": "Не удалось установить лицензию WARP.",
         "fetchFirst": "Сначала получите WARP-конфиг.",
         "createAccount": "Создать аккаунт WARP",

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 328 - 326
web/translation/tr-TR.json


+ 64 - 2
web/translation/uk-UA.json

@@ -812,7 +812,8 @@
       "clientCount": "Клієнтів у групі",
       "totalGroups": "Всього груп",
       "totalGroupedClients": "Клієнти з групою",
-      "emptyGroups": "Порожні групи",
+      "trafficUsed": "Використаний трафік",
+      "totalTraffic": "Загальний трафік",
       "addGroup": "Додати групу",
       "createSuccess": "Групу «{name}» створено.",
       "rename": "Перейменувати",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "У мережі",
         "offline": "Не в мережі",
-        "unknown": "Невідомо"
+        "unknown": "Невідомо",
+        "xrayError": "Помилка Xray",
+        "xrayStopped": "Зупинено"
       },
       "toasts": {
         "list": "Не вдалося завантажити вузли",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті",
       "subAnnounce": "Оголошення",
       "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
+      "subThemeDir": "Каталог теми підписки",
+      "subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
       "subEnableRouting": "Увімкнути маршрутизацію",
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
@@ -1356,6 +1361,58 @@
         "privateKey": "Приватний ключ",
         "load": "Навантаження"
       },
+      "OutboundSubscriptions": "Підписки вихідних",
+      "OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
+      "outboundSub": {
+        "manage": "Підписки",
+        "title": "Підписки вихідних",
+        "remark": "Примітка (необов'язково)",
+        "remarkPlaceholder": "напр. вузли HK",
+        "url": "URL підписки",
+        "urlPlaceholder": "https://... (список посилань у base64)",
+        "tagPrefix": "Префікс тегу",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Інтервал оновлення",
+        "hours": "год",
+        "minutes": "хв",
+        "intervalHint": "За замовчуванням 10 хвилин. Фонове завдання перевіряє часто; кожна підписка повторно завантажується лише після того, як мине її власний інтервал.",
+        "enabled": "Увімкнено",
+        "allowPrivate": "Дозволити приватні адреси",
+        "allowPrivateHint": "Дозволити localhost / LAN / приватні IP-адреси для URL цієї підписки. З міркувань безпеки вимкнено за замовчуванням — вмикайте лише для довіреного локального джерела.",
+        "prepend": "Перед ручними вихідними",
+        "prependHint": "Розмістити вихідні цієї підписки перед вашими ручними, щоб один із них міг стати типовим.",
+        "preview": "Попередній перегляд",
+        "previewEmpty": "За цим URL вихідних не знайдено.",
+        "refreshAll": "Оновити всі",
+        "statusOk": "OK",
+        "toastUpdated": "Підписку оновлено",
+        "addButton": "Додати",
+        "active": "Активні підписки",
+        "empty": "Підписок поки немає. Додайте одну вище.",
+        "colRemark": "Примітка",
+        "colPrefix": "Префікс",
+        "colInterval": "Інтервал",
+        "colLastFetch": "Останнє завантаження",
+        "colEnabled": "Увімкнено",
+        "auto": "авто",
+        "never": "ніколи",
+        "yes": "Так",
+        "no": "Ні",
+        "refreshNow": "Оновити зараз",
+        "lastError": "Остання помилка",
+        "deleteConfirm": "Видалити цю підписку?",
+        "restartHint": "Після додавання або оновлення перезапустіть Xray (або зачекайте наступного автоматичного перезавантаження), щоб вихідні стали активними.",
+        "fromSubsTitle": "З підписок вихідних (лише для читання)",
+        "fromSubsDesc": "Імпортовано з ваших активних підписок. Керуйте ними на панелі «Підписки» вище.",
+        "toastLoadFailed": "Не вдалося завантажити підписки",
+        "toastUrlRequired": "Потрібен URL підписки",
+        "toastAdded": "Підписку додано",
+        "toastAddFailed": "Не вдалося додати підписку",
+        "toastRefreshed": "Оновлено",
+        "toastRefreshFailed": "Не вдалося оновити",
+        "toastDeleted": "Видалено",
+        "toastDeleteFailed": "Не вдалося видалити"
+      },
       "balancer": {
         "addBalancer": "Додати балансир",
         "editBalancer": "Редагувати балансир",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Вихідний NordVPN оновлено"
       },
       "warp": {
+        "changeIp": "Змінити IP",
+        "changeIpSuccess": "IP-адресу WARP успішно змінено!",
+        "autoUpdateIp": "Автоматичне оновлення IP-адреси",
+        "intervalDays": "Інтервал (дні)",
+        "intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
         "licenseError": "Не вдалося встановити ліцензію WARP.",
         "fetchFirst": "Спочатку отримайте WARP-конфіг.",
         "createAccount": "Створити акаунт WARP",

+ 64 - 2
web/translation/vi-VN.json

@@ -812,7 +812,8 @@
       "clientCount": "Client trong nhóm",
       "totalGroups": "Tổng số nhóm",
       "totalGroupedClients": "Client có nhóm",
-      "emptyGroups": "Nhóm trống",
+      "trafficUsed": "Lưu lượng đã dùng",
+      "totalTraffic": "Tổng lưu lượng",
       "addGroup": "Thêm nhóm",
       "createSuccess": "Đã tạo nhóm «{name}».",
       "rename": "Đổi tên",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "Trực tuyến",
         "offline": "Ngoại tuyến",
-        "unknown": "Không xác định"
+        "unknown": "Không xác định",
+        "xrayError": "Lỗi Xray",
+        "xrayStopped": "Đã dừng"
       },
       "toasts": {
         "list": "Không tải được danh sách nút",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN",
       "subAnnounce": "Thông báo",
       "subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
+      "subThemeDir": "Thư mục giao diện Đăng ký",
+      "subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
       "subEnableRouting": "Bật định tuyến",
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
@@ -1356,6 +1361,58 @@
         "privateKey": "Khóa riêng",
         "load": "Tải"
       },
+      "OutboundSubscriptions": "Đăng ký Outbound",
+      "OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
+      "outboundSub": {
+        "manage": "Đăng ký",
+        "title": "Đăng ký Outbound",
+        "remark": "Ghi chú (tùy chọn)",
+        "remarkPlaceholder": "ví dụ node HK",
+        "url": "URL đăng ký",
+        "urlPlaceholder": "https://... (danh sách liên kết base64)",
+        "tagPrefix": "Tiền tố tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Khoảng cập nhật",
+        "hours": "giờ",
+        "minutes": "phút",
+        "intervalHint": "Mặc định 10 phút. Tác vụ nền kiểm tra thường xuyên; mỗi đăng ký chỉ tải lại khi khoảng thời gian riêng của nó đã trôi qua.",
+        "enabled": "Đã kích hoạt",
+        "allowPrivate": "Cho phép địa chỉ riêng tư",
+        "allowPrivateHint": "Cho phép localhost / mạng LAN / IP riêng tư đối với URL của đăng ký này. Mặc định tắt vì lý do bảo mật — chỉ bật khi nguồn cục bộ đáng tin cậy.",
+        "prepend": "Trước các outbound thủ công",
+        "prependHint": "Đặt các outbound của đăng ký này trước các outbound bạn cấu hình thủ công, để một trong số đó có thể trở thành mặc định.",
+        "preview": "Xem trước",
+        "previewEmpty": "Không tìm thấy outbound nào tại URL này.",
+        "refreshAll": "Cập nhật tất cả",
+        "statusOk": "OK",
+        "toastUpdated": "Đã cập nhật đăng ký",
+        "addButton": "Thêm",
+        "active": "Đăng ký đang hoạt động",
+        "empty": "Chưa có đăng ký nào. Hãy thêm một mục ở trên.",
+        "colRemark": "Ghi chú",
+        "colPrefix": "Tiền tố",
+        "colInterval": "Khoảng",
+        "colLastFetch": "Lần tải gần nhất",
+        "colEnabled": "Đã kích hoạt",
+        "auto": "tự động",
+        "never": "không bao giờ",
+        "yes": "Có",
+        "no": "Không",
+        "refreshNow": "Cập nhật ngay",
+        "lastError": "Lỗi gần nhất",
+        "deleteConfirm": "Xóa đăng ký này?",
+        "restartHint": "Sau khi thêm hoặc cập nhật, hãy khởi động lại Xray (hoặc chờ lần tự động tải lại tiếp theo) để kích hoạt các outbound.",
+        "fromSubsTitle": "Từ đăng ký outbound (chỉ đọc)",
+        "fromSubsDesc": "Được nhập từ các đăng ký đang hoạt động của bạn. Hãy quản lý chúng trong bảng Đăng ký ở trên.",
+        "toastLoadFailed": "Không thể tải danh sách đăng ký",
+        "toastUrlRequired": "URL đăng ký là bắt buộc",
+        "toastAdded": "Đã thêm đăng ký",
+        "toastAddFailed": "Không thể thêm đăng ký",
+        "toastRefreshed": "Đã cập nhật",
+        "toastRefreshFailed": "Cập nhật thất bại",
+        "toastDeleted": "Đã xóa",
+        "toastDeleteFailed": "Xóa thất bại"
+      },
       "balancer": {
         "addBalancer": "Thêm cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "Đã cập nhật outbound NordVPN"
       },
       "warp": {
+        "changeIp": "Đổi IP",
+        "changeIpSuccess": "Đã đổi IP WARP thành công!",
+        "autoUpdateIp": "Tự động cập nhật địa chỉ IP",
+        "intervalDays": "Khoảng thời gian (ngày)",
+        "intervalDesc": "0 để tắt. Tự động đổi địa chỉ IP.",
         "licenseError": "Không thiết lập được giấy phép WARP.",
         "fetchFirst": "Hãy lấy cấu hình WARP trước.",
         "createAccount": "Tạo tài khoản WARP",

+ 64 - 2
web/translation/zh-CN.json

@@ -812,7 +812,8 @@
       "clientCount": "分组中的客户端",
       "totalGroups": "分组总数",
       "totalGroupedClients": "有分组的客户端",
-      "emptyGroups": "空分组",
+      "trafficUsed": "已用流量",
+      "totalTraffic": "总流量",
       "addGroup": "添加分组",
       "createSuccess": "已创建分组 “{name}”。",
       "rename": "重命名",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "在线",
         "offline": "离线",
-        "unknown": "未知"
+        "unknown": "未知",
+        "xrayError": "Xray 错误",
+        "xrayStopped": "已停止"
       },
       "toasts": {
         "list": "加载节点失败",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "VPN 客户端中显示的网站链接",
       "subAnnounce": "公告",
       "subAnnounceDesc": "VPN 客户端中显示的公告文本",
+      "subThemeDir": "订阅主题目录",
+      "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
       "subEnableRouting": "启用路由",
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
       "subRoutingRules": "路由規則",
@@ -1356,6 +1361,58 @@
         "privateKey": "私钥",
         "load": "负载"
       },
+      "OutboundSubscriptions": "出站订阅",
+      "OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
+      "outboundSub": {
+        "manage": "订阅",
+        "title": "出站订阅",
+        "remark": "备注(可选)",
+        "remarkPlaceholder": "如:香港节点",
+        "url": "订阅 URL",
+        "urlPlaceholder": "https://...(base64 编码的链接列表)",
+        "tagPrefix": "标签前缀",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新间隔",
+        "hours": "时",
+        "minutes": "分",
+        "intervalHint": "默认 10 分钟。后台任务会频繁检查;每个订阅仅在自身间隔到期后才重新拉取。",
+        "enabled": "启用",
+        "allowPrivate": "允许私有地址",
+        "allowPrivateHint": "允许此订阅 URL 使用 localhost / 局域网(LAN)/ 私有 IP 地址。出于安全考虑默认关闭,仅在使用可信的本地来源时才开启。",
+        "prepend": "置于手动出站之前",
+        "prependHint": "将此订阅的出站排在手动配置的出站之前,使其中之一可成为默认出站。",
+        "preview": "预览",
+        "previewEmpty": "在该 URL 未找到任何出站。",
+        "refreshAll": "全部刷新",
+        "statusOk": "正常",
+        "toastUpdated": "订阅已更新",
+        "addButton": "添加",
+        "active": "已启用的订阅",
+        "empty": "暂无订阅。请在上方添加。",
+        "colRemark": "备注",
+        "colPrefix": "前缀",
+        "colInterval": "间隔",
+        "colLastFetch": "上次拉取",
+        "colEnabled": "启用",
+        "auto": "自动",
+        "never": "从未",
+        "yes": "是",
+        "no": "否",
+        "refreshNow": "立即刷新",
+        "lastError": "上次错误",
+        "deleteConfirm": "删除此订阅?",
+        "restartHint": "添加或刷新后,请重启 Xray(或等待下一次自动重载)以使出站生效。",
+        "fromSubsTitle": "来自出站订阅(只读)",
+        "fromSubsDesc": "从已启用的订阅中导入。请在上方的订阅面板中管理它们。",
+        "toastLoadFailed": "加载订阅失败",
+        "toastUrlRequired": "订阅 URL 为必填项",
+        "toastAdded": "订阅已添加",
+        "toastAddFailed": "添加订阅失败",
+        "toastRefreshed": "已刷新",
+        "toastRefreshFailed": "刷新失败",
+        "toastDeleted": "已删除",
+        "toastDeleteFailed": "删除失败"
+      },
       "balancer": {
         "addBalancer": "添加负载均衡",
         "editBalancer": "编辑负载均衡",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "NordVPN 出站已更新"
       },
       "warp": {
+        "changeIp": "更换 IP",
+        "changeIpSuccess": "WARP IP 更换成功!",
+        "autoUpdateIp": "自动更新 IP 地址",
+        "intervalDays": "间隔(天)",
+        "intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
         "licenseError": "设置 WARP 许可证失败。",
         "fetchFirst": "请先获取 WARP 配置。",
         "createAccount": "创建 WARP 账户",

+ 64 - 2
web/translation/zh-TW.json

@@ -812,7 +812,8 @@
       "clientCount": "群組中的客戶端",
       "totalGroups": "群組總數",
       "totalGroupedClients": "有群組的客戶端",
-      "emptyGroups": "空群組",
+      "trafficUsed": "已用流量",
+      "totalTraffic": "總流量",
       "addGroup": "新增群組",
       "createSuccess": "已建立群組「{name}」。",
       "rename": "重新命名",
@@ -896,7 +897,9 @@
       "statusValues": {
         "online": "上線",
         "offline": "離線",
-        "unknown": "未知"
+        "unknown": "未知",
+        "xrayError": "Xray 錯誤",
+        "xrayStopped": "已停止"
       },
       "toasts": {
         "list": "載入節點失敗",
@@ -1009,6 +1012,8 @@
       "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結",
       "subAnnounce": "公告",
       "subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
+      "subThemeDir": "訂閱主題目錄",
+      "subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
       "subEnableRouting": "啟用路由",
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
@@ -1356,6 +1361,58 @@
         "privateKey": "私密金鑰",
         "load": "負載"
       },
+      "OutboundSubscriptions": "出站訂閱",
+      "OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
+      "outboundSub": {
+        "manage": "訂閱",
+        "title": "出站訂閱",
+        "remark": "備註(選填)",
+        "remarkPlaceholder": "例如:香港節點",
+        "url": "訂閱 URL",
+        "urlPlaceholder": "https://...(base64 連結清單)",
+        "tagPrefix": "標籤前綴",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新間隔",
+        "hours": "時",
+        "minutes": "分",
+        "intervalHint": "預設為 10 分鐘。背景工作會頻繁檢查;每個訂閱只在自己的間隔到期後才重新抓取。",
+        "enabled": "啟用",
+        "allowPrivate": "允許私有位址",
+        "allowPrivateHint": "允許此訂閱的 URL 使用 localhost/區域網路(LAN)/私有 IP。基於安全考量預設為關閉,請僅在來源為受信任的本機時才啟用。",
+        "prepend": "置於手動出站之前",
+        "prependHint": "將此訂閱的出站排在您手動設定的出站之前,讓其中之一可成為預設出站。",
+        "preview": "預覽",
+        "previewEmpty": "在此 URL 找不到任何出站。",
+        "refreshAll": "全部重新整理",
+        "statusOk": "正常",
+        "toastUpdated": "訂閱已更新",
+        "addButton": "新增",
+        "active": "啟用中的訂閱",
+        "empty": "尚無訂閱。請從上方新增。",
+        "colRemark": "備註",
+        "colPrefix": "前綴",
+        "colInterval": "間隔",
+        "colLastFetch": "上次抓取",
+        "colEnabled": "啟用",
+        "auto": "自動",
+        "never": "從不",
+        "yes": "是",
+        "no": "否",
+        "refreshNow": "立即重新整理",
+        "lastError": "上次錯誤",
+        "deleteConfirm": "確定要刪除此訂閱嗎?",
+        "restartHint": "新增或重新整理後,請重新啟動 Xray(或等待下次自動重新載入),讓出站生效。",
+        "fromSubsTitle": "來自出站訂閱(唯讀)",
+        "fromSubsDesc": "從您啟用中的訂閱匯入。請於上方的「訂閱」面板中管理。",
+        "toastLoadFailed": "載入訂閱失敗",
+        "toastUrlRequired": "訂閱 URL 為必填",
+        "toastAdded": "訂閱已新增",
+        "toastAddFailed": "新增訂閱失敗",
+        "toastRefreshed": "已重新整理",
+        "toastRefreshFailed": "重新整理失敗",
+        "toastDeleted": "已刪除",
+        "toastDeleteFailed": "刪除失敗"
+      },
       "balancer": {
         "addBalancer": "新增負載均衡",
         "editBalancer": "編輯負載均衡",
@@ -1398,6 +1455,11 @@
         "outboundUpdated": "NordVPN 出站已更新"
       },
       "warp": {
+        "changeIp": "更換 IP",
+        "changeIpSuccess": "WARP IP 更換成功!",
+        "autoUpdateIp": "自動更新 IP 位址",
+        "intervalDays": "間隔(天)",
+        "intervalDesc": "設為 0 停用。自動更換 IP 位址。",
         "licenseError": "設定 WARP 授權失敗。",
         "fetchFirst": "請先取得 WARP 設定。",
         "createAccount": "建立 WARP 帳號",

+ 6 - 4
web/web.go

@@ -294,8 +294,12 @@ func (s *Server) startTask(restartXray bool) {
 
 	s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
 
+	// Outbound subscription auto-refresh (respects per-sub updateInterval)
+	s.cron.AddJob("@every 5m", job.NewOutboundSubscriptionJob())
+
 	// check client ips from log file every day
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
+	s.cron.AddJob("@hourly", job.NewWarpIpJob())
 
 	// Inbound traffic reset jobs
 	// Run every hour
@@ -355,9 +359,8 @@ func (s *Server) Start() (err error) {
 	return s.start(true, true)
 }
 
-// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
 func (s *Server) StartPanelOnly() (err error) {
-	return s.start(false, false)
+	return s.start(false, true)
 }
 
 func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
@@ -462,9 +465,8 @@ func (s *Server) Stop() error {
 	return s.stop(true, true)
 }
 
-// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
 func (s *Server) StopPanelOnly() error {
-	return s.stop(false, false)
+	return s.stop(false, true)
 }
 
 func (s *Server) stop(stopXray bool, stopTgBot bool) error {

+ 28 - 4
x-ui.sh

@@ -1181,7 +1181,7 @@ install_acme() {
 
 ssl_cert_issue_main() {
     echo -e "${green}\t1.${plain} Get SSL (Domain)"
-    echo -e "${green}\t2.${plain} Revoke"
+    echo -e "${green}\t2.${plain} Revoke & Remove"
     echo -e "${green}\t3.${plain} Force Renew"
     echo -e "${green}\t4.${plain} Show Existing Domains"
     echo -e "${green}\t5.${plain} Set Cert paths for the panel"
@@ -1204,10 +1204,34 @@ ssl_cert_issue_main() {
             else
                 echo "Existing domains:"
                 echo "$domains"
-                read -rp "Please enter a domain from the list to revoke the certificate: " domain
+                read -rp "Please enter a domain from the list to revoke and remove the certificate: " domain
                 if echo "$domains" | grep -qw "$domain"; then
-                    ~/.acme.sh/acme.sh --revoke -d ${domain}
-                    LOGI "Certificate revoked for domain: $domain"
+                    # The IP-cert flow (option 6) stores files under /root/cert/ip, but acme.sh
+                    # tracks the cert under the actual IP address(es). Resolve those so renewal
+                    # state is torn down too; otherwise the acme.sh cron re-creates the deleted cert.
+                    local acme_ids="${domain}"
+                    if [[ "${domain}" == "ip" ]]; then
+                        acme_ids=$(~/.acme.sh/acme.sh --list 2> /dev/null | awk 'NR>1 {print $1}' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$|:')
+                    fi
+                    for id in ${acme_ids}; do
+                        # Best-effort revoke at the CA, then drop acme.sh renewal tracking.
+                        ~/.acme.sh/acme.sh --revoke -d "${id}" 2> /dev/null
+                        ~/.acme.sh/acme.sh --remove -d "${id}" 2> /dev/null
+                        # --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
+                        rm -rf ~/.acme.sh/"${id}" ~/.acme.sh/"${id}_ecc"
+                    done
+                    # Delete the local certificate files for this domain.
+                    rm -rf "/root/cert/${domain}"
+                    LOGI "Certificate revoked and removed for domain: ${domain}"
+
+                    # If the panel currently serves this domain's cert, clear the stored paths
+                    # so it stops loading the now-deleted files, then restart.
+                    local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
+                    if [[ "${existing_cert}" == "/root/cert/${domain}/"* ]]; then
+                        ${xui_folder}/x-ui cert -reset
+                        LOGI "Cleared panel certificate paths referencing ${domain}; restarting panel."
+                        restart
+                    fi
                 else
                     echo "Invalid domain entered."
                 fi

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio