1
0

10 Коммитууд e63cde8fcb ... 573c43e445

Эзэн SHA1 Мессеж Огноо
  MHSanaei 573c43e445 feat(sidebar): collapse to icon rail, expand on hover 17 цаг өмнө
  MHSanaei db5ce06256 fix(panel-proxy): route custom geo and http(s) Telegram through panelProxy 17 цаг өмнө
  MHSanaei 71cf22fa8d fix(migrate-db): preserve false-valued columns in SQLite to Postgres copy 18 цаг өмнө
  MHSanaei e7c11c913a feat(inbounds): per-proxy Pinned Peer Cert SHA-256 + labeled External Proxy form 18 цаг өмнө
  MHSanaei df7ccd3a64 fix(clients): use client_inbounds link to resolve inbound, not stale id 18 цаг өмнө
  MHSanaei dc57c1e92c chore(frontend): bump deps to 0.2.7 and hide node row selection for single node 20 цаг өмнө
  MHSanaei d4c020f365 feat(dashboard): more System History metrics, persistence & localized labels 20 цаг өмнө
  MHSanaei 4b11c54206 feat(dashboard): richer System History & Xray Metrics charts 21 цаг өмнө
  MHSanaei a4dae566ce feat(xray): merge basic routing into the routing rules section 22 цаг өмнө
  MHSanaei ac89ec724f feat(settings): sidebar submenu nav for settings and xray with icon tabs 23 цаг өмнө
63 өөрчлөгдсөн 2699 нэмэгдсэн , 1163 устгасан
  1. 19 4
      database/migrate_data.go
  2. 75 0
      database/migrate_data_test.go
  3. 173 173
      frontend/package-lock.json
  4. 11 11
      frontend/package.json
  5. 25 0
      frontend/src/components/viz/Sparkline.css
  6. 85 6
      frontend/src/components/viz/Sparkline.tsx
  7. 23 0
      frontend/src/layouts/AppSidebar.css
  8. 75 36
      frontend/src/layouts/AppSidebar.tsx
  9. 1 1
      frontend/src/lib/xray/inbound-defaults.ts
  10. 19 0
      frontend/src/lib/xray/inbound-link.ts
  11. 1 1
      frontend/src/pages/clients/ClientsPage.tsx
  12. 1 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  13. 72 0
      frontend/src/pages/inbounds/form/transport/external-proxy.css
  14. 164 97
      frontend/src/pages/inbounds/form/transport/external-proxy.tsx
  15. 7 0
      frontend/src/pages/index/SystemHistoryModal.css
  16. 80 11
      frontend/src/pages/index/SystemHistoryModal.tsx
  17. 26 7
      frontend/src/pages/index/XrayMetricsModal.tsx
  18. 2 2
      frontend/src/pages/nodes/NodeList.tsx
  19. 19 8
      frontend/src/pages/settings/GeneralTab.tsx
  20. 9 5
      frontend/src/pages/settings/SecurityTab.tsx
  21. 13 90
      frontend/src/pages/settings/SettingsPage.tsx
  22. 13 2
      frontend/src/pages/settings/SubscriptionFormatsTab.css
  23. 120 132
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  24. 10 6
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  25. 8 4
      frontend/src/pages/settings/TelegramTab.tsx
  26. 17 0
      frontend/src/pages/settings/catTabLabel.tsx
  27. 94 187
      frontend/src/pages/xray/XrayPage.tsx
  28. 16 196
      frontend/src/pages/xray/basics/BasicsTab.tsx
  29. 19 8
      frontend/src/pages/xray/dns/DnsTab.tsx
  30. 160 0
      frontend/src/pages/xray/routing/RoutingBasic.tsx
  31. 4 0
      frontend/src/pages/xray/routing/RoutingTab.css
  32. 80 48
      frontend/src/pages/xray/routing/RoutingTab.tsx
  33. 8 1
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  34. 1 1
      frontend/src/schemas/protocols/inbound/shadowsocks.ts
  35. 1 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  36. 1 1
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  37. 74 0
      frontend/src/test/inbound-link.test.ts
  38. 79 5
      sub/subService.go
  39. 79 0
      sub/subService_test.go
  40. 6 0
      web/controller/server.go
  41. 32 9
      web/service/custom_geo.go
  42. 2 2
      web/service/custom_geo_test.go
  43. 140 67
      web/service/inbound.go
  44. 72 0
      web/service/inbound_client_traffic_test.go
  45. 86 1
      web/service/metric_history.go
  46. 103 0
      web/service/panel_proxy_test.go
  47. 65 5
      web/service/server.go
  48. 17 12
      web/service/tgbot.go
  49. 88 0
      web/service/tgbot_test.go
  50. 32 3
      web/translation/ar-EG.json
  51. 29 0
      web/translation/en-US.json
  52. 30 1
      web/translation/es-ES.json
  53. 32 3
      web/translation/fa-IR.json
  54. 30 1
      web/translation/id-ID.json
  55. 31 2
      web/translation/ja-JP.json
  56. 30 1
      web/translation/pt-BR.json
  57. 32 3
      web/translation/ru-RU.json
  58. 31 2
      web/translation/tr-TR.json
  59. 32 3
      web/translation/uk-UA.json
  60. 30 1
      web/translation/vi-VN.json
  61. 31 2
      web/translation/zh-CN.json
  62. 31 2
      web/translation/zh-TW.json
  63. 3 0
      web/web.go

+ 19 - 4
database/migrate_data.go

@@ -1,6 +1,7 @@
 package database
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"log"
@@ -109,14 +110,15 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 
 	sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
 
-	// Resolve primary-key columns so paging is deterministic across successive
-	// LIMIT/OFFSET reads. The model set is trusted (not user input).
 	stmt := &gorm.Statement{DB: src}
 	if err := stmt.Parse(mdl); err != nil {
 		return 0, err
 	}
 	order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
+	table := stmt.Schema.Table
+	columns := stmt.Schema.DBNames
 
+	ctx := context.Background()
 	total := 0
 	for offset := 0; ; offset += batchSize {
 		batchPtr := reflect.New(sliceType)
@@ -127,11 +129,24 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 		if err := q.Find(batchPtr.Interface()).Error; err != nil {
 			return total, err
 		}
-		n := batchPtr.Elem().Len()
+		slice := batchPtr.Elem()
+		n := slice.Len()
 		if n == 0 {
 			break
 		}
-		if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
+
+		rows := make([]map[string]any, n)
+		for i := 0; i < n; i++ {
+			rv := reflect.Indirect(slice.Index(i))
+			row := make(map[string]any, len(columns))
+			for _, name := range columns {
+				value, _ := stmt.Schema.FieldsByDBName[name].ValueOf(ctx, rv)
+				row[name] = value
+			}
+			rows[i] = row
+		}
+
+		if err := dst.Table(table).CreateInBatches(rows, 200).Error; err != nil {
 			return total, err
 		}
 		total += n

+ 75 - 0
database/migrate_data_test.go

@@ -62,3 +62,78 @@ func TestMigrateData_CompositeKeyTableLargerThanBatch(t *testing.T) {
 		t.Fatalf("client_inbounds rows = %d, want %d", got, n)
 	}
 }
+
+func TestMigrateData_PreservesFalseDefaultedColumns(t *testing.T) {
+	dsn := os.Getenv("XUI_TEST_PG_DSN")
+	if dsn == "" {
+		t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
+	}
+
+	srcPath := t.TempDir() + "/x-ui.db"
+	src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	for _, m := range migrationModels() {
+		if err := src.AutoMigrate(m); err != nil {
+			t.Fatalf("automigrate %T: %v", m, err)
+		}
+	}
+
+	if err := src.Create([]*model.ClientRecord{
+		{Email: "[email protected]"},
+		{Email: "[email protected]"},
+	}).Error; err != nil {
+		t.Fatalf("seed clients: %v", err)
+	}
+	if err := src.Model(&model.ClientRecord{}).Where("email = ?", "[email protected]").
+		Update("enable", false).Error; err != nil {
+		t.Fatalf("disable client: %v", err)
+	}
+	if err := src.Create(&model.Node{Name: "n-off", Address: "1.2.3.4", Port: 1, ApiToken: "tok"}).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+	if err := src.Model(&model.Node{}).Where("name = ?", "n-off").
+		Update("enable", false).Error; err != nil {
+		t.Fatalf("disable node: %v", err)
+	}
+	if sqlDB, err := src.DB(); err == nil {
+		sqlDB.Close()
+	}
+
+	dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open postgres: %v", err)
+	}
+	if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
+		t.Fatalf("drop tables: %v", err)
+	}
+
+	if err := MigrateData(srcPath, dsn); err != nil {
+		t.Fatalf("MigrateData: %v", err)
+	}
+
+	var off model.ClientRecord
+	if err := dst.Where("email = ?", "[email protected]").First(&off).Error; err != nil {
+		t.Fatalf("load disabled client: %v", err)
+	}
+	if off.Enable {
+		t.Fatalf("disabled client re-enabled after migration (enable=%v)", off.Enable)
+	}
+
+	var on model.ClientRecord
+	if err := dst.Where("email = ?", "[email protected]").First(&on).Error; err != nil {
+		t.Fatalf("load enabled client: %v", err)
+	}
+	if !on.Enable {
+		t.Fatalf("enabled client wrongly disabled after migration")
+	}
+
+	var node model.Node
+	if err := dst.Where("name = ?", "n-off").First(&node).Error; err != nil {
+		t.Fatalf("load node: %v", err)
+	}
+	if node.Enable {
+		t.Fatalf("disabled node re-enabled after migration")
+	}
+}

+ 173 - 173
frontend/package-lock.json

@@ -1,28 +1,28 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.2.5",
+  "version": "0.2.7",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.2.5",
+      "version": "0.2.7",
       "dependencies": {
         "@ant-design/icons": "^6.2.5",
         "@codemirror/lang-json": "^6.0.2",
         "@codemirror/theme-one-dark": "^6.1.3",
-        "@tanstack/react-query": "^5.100.14",
-        "@tanstack/react-query-devtools": "^5.100.14",
+        "@tanstack/react-query": "^5.101.0",
+        "@tanstack/react-query-devtools": "^5.101.0",
         "antd": "^6.4.3",
-        "axios": "^1.16.1",
+        "axios": "^1.17.0",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
         "i18next": "^26.3.0",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
         "qs": "^6.15.2",
-        "react": "^19.2.6",
-        "react-dom": "^19.2.6",
+        "react": "^19.2.7",
+        "react-dom": "^19.2.7",
         "react-i18next": "^17.0.8",
         "react-router-dom": "^7.16.0",
         "recharts": "^3.8.1",
@@ -33,18 +33,18 @@
         "@eslint/js": "^10.0.1",
         "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.2",
-        "@types/react": "^19.2.15",
+        "@types/react": "^19.2.16",
         "@types/react-dom": "^19.2.3",
         "@types/swagger-ui-react": "^5.18.0",
         "@vitejs/plugin-react": "^6.0.2",
-        "eslint": "^10.4.0",
+        "eslint": "^10.4.1",
         "eslint-plugin-react-hooks": "^7.1.1",
         "globals": "^17.6.0",
         "jsdom": "^29.1.1",
         "typescript": "^6.0.3",
-        "typescript-eslint": "^8.60.0",
-        "vite": "8.0.14",
-        "vitest": "^4.1.7"
+        "typescript-eslint": "^8.60.1",
+        "vite": "8.0.16",
+        "vitest": "^4.1.8"
       },
       "engines": {
         "node": ">=22.0.0",
@@ -1093,9 +1093,9 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.132.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
-      "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
+      "version": "0.133.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
+      "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -1842,9 +1842,9 @@
       }
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
-      "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
+      "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
       "cpu": [
         "arm64"
       ],
@@ -1859,9 +1859,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
-      "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
+      "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
       "cpu": [
         "arm64"
       ],
@@ -1876,9 +1876,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
-      "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
+      "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
       "cpu": [
         "x64"
       ],
@@ -1893,9 +1893,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
-      "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
+      "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
       "cpu": [
         "x64"
       ],
@@ -1910,9 +1910,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
-      "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
+      "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
       "cpu": [
         "arm"
       ],
@@ -1927,9 +1927,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
-      "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
+      "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
       "cpu": [
         "arm64"
       ],
@@ -1947,9 +1947,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
-      "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
+      "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
       "cpu": [
         "arm64"
       ],
@@ -1967,9 +1967,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
-      "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
+      "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
       "cpu": [
         "ppc64"
       ],
@@ -1987,9 +1987,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
-      "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
+      "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
       "cpu": [
         "s390x"
       ],
@@ -2007,9 +2007,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
-      "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
+      "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
       "cpu": [
         "x64"
       ],
@@ -2027,9 +2027,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
-      "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
+      "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
       "cpu": [
         "x64"
       ],
@@ -2047,9 +2047,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
-      "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
+      "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
       "cpu": [
         "arm64"
       ],
@@ -2064,9 +2064,9 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
-      "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
+      "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
       "cpu": [
         "wasm32"
       ],
@@ -2083,9 +2083,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
-      "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
+      "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
       "cpu": [
         "arm64"
       ],
@@ -2100,9 +2100,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
-      "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
+      "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
       "cpu": [
         "x64"
       ],
@@ -2802,9 +2802,9 @@
       }
     },
     "node_modules/@tanstack/query-core": {
-      "version": "5.100.14",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
-      "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
+      "version": "5.101.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
+      "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -2812,9 +2812,9 @@
       }
     },
     "node_modules/@tanstack/query-devtools": {
-      "version": "5.100.14",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.14.tgz",
-      "integrity": "sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==",
+      "version": "5.101.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.0.tgz",
+      "integrity": "sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -2822,12 +2822,12 @@
       }
     },
     "node_modules/@tanstack/react-query": {
-      "version": "5.100.14",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
-      "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
+      "version": "5.101.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
+      "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
       "license": "MIT",
       "dependencies": {
-        "@tanstack/query-core": "5.100.14"
+        "@tanstack/query-core": "5.101.0"
       },
       "funding": {
         "type": "github",
@@ -2838,19 +2838,19 @@
       }
     },
     "node_modules/@tanstack/react-query-devtools": {
-      "version": "5.100.14",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.14.tgz",
-      "integrity": "sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==",
+      "version": "5.101.0",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.0.tgz",
+      "integrity": "sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==",
       "license": "MIT",
       "dependencies": {
-        "@tanstack/query-devtools": "5.100.14"
+        "@tanstack/query-devtools": "5.101.0"
       },
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
       },
       "peerDependencies": {
-        "@tanstack/react-query": "^5.100.14",
+        "@tanstack/react-query": "^5.101.0",
         "react": "^18 || ^19"
       }
     },
@@ -3047,9 +3047,9 @@
       }
     },
     "node_modules/@types/react": {
-      "version": "19.2.15",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
-      "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
+      "version": "19.2.16",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
+      "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
       "devOptional": true,
       "license": "MIT",
       "dependencies": {
@@ -3096,17 +3096,17 @@
       "license": "MIT"
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
-      "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
+      "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/regexpp": "^4.12.2",
-        "@typescript-eslint/scope-manager": "8.60.0",
-        "@typescript-eslint/type-utils": "8.60.0",
-        "@typescript-eslint/utils": "8.60.0",
-        "@typescript-eslint/visitor-keys": "8.60.0",
+        "@typescript-eslint/scope-manager": "8.60.1",
+        "@typescript-eslint/type-utils": "8.60.1",
+        "@typescript-eslint/utils": "8.60.1",
+        "@typescript-eslint/visitor-keys": "8.60.1",
         "ignore": "^7.0.5",
         "natural-compare": "^1.4.0",
         "ts-api-utils": "^2.5.0"
@@ -3119,7 +3119,7 @@
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^8.60.0",
+        "@typescript-eslint/parser": "^8.60.1",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "typescript": ">=4.8.4 <6.1.0"
       }
@@ -3135,16 +3135,16 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
-      "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
+      "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.60.0",
-        "@typescript-eslint/types": "8.60.0",
-        "@typescript-eslint/typescript-estree": "8.60.0",
-        "@typescript-eslint/visitor-keys": "8.60.0",
+        "@typescript-eslint/scope-manager": "8.60.1",
+        "@typescript-eslint/types": "8.60.1",
+        "@typescript-eslint/typescript-estree": "8.60.1",
+        "@typescript-eslint/visitor-keys": "8.60.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3160,14 +3160,14 @@
       }
     },
     "node_modules/@typescript-eslint/project-service": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
-      "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
+      "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/tsconfig-utils": "^8.60.0",
-        "@typescript-eslint/types": "^8.60.0",
+        "@typescript-eslint/tsconfig-utils": "^8.60.1",
+        "@typescript-eslint/types": "^8.60.1",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3182,14 +3182,14 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
-      "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
+      "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.60.0",
-        "@typescript-eslint/visitor-keys": "8.60.0"
+        "@typescript-eslint/types": "8.60.1",
+        "@typescript-eslint/visitor-keys": "8.60.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3200,9 +3200,9 @@
       }
     },
     "node_modules/@typescript-eslint/tsconfig-utils": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
-      "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
+      "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3217,15 +3217,15 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
-      "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
+      "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.60.0",
-        "@typescript-eslint/typescript-estree": "8.60.0",
-        "@typescript-eslint/utils": "8.60.0",
+        "@typescript-eslint/types": "8.60.1",
+        "@typescript-eslint/typescript-estree": "8.60.1",
+        "@typescript-eslint/utils": "8.60.1",
         "debug": "^4.4.3",
         "ts-api-utils": "^2.5.0"
       },
@@ -3242,9 +3242,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
-      "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
+      "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3256,16 +3256,16 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
-      "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
+      "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/project-service": "8.60.0",
-        "@typescript-eslint/tsconfig-utils": "8.60.0",
-        "@typescript-eslint/types": "8.60.0",
-        "@typescript-eslint/visitor-keys": "8.60.0",
+        "@typescript-eslint/project-service": "8.60.1",
+        "@typescript-eslint/tsconfig-utils": "8.60.1",
+        "@typescript-eslint/types": "8.60.1",
+        "@typescript-eslint/visitor-keys": "8.60.1",
         "debug": "^4.4.3",
         "minimatch": "^10.2.2",
         "semver": "^7.7.3",
@@ -3297,16 +3297,16 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
-      "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
+      "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.9.1",
-        "@typescript-eslint/scope-manager": "8.60.0",
-        "@typescript-eslint/types": "8.60.0",
-        "@typescript-eslint/typescript-estree": "8.60.0"
+        "@typescript-eslint/scope-manager": "8.60.1",
+        "@typescript-eslint/types": "8.60.1",
+        "@typescript-eslint/typescript-estree": "8.60.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3321,13 +3321,13 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
-      "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
+      "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.60.0",
+        "@typescript-eslint/types": "8.60.1",
         "eslint-visitor-keys": "^5.0.0"
       },
       "engines": {
@@ -3678,9 +3678,9 @@
       }
     },
     "node_modules/axios": {
-      "version": "1.16.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
-      "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
+      "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
       "license": "MIT",
       "dependencies": {
         "follow-redirects": "^1.16.0",
@@ -6379,9 +6379,9 @@
       }
     },
     "node_modules/react": {
-      "version": "19.2.6",
-      "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
-      "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -6414,15 +6414,15 @@
       }
     },
     "node_modules/react-dom": {
-      "version": "19.2.6",
-      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
-      "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
       "license": "MIT",
       "dependencies": {
         "scheduler": "^0.27.0"
       },
       "peerDependencies": {
-        "react": "^19.2.6"
+        "react": "^19.2.7"
       }
     },
     "node_modules/react-i18next": {
@@ -6708,13 +6708,13 @@
       }
     },
     "node_modules/rolldown": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
-      "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
+      "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@oxc-project/types": "=0.132.0",
+        "@oxc-project/types": "=0.133.0",
         "@rolldown/pluginutils": "^1.0.0"
       },
       "bin": {
@@ -6724,21 +6724,21 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.2",
-        "@rolldown/binding-darwin-arm64": "1.0.2",
-        "@rolldown/binding-darwin-x64": "1.0.2",
-        "@rolldown/binding-freebsd-x64": "1.0.2",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.2",
-        "@rolldown/binding-linux-arm64-musl": "1.0.2",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.2",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.2",
-        "@rolldown/binding-linux-x64-gnu": "1.0.2",
-        "@rolldown/binding-linux-x64-musl": "1.0.2",
-        "@rolldown/binding-openharmony-arm64": "1.0.2",
-        "@rolldown/binding-wasm32-wasi": "1.0.2",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.2",
-        "@rolldown/binding-win32-x64-msvc": "1.0.2"
+        "@rolldown/binding-android-arm64": "1.0.3",
+        "@rolldown/binding-darwin-arm64": "1.0.3",
+        "@rolldown/binding-darwin-x64": "1.0.3",
+        "@rolldown/binding-freebsd-x64": "1.0.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.3",
+        "@rolldown/binding-linux-arm64-musl": "1.0.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.3",
+        "@rolldown/binding-linux-x64-gnu": "1.0.3",
+        "@rolldown/binding-linux-x64-musl": "1.0.3",
+        "@rolldown/binding-openharmony-arm64": "1.0.3",
+        "@rolldown/binding-wasm32-wasi": "1.0.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.3",
+        "@rolldown/binding-win32-x64-msvc": "1.0.3"
       }
     },
     "node_modules/safe-buffer": {
@@ -7360,16 +7360,16 @@
       }
     },
     "node_modules/typescript-eslint": {
-      "version": "8.60.0",
-      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
-      "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
+      "version": "8.60.1",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
+      "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/eslint-plugin": "8.60.0",
-        "@typescript-eslint/parser": "8.60.0",
-        "@typescript-eslint/typescript-estree": "8.60.0",
-        "@typescript-eslint/utils": "8.60.0"
+        "@typescript-eslint/eslint-plugin": "8.60.1",
+        "@typescript-eslint/parser": "8.60.1",
+        "@typescript-eslint/typescript-estree": "8.60.1",
+        "@typescript-eslint/utils": "8.60.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7482,17 +7482,17 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.0.14",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
-      "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
+      "version": "8.0.16",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
+      "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
         "postcss": "^8.5.15",
-        "rolldown": "1.0.2",
-        "tinyglobby": "^0.2.16"
+        "rolldown": "1.0.3",
+        "tinyglobby": "^0.2.17"
       },
       "bin": {
         "vite": "bin/vite.js"

+ 11 - 11
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.2.5",
+  "version": "0.2.7",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -23,18 +23,18 @@
     "@ant-design/icons": "^6.2.5",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "@tanstack/react-query": "^5.100.14",
-    "@tanstack/react-query-devtools": "^5.100.14",
+    "@tanstack/react-query": "^5.101.0",
+    "@tanstack/react-query-devtools": "^5.101.0",
     "antd": "^6.4.3",
-    "axios": "^1.16.1",
+    "axios": "^1.17.0",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
     "i18next": "^26.3.0",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.2",
-    "react": "^19.2.6",
-    "react-dom": "^19.2.6",
+    "react": "^19.2.7",
+    "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",
     "react-router-dom": "^7.16.0",
     "recharts": "^3.8.1",
@@ -45,18 +45,18 @@
     "@eslint/js": "^10.0.1",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
-    "@types/react": "^19.2.15",
+    "@types/react": "^19.2.16",
     "@types/react-dom": "^19.2.3",
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.0.2",
-    "eslint": "^10.4.0",
+    "eslint": "^10.4.1",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
     "jsdom": "^29.1.1",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.60.0",
-    "vite": "8.0.14",
-    "vitest": "^4.1.7"
+    "typescript-eslint": "^8.60.1",
+    "vite": "8.0.16",
+    "vitest": "^4.1.8"
   },
   "overrides": {
     "react-copy-to-clipboard": "^5.1.1",

+ 25 - 0
frontend/src/components/viz/Sparkline.css

@@ -32,3 +32,28 @@
   gap: 4px;
   white-space: nowrap;
 }
+
+.sparkline-legend {
+  position: absolute;
+  top: 2px;
+  right: 8px;
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  padding: 2px 8px;
+  background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 16px;
+  pointer-events: none;
+  z-index: 1;
+}
+
+.sparkline-legend .extrema-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  white-space: nowrap;
+}

+ 85 - 6
frontend/src/components/viz/Sparkline.tsx

@@ -31,6 +31,13 @@ const DEFAULT_MAX_COLOR = '#fa541c';
 
 interface SparklineProps {
   data: number[];
+  data2?: number[];
+  data3?: number[];
+  stroke2?: string;
+  stroke3?: string;
+  name1?: string;
+  name2?: string;
+  name3?: string;
   labels?: (string | number)[];
   height?: number;
   stroke?: string;
@@ -56,11 +63,20 @@ interface SparklineProps {
 interface ChartPoint {
   index: number;
   value: number;
+  value2: number;
+  value3: number;
   label: string;
 }
 
 export default function Sparkline({
   data,
+  data2 = [],
+  data3 = [],
+  stroke2 = '#722ed1',
+  stroke3 = '#a0d911',
+  name1,
+  name2,
+  name3,
   labels = [],
   height = 80,
   stroke = '#008771',
@@ -85,28 +101,39 @@ export default function Sparkline({
   const reactId = useId();
   const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
   const gradId = `spkGrad-${safeId}`;
+  const gradId2 = `spkGrad2-${safeId}`;
+  const gradId3 = `spkGrad3-${safeId}`;
+  const hasSeries2 = data2.length > 0;
+  const hasSeries3 = data3.length > 0;
+  const multiSeries = hasSeries2 || hasSeries3;
 
   const points = useMemo<ChartPoint[]>(() => {
     const n = Math.min(data.length, maxPoints);
     if (n === 0) return [];
     const sliceStart = data.length - n;
     const labelStart = Math.max(0, labels.length - n);
+    const slice2Start = data2.length - n;
+    const slice3Start = data3.length - n;
     return data.slice(sliceStart).map((value, i) => ({
       index: i,
       value: Number(value) || 0,
+      value2: data2.length ? Number(data2[slice2Start + i]) || 0 : 0,
+      value3: data3.length ? Number(data3[slice3Start + i]) || 0 : 0,
       label: String(labels[labelStart + i] ?? i + 1),
     }));
-  }, [data, labels, maxPoints]);
+  }, [data, data2, data3, labels, maxPoints]);
 
   const yDomain = useMemo<[number, number]>(() => {
     if (valueMax != null) return [valueMin, valueMax];
     let max = valueMin;
     for (const p of points) {
       if (Number.isFinite(p.value) && p.value > max) max = p.value;
+      if (hasSeries2 && Number.isFinite(p.value2) && p.value2 > max) max = p.value2;
+      if (hasSeries3 && Number.isFinite(p.value3) && p.value3 > max) max = p.value3;
     }
     if (max <= valueMin) max = valueMin + 1;
     return [valueMin, max * 1.1];
-  }, [points, valueMin, valueMax]);
+  }, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
 
   const yTicks = useMemo(() => {
     if (!showAxes) return undefined;
@@ -129,7 +156,7 @@ export default function Sparkline({
   const fmtTooltip = tooltipFormatter ?? yFormatter;
 
   const extremaPoints = useMemo(() => {
-    if (!extrema?.show || points.length < 2) return null;
+    if (!extrema?.show || multiSeries || points.length < 2) return null;
     let minIdx = 0;
     let maxIdx = 0;
     for (let i = 1; i < points.length; i++) {
@@ -138,7 +165,17 @@ export default function Sparkline({
     }
     if (minIdx === maxIdx) return null;
     return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
-  }, [points, extrema?.show]);
+  }, [points, extrema?.show, multiSeries]);
+
+  const legendItems = useMemo(
+    () =>
+      [
+        { name: name1, color: stroke },
+        { name: name2, color: stroke2 },
+        { name: name3, color: stroke3 },
+      ].filter((s, i) => s.name && (i === 0 ? multiSeries : i === 1 ? hasSeries2 : hasSeries3)),
+    [name1, name2, name3, stroke, stroke2, stroke3, multiSeries, hasSeries2, hasSeries3],
+  );
 
   const fmtExtrema = extrema?.formatter ?? yFormatter;
   const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
@@ -156,6 +193,13 @@ export default function Sparkline({
           </span>
         </div>
       )}
+      {legendItems.length > 0 && (
+        <div className="sparkline-legend" aria-hidden="true">
+          {legendItems.map((s) => (
+            <span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
+          ))}
+        </div>
+      )}
       <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
         <AreaChart
           data={points}
@@ -171,6 +215,14 @@ export default function Sparkline({
               <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
               <stop offset="100%" stopColor={stroke} stopOpacity={0} />
             </linearGradient>
+            <linearGradient id={gradId2} x1="0" y1="0" x2="0" y2="1">
+              <stop offset="0%" stopColor={stroke2} stopOpacity={fillOpacity} />
+              <stop offset="100%" stopColor={stroke2} stopOpacity={0} />
+            </linearGradient>
+            <linearGradient id={gradId3} x1="0" y1="0" x2="0" y2="1">
+              <stop offset="0%" stopColor={stroke3} stopOpacity={fillOpacity} />
+              <stop offset="100%" stopColor={stroke3} stopOpacity={0} />
+            </linearGradient>
           </defs>
           {showGrid && (
             <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
@@ -209,9 +261,9 @@ export default function Sparkline({
               }}
               labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
               itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
-              formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
+              formatter={(v, name) => [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']}
               labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
-              separator=""
+              separator={multiSeries ? ': ' : ''}
             />
           )}
           {referenceLines?.map((rl, idx) => (
@@ -256,6 +308,7 @@ export default function Sparkline({
           <Area
             type="monotone"
             dataKey="value"
+            name={multiSeries ? name1 : undefined}
             stroke={stroke}
             strokeWidth={strokeWidth}
             fill={`url(#${gradId})`}
@@ -263,6 +316,32 @@ export default function Sparkline({
             activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
             isAnimationActive={false}
           />
+          {hasSeries2 && (
+            <Area
+              type="monotone"
+              dataKey="value2"
+              name={name2}
+              stroke={stroke2}
+              strokeWidth={strokeWidth}
+              fill={`url(#${gradId2})`}
+              dot={false}
+              activeDot={showMarker ? { r: markerRadius, fill: stroke2, strokeWidth: 0 } : false}
+              isAnimationActive={false}
+            />
+          )}
+          {hasSeries3 && (
+            <Area
+              type="monotone"
+              dataKey="value3"
+              name={name3}
+              stroke={stroke3}
+              strokeWidth={strokeWidth}
+              fill={`url(#${gradId3})`}
+              dot={false}
+              activeDot={showMarker ? { r: markerRadius, fill: stroke3, strokeWidth: 0 } : false}
+              isAnimationActive={false}
+            />
+          )}
         </AreaChart>
       </ResponsiveContainer>
     </div>

+ 23 - 0
frontend/src/layouts/AppSidebar.css

@@ -5,6 +5,20 @@
   align-self: flex-start;
 }
 
+.ant-sidebar.is-rail {
+  flex: 0 0 80px;
+  width: 80px;
+  overflow: visible;
+}
+
+.ant-sidebar.is-rail > .ant-layout-sider {
+  z-index: 100;
+}
+
+.ant-sidebar.is-rail:hover > .ant-layout-sider {
+  box-shadow: 2px 0 16px rgba(0, 0, 0, 0.18);
+}
+
 .sider-brand,
 .drawer-brand {
   font-weight: 600;
@@ -245,6 +259,15 @@
     min-width: 0 !important;
     width: 0 !important;
   }
+
+  .ant-sidebar,
+  .ant-sidebar.is-rail {
+    flex: 0 0 0 !important;
+    width: 0 !important;
+    min-width: 0 !important;
+    max-width: 0 !important;
+    overflow: hidden !important;
+  }
 }
 
 body.dark .ant-drawer-content,

+ 75 - 36
frontend/src/layouts/AppSidebar.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { ComponentType } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
@@ -6,28 +6,35 @@ import { Drawer, Layout, Menu } from 'antd';
 import type { MenuProps } from 'antd';
 import {
   ApiOutlined,
-  ClusterOutlined,
   CloseOutlined,
+  CloudServerOutlined,
+  ClusterOutlined,
+  CodeOutlined,
   DashboardOutlined,
+  DatabaseOutlined,
   GithubOutlined,
   HeartOutlined,
   ImportOutlined,
   LogoutOutlined,
   MenuOutlined,
+  MessageOutlined,
   MoonFilled,
   MoonOutlined,
+  SafetyOutlined,
   SettingOutlined,
   SunOutlined,
+  SwapOutlined,
   TagsOutlined,
   TeamOutlined,
   ToolOutlined,
+  UploadOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import { useAllSettings } from '@/api/queries/useAllSettings';
 import './AppSidebar.css';
 
-const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
@@ -46,14 +53,6 @@ const iconByName: Record<IconName, ComponentType> = {
   apidocs: ApiOutlined,
 };
 
-function readCollapsed(): boolean {
-  try {
-    return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
-  } catch {
-    return false;
-  }
-}
-
 function DonateButton({ ariaLabel }: { ariaLabel: string }) {
   return (
     <a
@@ -113,10 +112,13 @@ export default function AppSidebar() {
   const { t } = useTranslation();
   const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
   const navigate = useNavigate();
-  const { pathname } = useLocation();
+  const { pathname, hash } = useLocation();
+  const { allSetting } = useAllSettings();
+  const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
 
-  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
+  const [hovered, setHovered] = useState(false);
   const [drawerOpen, setDrawerOpen] = useState(false);
+  const collapsedView = !hovered;
 
   const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
   const panelVersion = window.X_UI_CUR_VER || '';
@@ -136,18 +138,56 @@ export default function AppSidebar() {
   const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
   const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
 
-  const selectedKey = pathname === '' ? '/' : pathname;
+  const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
+    const children: NonNullable<MenuProps['items']> = [
+      { key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
+      { key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
+      { key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
+      { key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
+    ];
+    if (showSubFormats) {
+      children.push({ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: 'Sub Formats' });
+    }
+    return children;
+  }, [t, showSubFormats]);
+
+  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') },
+  ], [t]);
+
+  const settingsActive = pathname === '/settings';
+  const xrayActive = pathname === '/xray';
+  const selectedKey = settingsActive
+    ? `/settings${hash || '#general'}`
+    : xrayActive
+      ? `/xray${hash || '#basic'}`
+      : (pathname === '' ? '/' : pathname);
+
+  const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
+  const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
+  useEffect(() => {
+    if (openSubmenu) {
+      setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu]));
+    }
+  }, [openSubmenu]);
 
   const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
     items.map((tab) => {
       const Icon = iconByName[tab.icon];
-      return {
-        key: tab.key,
-        icon: <Icon />,
-        label: tab.title,
-      };
+      if (tab.key === '/settings') {
+        return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
+      }
+      if (tab.key === '/xray') {
+        return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
+      }
+      return { key: tab.key, icon: <Icon />, label: tab.title };
     }),
-  []);
+  [settingsChildren, xrayChildren]);
 
   const openLink = useCallback(async (key: string) => {
     if (key === LOGOUT_KEY) {
@@ -162,13 +202,6 @@ export default function AppSidebar() {
     openLink(String(key));
   }, [openLink]);
 
-  const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
-    if (type === 'clickTrigger') {
-      localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
-      setCollapsed(isCollapsed);
-    }
-  }, []);
-
   const cycleTheme = useCallback((id: string) => {
     pauseAnimationsUntilLeave(id);
     if (!isDark) {
@@ -183,19 +216,21 @@ export default function AppSidebar() {
   }, [isDark, isUltra, toggleTheme, toggleUltra]);
 
   return (
-    <div className="ant-sidebar">
+    <div
+      className="ant-sidebar is-rail"
+      onMouseEnter={() => setHovered(true)}
+      onMouseLeave={() => setHovered(false)}
+    >
       <Layout.Sider
         theme={currentTheme}
-        collapsible
-        collapsed={collapsed}
-        breakpoint="md"
-        onCollapse={onSiderCollapse}
+        collapsed={collapsedView}
+        trigger={null}
       >
-        <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
+        <div className={`sider-brand${collapsedView ? ' sider-brand-collapsed' : ''}`}>
           <div className="brand-block">
-            <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
+            <span className="brand-text">{collapsedView ? '3X' : '3X-UI'}</span>
           </div>
-          {!collapsed && (
+          {!collapsedView && (
             <div className="brand-actions">
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
               <ThemeCycleButton
@@ -212,6 +247,8 @@ export default function AppSidebar() {
           theme={currentTheme}
           mode="inline"
           selectedKeys={[selectedKey]}
+          openKeys={collapsedView ? undefined : openKeys}
+          onOpenChange={(keys) => setOpenKeys(keys as string[])}
           className="sider-nav"
           items={toMenuItems(navItems)}
           onClick={onMenuClick}
@@ -225,7 +262,7 @@ export default function AppSidebar() {
           onClick={onMenuClick}
         />
         <div className="sider-footer">
-          <VersionBadge version={panelVersion} collapsed={collapsed} />
+          <VersionBadge version={panelVersion} collapsed={collapsedView} />
         </div>
       </Layout.Sider>
 
@@ -269,6 +306,8 @@ export default function AppSidebar() {
           theme={currentTheme}
           mode="inline"
           selectedKeys={[selectedKey]}
+          openKeys={openKeys}
+          onOpenChange={(keys) => setOpenKeys(keys as string[])}
           className="drawer-menu drawer-nav"
           items={toMenuItems(navItems)}
           onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}

+ 1 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -162,7 +162,7 @@ export function createDefaultShadowsocksInboundSettings(
   return {
     method,
     password: seed.password ?? RandomUtil.randomShadowsocksPassword(method),
-    network: seed.network ?? 'tcp',
+    network: seed.network ?? 'tcp,udp',
     clients: [],
     ivCheck: seed.ivCheck ?? false,
   };

+ 19 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -119,6 +119,11 @@ function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
   return '';
 }
 
+function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string {
+  if (Array.isArray(value)) return value.filter(Boolean).join(',');
+  return '';
+}
+
 function applyExternalProxyTLSObj(
   externalProxy: ExternalProxyEntry | null | undefined,
   obj: Record<string, unknown>,
@@ -130,6 +135,8 @@ function applyExternalProxyTLSObj(
   if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
   const alpn = externalProxyAlpn(externalProxy.alpn);
   if (alpn.length > 0) obj.alpn = alpn;
+  const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
+  if (pins.length > 0) obj.pcs = pins;
 }
 
 export interface GenVmessLinkInput {
@@ -270,6 +277,8 @@ function applyExternalProxyTLSParams(
   if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
   const alpn = externalProxyAlpn(externalProxy.alpn);
   if (alpn.length > 0) params.set('alpn', alpn);
+  const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
+  if (pins.length > 0) params.set('pcs', pins);
 }
 
 export interface GenVlessLinkInput {
@@ -576,6 +585,7 @@ export interface GenHysteriaLinkInput {
   port?: number;
   remark?: string;
   clientAuth: string;
+  externalProxy?: ExternalProxyEntry | null;
 }
 
 // Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core
@@ -616,6 +626,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     port = inbound.port,
     remark = '',
     clientAuth,
+    externalProxy = null,
   } = input;
 
   if (inbound.protocol !== 'hysteria') return '';
@@ -635,6 +646,13 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
   if (tls.settings.pinnedPeerCertSha256.length > 0) {
     params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
   }
+  // An external-proxy entry can pin a different endpoint's certificate.
+  // Hysteria carries it as hex `pinSHA256` (not the `pcs` other protocols
+  // use), so coerce each entry through hysteriaPinHex like the main pin.
+  if (Array.isArray(externalProxy?.pinnedPeerCertSha256)) {
+    const epPins = externalProxy.pinnedPeerCertSha256.filter(Boolean).map(hysteriaPinHex);
+    if (epPins.length > 0) params.set('pinSHA256', epPins.join(','));
+  }
 
   const udpMasks = stream.finalmask?.udp;
   if (Array.isArray(udpMasks)) {
@@ -844,6 +862,7 @@ export function genLink(input: GenLinkInput): string {
       return genHysteriaLink({
         inbound, address, port, remark,
         clientAuth: client.auth ?? '',
+        externalProxy,
       });
     default:
       return '';

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

@@ -445,7 +445,7 @@ export default function ClientsPage() {
   }
 
   function onResetTraffic(row: ClientRecord) {
-    if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
+    if (!row?.email) {
       messageApi.warning(t('pages.clients.resetNotPossible'));
       return;
     }

+ 1 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -207,6 +207,7 @@ export default function InboundFormModal({
         sni: '',
         fingerprint: '',
         alpn: [],
+        pinnedPeerCertSha256: [],
       }]);
     } else {
       form.setFieldValue(['streamSettings', 'externalProxy'], []);

+ 72 - 0
frontend/src/pages/inbounds/form/transport/external-proxy.css

@@ -0,0 +1,72 @@
+.ext-proxy-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.ext-proxy-card {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 12px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 10px;
+  background: var(--ant-color-fill-quaternary);
+}
+
+.ext-proxy-card__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.ext-proxy-card__title {
+  font-weight: 600;
+  font-size: 13px;
+  opacity: 0.85;
+}
+
+.ext-proxy-field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.ext-proxy-flabel {
+  font-size: 12px;
+  line-height: 1.2;
+  opacity: 0.65;
+}
+
+.ext-proxy-grid {
+  display: grid;
+  gap: 8px;
+}
+
+.ext-proxy-grid--dest {
+  grid-template-columns: 1fr 1.7fr 0.9fr;
+}
+
+.ext-proxy-grid--tls {
+  grid-template-columns: 1fr 1fr 1fr;
+}
+
+.ext-proxy-tls {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-top: 2px;
+  padding-top: 10px;
+  border-top: 1px dashed var(--ant-color-border-secondary);
+}
+
+.ext-proxy-add {
+  margin-top: 10px;
+}
+
+@media (max-width: 575px) {
+  .ext-proxy-grid--dest,
+  .ext-proxy-grid--tls {
+    grid-template-columns: 1fr;
+  }
+}

+ 164 - 97
frontend/src/pages/inbounds/form/transport/external-proxy.tsx

@@ -1,16 +1,49 @@
+import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
-import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 
-import { InputAddon } from '@/components/ui';
 import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
 
+import './external-proxy.css';
+
+const newEntry = () => ({
+  forceTls: 'same',
+  dest: '',
+  port: 443,
+  remark: '',
+  sni: '',
+  fingerprint: '',
+  alpn: [],
+  pinnedPeerCertSha256: [],
+});
+
+function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
+  return (
+    <div className="ext-proxy-field">
+      <span className="ext-proxy-flabel">{label}</span>
+      {children}
+    </div>
+  );
+}
+
 export default function ExternalProxyForm({
   toggleExternalProxy,
 }: {
   toggleExternalProxy: (on: boolean) => void;
 }) {
   const { t } = useTranslation();
+  const form = Form.useFormInstance();
+
+  const generateRandomPin = (name: number) => {
+    const bytes = new Uint8Array(32);
+    crypto.getRandomValues(bytes);
+    const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+    const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
+    const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
+    form.setFieldValue(path, [...current, hash]);
+  };
+
   return (
     <Form.Item
       noStyle
@@ -29,104 +62,138 @@ export default function ExternalProxyForm({
               <Switch checked={on} onChange={toggleExternalProxy} />
             </Form.Item>
             {on && (
-              <Form.List name={['streamSettings', 'externalProxy']}>
-                {(fields, { add, remove }) => (
-                  <>
-                    <Form.Item label=" " colon={false}>
+              <Form.Item wrapperCol={{ span: 24 }}>
+                <Form.List name={['streamSettings', 'externalProxy']}>
+                  {(fields, { add, remove }) => (
+                    <>
+                      <div className="ext-proxy-list">
+                        {fields.map((field, idx) => (
+                          <div key={field.key} className="ext-proxy-card">
+                            <div className="ext-proxy-card__head">
+                              <span className="ext-proxy-card__title">#{idx + 1}</span>
+                              <Button
+                                size="small"
+                                type="text"
+                                danger
+                                icon={<DeleteOutlined />}
+                                onClick={() => remove(field.name)}
+                              />
+                            </div>
+                            <div className="ext-proxy-grid ext-proxy-grid--dest">
+                              <Field label={t('pages.inbounds.form.forceTls')}>
+                                <Form.Item name={[field.name, 'forceTls']} noStyle>
+                                  <Select
+                                    style={{ width: '100%' }}
+                                    options={[
+                                      { value: 'same', label: t('pages.inbounds.same') },
+                                      { value: 'none', label: t('none') },
+                                      { value: 'tls', label: 'TLS' },
+                                    ]}
+                                  />
+                                </Form.Item>
+                              </Field>
+                              <Field label={t('host')}>
+                                <Form.Item name={[field.name, 'dest']} noStyle>
+                                  <Input placeholder={t('host')} />
+                                </Form.Item>
+                              </Field>
+                              <Field label={t('pages.inbounds.port')}>
+                                <Form.Item name={[field.name, 'port']} noStyle>
+                                  <InputNumber style={{ width: '100%' }} min={1} max={65535} />
+                                </Form.Item>
+                              </Field>
+                            </div>
+                            <Field label={t('pages.inbounds.remark')}>
+                              <Form.Item name={[field.name, 'remark']} noStyle>
+                                <Input placeholder={t('pages.inbounds.remark')} />
+                              </Form.Item>
+                            </Field>
+                            <Form.Item
+                              noStyle
+                              shouldUpdate={(prev, curr) =>
+                                prev.streamSettings?.externalProxy?.[field.name]?.forceTls
+                                !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
+                              }
+                            >
+                              {({ getFieldValue }) => {
+                                const ft = getFieldValue([
+                                  'streamSettings', 'externalProxy', field.name, 'forceTls',
+                                ]);
+                                if (ft !== 'tls') return null;
+                                return (
+                                  <div className="ext-proxy-tls">
+                                    <div className="ext-proxy-grid ext-proxy-grid--tls">
+                                      <Field label="SNI">
+                                        <Form.Item name={[field.name, 'sni']} noStyle>
+                                          <Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
+                                        </Form.Item>
+                                      </Field>
+                                      <Field label={t('pages.inbounds.form.fingerprint')}>
+                                        <Form.Item name={[field.name, 'fingerprint']} noStyle>
+                                          <Select
+                                            style={{ width: '100%' }}
+                                            placeholder={t('pages.inbounds.form.fingerprint')}
+                                            options={[
+                                              { value: '', label: t('pages.inbounds.form.defaultOption') },
+                                              ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
+                                                value: fp,
+                                                label: fp,
+                                              })),
+                                            ]}
+                                          />
+                                        </Form.Item>
+                                      </Field>
+                                      <Field label="ALPN">
+                                        <Form.Item name={[field.name, 'alpn']} noStyle>
+                                          <Select
+                                            mode="multiple"
+                                            style={{ width: '100%' }}
+                                            placeholder="ALPN"
+                                            options={Object.values(ALPN_OPTION).map((a) => ({
+                                              value: a,
+                                              label: a,
+                                            }))}
+                                          />
+                                        </Form.Item>
+                                      </Field>
+                                    </div>
+                                    <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
+                                      <Space.Compact block>
+                                        <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
+                                          <Select
+                                            mode="tags"
+                                            tokenSeparators={[',', ' ']}
+                                            placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
+                                            style={{ width: 'calc(100% - 32px)' }}
+                                          />
+                                        </Form.Item>
+                                        <Button
+                                          icon={<ReloadOutlined />}
+                                          onClick={() => generateRandomPin(field.name)}
+                                          title={t('pages.inbounds.form.generateRandomPin')}
+                                        />
+                                      </Space.Compact>
+                                    </Field>
+                                  </div>
+                                );
+                              }}
+                            </Form.Item>
+                          </div>
+                        ))}
+                      </div>
                       <Button
-                        size="small"
-                        type="primary"
-                        onClick={() => add({
-                          forceTls: 'same',
-                          dest: '',
-                          port: 443,
-                          remark: '',
-                          sni: '',
-                          fingerprint: '',
-                          alpn: [],
-                        })}
+                        className="ext-proxy-add"
+                        block
+                        type="dashed"
+                        icon={<PlusOutlined />}
+                        onClick={() => add(newEntry())}
                       >
-                        <PlusOutlined />
+                        {t('add')}
                       </Button>
-                    </Form.Item>
-                    <Form.Item wrapperCol={{ span: 24 }}>
-                      {fields.map((field) => (
-                        <div key={field.key} style={{ margin: '8px 0' }}>
-                          <Space.Compact block>
-                            <Form.Item name={[field.name, 'forceTls']} noStyle>
-                              <Select
-                                style={{ width: '20%' }}
-                                options={[
-                                  { value: 'same', label: t('pages.inbounds.same') },
-                                  { value: 'none', label: t('none') },
-                                  { value: 'tls', label: 'TLS' },
-                                ]}
-                              />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'dest']} noStyle>
-                              <Input style={{ width: '30%' }} placeholder={t('host')} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'port']} noStyle>
-                              <InputNumber style={{ width: '15%' }} min={1} max={65535} />
-                            </Form.Item>
-                            <Form.Item name={[field.name, 'remark']} noStyle>
-                              <Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
-                            </Form.Item>
-                            <InputAddon onClick={() => remove(field.name)}>
-                              <MinusOutlined />
-                            </InputAddon>
-                          </Space.Compact>
-                          <Form.Item
-                            noStyle
-                            shouldUpdate={(prev, curr) =>
-                              prev.streamSettings?.externalProxy?.[field.name]?.forceTls
-                              !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
-                            }
-                          >
-                            {({ getFieldValue }) => {
-                              const ft = getFieldValue([
-                                'streamSettings', 'externalProxy', field.name, 'forceTls',
-                              ]);
-                              if (ft !== 'tls') return null;
-                              return (
-                                <Space.Compact style={{ marginTop: 6 }} block>
-                                  <Form.Item name={[field.name, 'sni']} noStyle>
-                                    <Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
-                                  </Form.Item>
-                                  <Form.Item name={[field.name, 'fingerprint']} noStyle>
-                                    <Select
-                                      style={{ width: '30%' }}
-                                      placeholder={t('pages.inbounds.form.fingerprint')}
-                                      options={[
-                                        { value: '', label: t('pages.inbounds.form.defaultOption') },
-                                        ...Object.values(UTLS_FINGERPRINT).map((fp) => ({
-                                          value: fp,
-                                          label: fp,
-                                        })),
-                                      ]}
-                                    />
-                                  </Form.Item>
-                                  <Form.Item name={[field.name, 'alpn']} noStyle>
-                                    <Select
-                                      mode="multiple"
-                                      style={{ width: '40%' }}
-                                      placeholder="ALPN"
-                                      options={Object.values(ALPN_OPTION).map((a) => ({
-                                        value: a,
-                                        label: a,
-                                      }))}
-                                    />
-                                  </Form.Item>
-                                </Space.Compact>
-                              );
-                            }}
-                          </Form.Item>
-                        </div>
-                      ))}
-                    </Form.Item>
-                  </>
-                )}
-              </Form.List>
+                    </>
+                  )}
+                </Form.List>
+              </Form.Item>
             )}
           </>
         );

+ 7 - 0
frontend/src/pages/index/SystemHistoryModal.css

@@ -13,6 +13,13 @@
   margin-bottom: 4px;
 }
 
+.history-chart-title {
+  margin-bottom: 12px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+}
+
 .cpu-chart-wrap {
   margin: 8px 8px 16px;
   padding: 16px 18px 18px;

+ 80 - 11
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -1,6 +1,18 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Modal, Select, Tabs } from 'antd';
+import {
+  ApiOutlined,
+  DashboardOutlined,
+  DatabaseOutlined,
+  DeploymentUnitOutlined,
+  GlobalOutlined,
+  HddOutlined,
+  LineChartOutlined,
+  PieChartOutlined,
+  TeamOutlined,
+} from '@ant-design/icons';
 
 import { HttpUtil, SizeFormatter } from '@/utils';
 import { Sparkline } from '@/components/viz';
@@ -17,32 +29,48 @@ interface SystemHistoryModalProps {
 interface MetricDef {
   key: string;
   tab: string;
+  tabKey?: string;
+  title: string;
+  icon: ReactNode;
   valueMax: number | null;
   unit: string;
   stroke: string;
+  key2?: string;
+  stroke2?: string;
+  name1?: string;
+  name2?: string;
+  key3?: string;
+  stroke3?: string;
+  name3?: string;
 }
 
 const METRICS: MetricDef[] = [
-  { key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
-  { key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
-  { key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
-  { key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
-  { key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
-  { key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
-  { key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
-  { key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
+  { key: 'cpu', tab: 'CPU', tabKey: 'pages.index.cpu', title: 'pages.index.historyTitleCpu', icon: <DashboardOutlined />, valueMax: 100, unit: '%', stroke: '' },
+  { key: 'mem', tab: 'RAM', tabKey: 'pages.index.memory', title: 'pages.index.historyTitleMem', icon: <DatabaseOutlined />, valueMax: 100, unit: '%', stroke: '#7c4dff', key2: 'swap', stroke2: '#ffa940', name1: 'pages.index.memory', name2: 'pages.index.swap' },
+  { key: 'netUp', tab: 'Bandwidth', tabKey: 'pages.index.historyTabBandwidth', title: 'pages.index.historyTitleNetwork', icon: <GlobalOutlined />, valueMax: null, unit: 'B/s', stroke: '#1890ff', key2: 'netDown', stroke2: '#13c2c2', name1: 'Up', name2: 'Down' },
+  { key: 'pktUp', tab: 'Packets', tabKey: 'pages.index.historyTabPackets', title: 'pages.index.historyTitlePackets', icon: <DeploymentUnitOutlined />, valueMax: null, unit: 'pkt/s', stroke: '#2f54eb', key2: 'pktDown', stroke2: '#36cfc9', name1: 'Up', name2: 'Down' },
+  { key: 'tcpCount', tab: 'Connections', tabKey: 'pages.index.historyTabConnections', title: 'pages.index.historyTitleConnections', icon: <ApiOutlined />, valueMax: null, unit: '', stroke: '#597ef7', key2: 'udpCount', stroke2: '#73d13d', name1: 'TCP', name2: 'UDP' },
+  { key: 'diskRead', tab: 'Disk I/O', tabKey: 'pages.index.historyTabDisk', title: 'pages.index.historyTitleDisk', icon: <HddOutlined />, valueMax: null, unit: 'B/s', stroke: '#eb2f96', key2: 'diskWrite', stroke2: '#722ed1', name1: 'Read', name2: 'Write' },
+  { key: 'diskUsage', tab: 'Disk Usage', tabKey: 'pages.index.historyTabDiskUsage', title: 'pages.index.historyTitleDiskUsage', icon: <PieChartOutlined />, valueMax: 100, unit: '%', stroke: '#13c2c2' },
+  { key: 'online', tab: 'Online', tabKey: 'pages.index.historyTabOnline', title: 'pages.index.historyTitleOnline', icon: <TeamOutlined />, valueMax: null, unit: '', stroke: '#52c41a' },
+  { key: 'load1', tab: 'Load', tabKey: 'pages.index.historyTabLoad', title: 'pages.index.historyTitleLoad', icon: <LineChartOutlined />, valueMax: null, unit: '', stroke: '#fa8c16', key2: 'load5', stroke2: '#f5222d', name1: '1m', name2: '5m', key3: 'load15', stroke3: '#a0d911', name3: '15m' },
 ];
 
 function unitFormatter(unit: string, activeKey: string): (v: number) => string {
   if (unit === 'B/s') {
     return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
   }
+  if (unit === 'pkt/s') {
+    return (v) => `${Math.round(Math.max(0, Number(v) || 0)).toLocaleString()}/s`;
+  }
   if (unit === '%') {
     return (v) => `${Number(v).toFixed(1)}%`;
   }
   return (v) => {
     const n = Number(v) || 0;
-    if (activeKey === 'online') return String(Math.round(n));
+    if (activeKey === 'online' || activeKey === 'tcpCount' || activeKey === 'udpCount') {
+      return Math.round(n).toLocaleString();
+    }
     return n.toFixed(2);
   };
 }
@@ -69,10 +97,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
   const [activeKey, setActiveKey] = useState('cpu');
   const [bucket, setBucket] = useState(2);
   const [points, setPoints] = useState<number[]>([]);
+  const [points2, setPoints2] = useState<number[]>([]);
+  const [points3, setPoints3] = useState<number[]>([]);
   const [labels, setLabels] = useState<string[]>([]);
   const [timestamps, setTimestamps] = useState<number[]>([]);
 
   const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
+  const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
   const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
   const yFormatter = useMemo(
     () => unitFormatter(activeMetric?.unit ?? '', activeKey),
@@ -116,15 +147,32 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
         setLabels(labs);
         setPoints(vals);
         setTimestamps(tss);
+
+        const fetchAligned = async (key?: string): Promise<number[]> => {
+          if (!key) return [];
+          const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
+          if (m?.success && Array.isArray(m.obj)) {
+            const byTs = new Map<number, number>();
+            for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
+            return tss.map((ts) => byTs.get(ts) ?? 0);
+          }
+          return [];
+        };
+        setPoints2(await fetchAligned(activeMetric.key2));
+        setPoints3(await fetchAligned(activeMetric.key3));
       } else {
         setLabels([]);
         setPoints([]);
+        setPoints2([]);
+        setPoints3([]);
         setTimestamps([]);
       }
     } catch (e) {
       console.error('Failed to fetch history bucket', e);
       setLabels([]);
       setPoints([]);
+      setPoints2([]);
+      setPoints3([]);
       setTimestamps([]);
     }
   }, [activeMetric, bucket]);
@@ -137,6 +185,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
     if (open) fetchBucket();
   }, [open, activeKey, bucket, fetchBucket]);
 
+  useEffect(() => {
+    if (!open) return undefined;
+    const ms = bucket <= 30 ? 2000 : 10000;
+    const id = window.setInterval(() => fetchBucket(), ms);
+    return () => window.clearInterval(id);
+  }, [open, bucket, fetchBucket]);
+
   return (
     <Modal
       open={open}
@@ -168,12 +223,26 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
         onChange={setActiveKey}
         size="small"
         className="history-tabs"
-        items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
+        items={METRICS.map((m) => {
+          const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
+          return {
+            key: m.key,
+            label: isMobile ? <span title={tabLabel} aria-label={tabLabel}>{m.icon}</span> : tabLabel,
+          };
+        })}
       />
 
       <div className="cpu-chart-wrap">
+        {activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
         <Sparkline
           data={points}
+          data2={activeMetric?.key2 ? points2 : undefined}
+          data3={activeMetric?.key3 ? points3 : undefined}
+          stroke2={activeMetric?.stroke2}
+          stroke3={activeMetric?.stroke3}
+          name1={trName(activeMetric?.name1)}
+          name2={trName(activeMetric?.name2)}
+          name3={trName(activeMetric?.name3)}
           labels={labels}
           height={260}
           stroke={strokeColor}
@@ -189,7 +258,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
           valueMax={activeMetric?.valueMax ?? null}
           yFormatter={yFormatter}
           tooltipLabelFormatter={tooltipLabelFormatter}
-          extrema={{ show: true, formatter: yFormatter }}
+          extrema={{ show: !activeMetric?.key2, formatter: yFormatter }}
         />
       </div>
     </Modal>

+ 26 - 7
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -1,6 +1,15 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Tabs, Tag } from 'antd';
+import {
+  BlockOutlined,
+  CloudServerOutlined,
+  DatabaseOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+  PauseCircleOutlined,
+} from '@ant-design/icons';
 
 import { HttpUtil, Msg, SizeFormatter } from '@/utils';
 import { Sparkline } from '@/components/viz';
@@ -17,6 +26,9 @@ interface XrayMetricsModalProps {
 interface MetricDef {
   key: string;
   tab: string;
+  tabKey: string;
+  title: string;
+  icon: ReactNode;
   unit: 'B' | 'ns' | 'ms' | '';
   stroke: string;
 }
@@ -36,12 +48,12 @@ interface ObservatoryTag {
 }
 
 const METRICS: MetricDef[] = [
-  { key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
-  { key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
-  { key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
-  { key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
-  { key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
-  { key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
+  { key: 'xrAlloc', tab: 'Heap', tabKey: 'pages.index.xrayTabHeap', title: 'pages.index.xrayTitleHeap', icon: <DatabaseOutlined />, unit: 'B', stroke: '#7c4dff' },
+  { key: 'xrSys', tab: 'Sys', tabKey: 'pages.index.xrayTabSys', title: 'pages.index.xrayTitleSys', icon: <CloudServerOutlined />, unit: 'B', stroke: '#1890ff' },
+  { key: 'xrHeapObjects', tab: 'Objects', tabKey: 'pages.index.xrayTabObjects', title: 'pages.index.xrayTitleObjects', icon: <BlockOutlined />, unit: '', stroke: '#13c2c2' },
+  { key: 'xrNumGC', tab: 'GC Count', tabKey: 'pages.index.xrayTabGcCount', title: 'pages.index.xrayTitleGcCount', icon: <DeleteOutlined />, unit: '', stroke: '#fa8c16' },
+  { key: 'xrPauseNs', tab: 'GC Pause', tabKey: 'pages.index.xrayTabGcPause', title: 'pages.index.xrayTitleGcPause', icon: <PauseCircleOutlined />, unit: 'ns', stroke: '#f5222d' },
+  { key: OBS_KEY, tab: 'Observatory', tabKey: 'pages.index.xrayTabObservatory', title: 'pages.index.xrayTitleObservatory', icon: <EyeOutlined />, unit: 'ms', stroke: '#52c41a' },
 ];
 
 function unitFormatter(unit: string): (v: number) => string {
@@ -299,7 +311,13 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
         onChange={setActiveKey}
         size="small"
         className="history-tabs"
-        items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
+        items={METRICS.map((m) => {
+          const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
+          return {
+            key: m.key,
+            label: isMobile ? <span title={tabLabel} aria-label={tabLabel}>{m.icon}</span> : tabLabel,
+          };
+        })}
       />
 
       {isObservatory && (
@@ -353,6 +371,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       )}
 
       <div className="cpu-chart-wrap">
+        {activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
         <Sparkline
           data={points}
           labels={labels}

+ 2 - 2
frontend/src/pages/nodes/NodeList.tsx

@@ -499,11 +499,11 @@ export default function NodeList({
           scroll={{ x: 'max-content' }}
           size="middle"
           rowKey="id"
-          rowSelection={{
+          rowSelection={dataSource.length > 1 ? {
             selectedRowKeys: selectedIds,
             onChange: (keys) => onSelectionChange(keys as number[]),
             getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
-          }}
+          } : undefined}
           locale={{
             emptyText: (
               <div className="card-empty">

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

@@ -1,15 +1,25 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
-  Collapse,
   Input,
   InputNumber,
   Select,
   Switch,
+  Tabs,
 } from 'antd';
+import {
+  ApartmentOutlined,
+  BellOutlined,
+  ClockCircleOutlined,
+  GlobalOutlined,
+  SafetyCertificateOutlined,
+  SettingOutlined,
+} from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
 import { sanitizePath } from './uriPath';
 
 interface ApiMsg<T = unknown> {
@@ -29,6 +39,7 @@ const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
 
 export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
 
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
   const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
@@ -82,10 +93,10 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
   );
 
   return (
-    <Collapse defaultActiveKey="1" items={[
+    <Tabs defaultActiveKey="1" items={[
       {
         key: '1',
-        label: t('pages.settings.panelSettings'),
+        label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
@@ -148,7 +159,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
       },
       {
         key: '2',
-        label: t('pages.settings.notifications'),
+        label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
@@ -164,7 +175,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
       },
       {
         key: '3',
-        label: t('pages.settings.certs'),
+        label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
@@ -178,7 +189,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
       },
       {
         key: '4',
-        label: t('pages.settings.externalTraffic'),
+        label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
@@ -201,7 +212,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
       },
       {
         key: '5',
-        label: t('pages.settings.dateAndTime'),
+        label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
@@ -220,7 +231,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
       },
       {
         key: '6',
-        label: 'LDAP',
+        label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>

+ 9 - 5
frontend/src/pages/settings/SecurityTab.tsx

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
-  Collapse,
   Empty,
   Form,
   Input,
@@ -10,11 +9,15 @@ import {
   Space,
   Spin,
   Switch,
+  Tabs,
   message,
 } from 'antd';
+import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
 import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
 import TwoFactorModal from './TwoFactorModal';
 import './SecurityTab.css';
 
@@ -59,6 +62,7 @@ const TFA_INITIAL: TfaState = {
 
 export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
   const [modal, modalContextHolder] = Modal.useModal();
   const [messageApi, messageContextHolder] = message.useMessage();
 
@@ -248,10 +252,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     <>
       {messageContextHolder}
       {modalContextHolder}
-      <Collapse defaultActiveKey="1" items={[
+      <Tabs defaultActiveKey="1" items={[
         {
           key: '1',
-          label: t('pages.settings.security.admin'),
+          label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
           children: (
             <>
               <SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
@@ -282,7 +286,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
         },
         {
           key: '2',
-          label: t('pages.settings.security.twoFactor'),
+          label: catTabLabel(<SafetyOutlined />, t('pages.settings.security.twoFactor'), isMobile),
           children: (
             <SettingListItem
               paddings="small"
@@ -295,7 +299,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
         },
         {
           key: '3',
-          label: t('pages.nodes.apiToken'),
+          label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
           children: (
             <div className="api-token-section">
               <div className="api-token-header">

+ 13 - 90
frontend/src/pages/settings/SettingsPage.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { useLocation } from 'react-router-dom';
 import {
   Alert,
   Button,
@@ -12,17 +13,8 @@ import {
   Row,
   Space,
   Spin,
-  Tabs,
-  Tooltip,
   message,
 } from 'antd';
-import {
-  CloudServerOutlined,
-  CodeOutlined,
-  MessageOutlined,
-  SafetyOutlined,
-  SettingOutlined,
-} from '@ant-design/icons';
 
 import { HttpUtil, PromiseUtil } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
@@ -44,15 +36,6 @@ interface ApiMsg {
 
 const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
 
-function slugToKey(slug: string): string {
-  const i = tabSlugs.indexOf(slug);
-  return i >= 0 ? String(i + 1) : '1';
-}
-
-function keyToSlug(key: string): string {
-  return tabSlugs[Number(key) - 1] || tabSlugs[0];
-}
-
 function isIp(h: string): boolean {
   if (typeof h !== 'string') return false;
   const v4 = h.split('.');
@@ -108,21 +91,9 @@ export default function SettingsPage() {
   }, []);
 
   const [alertVisible, setAlertVisible] = useState(true);
-  const [activeTabKey, setActiveTabKey] = useState<string>(() => slugToKey(window.location.hash.slice(1)));
-
-  useEffect(() => {
-    const onHashChange = () => setActiveTabKey(slugToKey(window.location.hash.slice(1)));
-    window.addEventListener('hashchange', onHashChange);
-    return () => window.removeEventListener('hashchange', onHashChange);
-  }, []);
-
-  function onTabChange(key: string) {
-    setActiveTabKey(key);
-    const slug = keyToSlug(key);
-    if (window.location.hash !== `#${slug}`) {
-      history.replaceState(null, '', `#${slug}`);
-    }
-  }
+  const location = useLocation();
+  const slug = location.hash.replace(/^#/, '');
+  const activeSlug = tabSlugs.includes(slug) ? slug : 'general';
 
   function rebuildUrlAfterRestart(): string {
     const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
@@ -222,58 +193,15 @@ export default function SettingsPage() {
     return classes.join(' ');
   }, [isDark, isUltra]);
 
-  const tabItems = useMemo(() => {
-    const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [
-      {
-        key: '1',
-        label: (
-          <Tooltip title={isMobile ? t('pages.settings.panelSettings') : null}>
-            <span><SettingOutlined />{!isMobile && <> {t('pages.settings.panelSettings')}</>}</span>
-          </Tooltip>
-        ),
-        children: <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
-      },
-      {
-        key: '2',
-        label: (
-          <Tooltip title={isMobile ? t('pages.settings.securitySettings') : null}>
-            <span><SafetyOutlined />{!isMobile && <> {t('pages.settings.securitySettings')}</>}</span>
-          </Tooltip>
-        ),
-        children: <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />,
-      },
-      {
-        key: '3',
-        label: (
-          <Tooltip title={isMobile ? t('pages.settings.TGBotSettings') : null}>
-            <span><MessageOutlined />{!isMobile && <> {t('pages.settings.TGBotSettings')}</>}</span>
-          </Tooltip>
-        ),
-        children: <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />,
-      },
-      {
-        key: '4',
-        label: (
-          <Tooltip title={isMobile ? t('pages.settings.subSettings') : null}>
-            <span><CloudServerOutlined />{!isMobile && <> {t('pages.settings.subSettings')}</>}</span>
-          </Tooltip>
-        ),
-        children: <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
-      },
-    ];
-    if (allSetting.subJsonEnable || allSetting.subClashEnable) {
-      items.push({
-        key: '5',
-        label: (
-          <Tooltip title={isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null}>
-            <span><CodeOutlined />{!isMobile && <> {t('pages.settings.subSettings')} (Formats)</>}</span>
-          </Tooltip>
-        ),
-        children: <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />,
-      });
+  const categoryBody = useMemo(() => {
+    switch (activeSlug) {
+      case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
+      case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
+      default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
     }
-    return items;
-  }, [allSetting, updateSetting, isMobile, t]);
+  }, [activeSlug, allSetting, updateSetting]);
 
   return (
     <ConfigProvider theme={antdThemeConfig}>
@@ -331,12 +259,7 @@ export default function SettingsPage() {
 
                     <Col span={24}>
                       <Card hoverable>
-                        <Tabs
-                          activeKey={activeTabKey}
-                          onChange={onTabChange}
-                          className={isMobile ? 'icons-only' : ''}
-                          items={tabItems}
-                        />
+                        {categoryBody}
                       </Card>
                     </Col>
                   </Row>

+ 13 - 2
frontend/src/pages/settings/SubscriptionFormatsTab.css

@@ -1,3 +1,14 @@
-.nested-block {
-  padding: 10px 20px;
+.format-settings {
+  margin-bottom: 8px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.format-settings-list {
+  padding-top: 4px;
+}
+
+.noise-card {
+  margin-bottom: 10px;
 }

+ 120 - 132
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -2,15 +2,26 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
-  Collapse,
+  Card,
   Input,
   InputNumber,
   Select,
-  Space,
   Switch,
+  Tabs,
 } from 'antd';
+import {
+  DeleteOutlined,
+  PartitionOutlined,
+  PlusOutlined,
+  ScissorOutlined,
+  SendOutlined,
+  SettingOutlined,
+  ThunderboltOutlined,
+} from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 import './SubscriptionFormatsTab.css';
 
@@ -72,6 +83,7 @@ function readJson<T>(raw: string, fallback: T): T {
 
 export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
 
   const fragment = allSetting.subJsonFragment !== '';
   const noisesEnabled = allSetting.subJsonNoises !== '';
@@ -190,10 +202,10 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
   }
 
   return (
-    <Collapse defaultActiveKey="1" items={[
+    <Tabs defaultActiveKey="1" items={[
       {
         key: '1',
-        label: t('pages.settings.panelSettings'),
+        label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
           <>
             {allSetting.subJsonEnable && (
@@ -239,40 +251,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '2',
-        label: t('pages.settings.fragment'),
+        label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
               <Switch checked={fragment} onChange={setFragmentEnabled} />
             </SettingListItem>
             {fragment && (
-              <div className="nested-block">
-                <Collapse items={[
-                  {
-                    key: 'sett',
-                    label: t('pages.settings.fragmentSett'),
-                    children: (
-                      <>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
-                          <Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
-                            onChange={(e) => setFragmentField('packets', e.target.value)} />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
-                          <Input value={fragmentObj.length} placeholder="100-200"
-                            onChange={(e) => setFragmentField('length', e.target.value)} />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
-                          <Input value={fragmentObj.interval} placeholder="10-20"
-                            onChange={(e) => setFragmentField('interval', e.target.value)} />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
-                          <Input value={fragmentObj.maxSplit} placeholder="300-400"
-                            onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
-                        </SettingListItem>
-                      </>
-                    ),
-                  },
-                ]} />
+              <div className="format-settings">
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
+                  <Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
+                    onChange={(e) => setFragmentField('packets', e.target.value)} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
+                  <Input value={fragmentObj.length} placeholder="100-200"
+                    onChange={(e) => setFragmentField('length', e.target.value)} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
+                  <Input value={fragmentObj.interval} placeholder="10-20"
+                    onChange={(e) => setFragmentField('interval', e.target.value)} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
+                  <Input value={fragmentObj.maxSplit} placeholder="300-400"
+                    onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
+                </SettingListItem>
               </div>
             )}
           </>
@@ -280,54 +282,60 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '3',
-        label: t('pages.settings.subFormats.noises'),
+        label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
               <Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
             </SettingListItem>
             {noisesEnabled && (
-              <div className="nested-block">
-                <Collapse items={noisesArray.map((noise, index) => ({
-                  key: String(index),
-                  label: t('pages.settings.subFormats.noiseItem', { n: index + 1 }),
-                  children: (
-                    <>
-                      <SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
-                        <Select
-                          value={noise.type}
-                          style={{ width: '100%' }}
-                          onChange={(v) => updateNoiseField(index, 'type', v)}
-                          options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
-                        />
-                      </SettingListItem>
-                      <SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
-                        <Input value={noise.packet} placeholder="5-10"
-                          onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
-                      </SettingListItem>
-                      <SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
-                        <Input value={noise.delay} placeholder="10-20"
-                          onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
-                      </SettingListItem>
-                      <SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
-                        <Select
-                          value={noise.applyTo}
-                          style={{ width: '100%' }}
-                          onChange={(v) => updateNoiseField(index, 'applyTo', v)}
-                          options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
-                        />
-                      </SettingListItem>
-                      <Space style={{ padding: '10px 20px' }}>
-                        {noisesArray.length > 1 && (
-                          <Button type="primary" danger onClick={() => removeNoise(index)}>
-                            {t('delete')}
-                          </Button>
-                        )}
-                      </Space>
-                    </>
-                  ),
-                }))} />
-                <Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>{t('pages.settings.subFormats.addNoise')}</Button>
+              <div className="format-settings-list">
+                {noisesArray.map((noise, index) => (
+                  <Card
+                    key={index}
+                    size="small"
+                    className="noise-card"
+                    title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
+                    extra={noisesArray.length > 1 ? (
+                      <Button
+                        size="small"
+                        danger
+                        icon={<DeleteOutlined />}
+                        aria-label={t('delete')}
+                        onClick={() => removeNoise(index)}
+                      />
+                    ) : null}
+                    styles={{ body: { padding: 0 } }}
+                  >
+                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
+                      <Select
+                        value={noise.type}
+                        style={{ width: '100%' }}
+                        onChange={(v) => updateNoiseField(index, 'type', v)}
+                        options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
+                      />
+                    </SettingListItem>
+                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
+                      <Input value={noise.packet} placeholder="5-10"
+                        onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
+                    </SettingListItem>
+                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
+                      <Input value={noise.delay} placeholder="10-20"
+                        onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
+                    </SettingListItem>
+                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
+                      <Select
+                        value={noise.applyTo}
+                        style={{ width: '100%' }}
+                        onChange={(v) => updateNoiseField(index, 'applyTo', v)}
+                        options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
+                      />
+                    </SettingListItem>
+                  </Card>
+                ))}
+                <Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
+                  {t('pages.settings.subFormats.addNoise')}
+                </Button>
               </div>
             )}
           </>
@@ -335,40 +343,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '4',
-        label: t('pages.settings.mux'),
+        label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}>
               <Switch checked={muxEnabled} onChange={setMuxEnabled} />
             </SettingListItem>
             {muxEnabled && (
-              <div className="nested-block">
-                <Collapse items={[
-                  {
-                    key: 'sett',
-                    label: t('pages.settings.muxSett'),
-                    children: (
-                      <>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
-                          <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
-                            onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
-                          <InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
-                            onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
-                          <Select
-                            value={muxObj.xudpProxyUDP443}
-                            style={{ width: '100%' }}
-                            onChange={(v) => setMuxField('xudpProxyUDP443', v)}
-                            options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
-                          />
-                        </SettingListItem>
-                      </>
-                    ),
-                  },
-                ]} />
+              <div className="format-settings">
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
+                  <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
+                    onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
+                  <InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
+                    onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
+                  <Select
+                    value={muxObj.xudpProxyUDP443}
+                    style={{ width: '100%' }}
+                    onChange={(v) => setMuxField('xudpProxyUDP443', v)}
+                    options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
+                  />
+                </SettingListItem>
               </div>
             )}
           </>
@@ -376,42 +374,32 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '5',
-        label: t('pages.settings.direct'),
+        label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}>
               <Switch checked={directEnabled} onChange={setDirectEnabled} />
             </SettingListItem>
             {directEnabled && (
-              <div className="nested-block">
-                <Collapse items={[
-                  {
-                    key: 'rules',
-                    label: t('pages.settings.direct'),
-                    children: (
-                      <>
-                        <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
-                          <Select
-                            mode="tags"
-                            value={directIPs}
-                            style={{ width: '100%' }}
-                            onChange={setDirectIPs}
-                            options={directIPsOptions}
-                          />
-                        </SettingListItem>
-                        <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
-                          <Select
-                            mode="tags"
-                            value={directDomains}
-                            style={{ width: '100%' }}
-                            onChange={setDirectDomains}
-                            options={directDomainsOptions}
-                          />
-                        </SettingListItem>
-                      </>
-                    ),
-                  },
-                ]} />
+              <div className="format-settings">
+                <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
+                  <Select
+                    mode="tags"
+                    value={directIPs}
+                    style={{ width: '100%' }}
+                    onChange={setDirectIPs}
+                    options={directIPsOptions}
+                  />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
+                  <Select
+                    mode="tags"
+                    value={directDomains}
+                    style={{ width: '100%' }}
+                    onChange={setDirectDomains}
+                    options={directDomainsOptions}
+                  />
+                </SettingListItem>
               </div>
             )}
           </>

+ 10 - 6
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,8 +1,11 @@
 import { useMemo } from 'react';
-import { Collapse, Divider, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { Divider, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
+import { ClockCircleOutlined, InfoCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 
 const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
@@ -16,6 +19,7 @@ interface SubscriptionGeneralTabProps {
 
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
 
   const remarkModel = useMemo(() => {
     const rm = allSetting.remarkModel || '';
@@ -42,10 +46,10 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
   }
 
   return (
-    <Collapse defaultActiveKey="1" items={[
+    <Tabs defaultActiveKey="1" items={[
       {
         key: '1',
-        label: t('pages.settings.panelSettings'),
+        label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subEnable')} description={t('pages.settings.subEnableDesc')}>
@@ -84,7 +88,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
       },
       {
         key: '2',
-        label: t('pages.settings.information'),
+        label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
@@ -167,7 +171,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
       },
       {
         key: '3',
-        label: t('pages.settings.certs'),
+        label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
@@ -181,7 +185,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
       },
       {
         key: '4',
-        label: t('pages.settings.intervals'),
+        label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.intervals'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>

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

@@ -1,9 +1,12 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Collapse, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
+import { BellOutlined, SettingOutlined } from '@ant-design/icons';
 import { LanguageManager } from '@/utils';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from './catTabLabel';
 
 interface TelegramTabProps {
   allSetting: AllSetting;
@@ -12,6 +15,7 @@ interface TelegramTabProps {
 
 export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
 
   const langOptions = useMemo(
     () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
@@ -27,10 +31,10 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
   );
 
   return (
-    <Collapse defaultActiveKey="1" items={[
+    <Tabs defaultActiveKey="1" items={[
       {
         key: '1',
-        label: t('pages.settings.panelSettings'),
+        label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}>
@@ -71,7 +75,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
       },
       {
         key: '2',
-        label: t('pages.settings.notifications'),
+        label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>

+ 17 - 0
frontend/src/pages/settings/catTabLabel.tsx

@@ -0,0 +1,17 @@
+import type { ReactNode } from 'react';
+import { Tooltip } from 'antd';
+
+/* Builds a settings category tab label: icon + text on desktop, and on
+   mobile just the icon with the text moved into a tooltip — mirroring the
+   old top tab bar's icons-only behaviour. */
+export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
+  if (iconsOnly) {
+    return <Tooltip title={text}>{icon}</Tooltip>;
+  }
+  return (
+    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+      {icon}
+      <span>{text}</span>
+    </span>
+  );
+}

+ 94 - 187
frontend/src/pages/xray/XrayPage.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { useLocation, useNavigate } from 'react-router-dom';
 import {
   Alert,
   Button,
@@ -16,18 +17,8 @@ import {
   Row,
   Space,
   Spin,
-  Tabs,
-  Tooltip,
 } from 'antd';
-import {
-  SettingOutlined,
-  SwapOutlined,
-  UploadOutlined,
-  ClusterOutlined,
-  DatabaseOutlined,
-  CodeOutlined,
-  QuestionCircleOutlined,
-} from '@ant-design/icons';
+import { QuestionCircleOutlined } from '@ant-design/icons';
 
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -45,18 +36,7 @@ import { DnsTab } from './dns';
 import { WarpModal, NordModal } from './overrides';
 import './XrayPage.css';
 
-const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
-const SLUG_BY_KEY: Record<string, string> = {
-  'tpl-basic': 'basic',
-  'tpl-routing': 'routing',
-  'tpl-outbound': 'outbound',
-  'tpl-balancer': 'balancer',
-  'tpl-dns': 'dns',
-  'tpl-advanced': 'advanced',
-};
-const KEY_BY_SLUG: Record<string, string> = Object.fromEntries(
-  Object.entries(SLUG_BY_KEY).map(([k, v]) => [v, k]),
-);
+const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
 
 type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
 
@@ -97,27 +77,10 @@ export default function XrayPage() {
   const [warpOpen, setWarpOpen] = useState(false);
   const [nordOpen, setNordOpen] = useState(false);
   const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
-  const [activeTabKey, setActiveTabKey] = useState(() => {
-    const slug = window.location.hash.slice(1);
-    return KEY_BY_SLUG[slug] || TAB_KEYS[0];
-  });
-
-  useEffect(() => {
-    function syncTabFromHash() {
-      const key = KEY_BY_SLUG[window.location.hash.slice(1)];
-      if (key) setActiveTabKey(key);
-    }
-    window.addEventListener('hashchange', syncTabFromHash);
-    return () => window.removeEventListener('hashchange', syncTabFromHash);
-  }, []);
-
-  function onTabChange(key: string) {
-    setActiveTabKey(key);
-    const slug = SLUG_BY_KEY[key];
-    if (slug && window.location.hash !== `#${slug}`) {
-      history.replaceState(null, '', `#${slug}`);
-    }
-  }
+  const location = useLocation();
+  const navigate = useNavigate();
+  const sectionSlug = location.hash.replace(/^#/, '');
+  const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
 
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {
@@ -131,9 +94,6 @@ export default function XrayPage() {
     [setTemplateSettings],
   );
 
-  const warpExist = !!templateSettings?.outbounds?.find((o) => o?.tag === 'warp');
-  const nordExist = !!templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'));
-
   async function onTestOutbound(idx: number, mode: string) {
     const outbound = templateSettings?.outbounds?.[idx];
     if (outbound) await testOutbound(idx, outbound, mode);
@@ -235,7 +195,7 @@ export default function XrayPage() {
       JSON.parse(xraySetting);
     } catch (e) {
       messageApi.error(`Advanced JSON: ${(e as Error).message}`);
-      setActiveTabKey('tpl-advanced');
+      navigate('/xray#advanced');
       return;
     }
     saveAll();
@@ -245,6 +205,91 @@ export default function XrayPage() {
 
   const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
 
+  const sectionBody = (() => {
+    switch (activeSection) {
+      case 'routing':
+        return (
+          <RoutingTab
+            templateSettings={templateSettings}
+            setTemplateSettings={setTemplateSettings}
+            inboundTags={inboundTags}
+            clientReverseTags={clientReverseTags}
+            isMobile={isMobile}
+          />
+        );
+      case 'outbound':
+        return (
+          <OutboundsTab
+            templateSettings={templateSettings}
+            setTemplateSettings={setTemplateSettings}
+            outboundsTraffic={outboundsTraffic}
+            outboundTestStates={outboundTestStates}
+            testingAll={testingAll}
+            inboundTags={inboundTags}
+            isMobile={isMobile}
+            onResetTraffic={resetOutboundsTraffic}
+            onTest={onTestOutbound}
+            onTestAll={testAllOutbounds}
+            onShowWarp={() => setWarpOpen(true)}
+            onShowNord={() => setNordOpen(true)}
+          />
+        );
+      case 'balancer':
+        return (
+          <BalancersTab
+            templateSettings={templateSettings}
+            setTemplateSettings={setTemplateSettings}
+            clientReverseTags={clientReverseTags}
+            isMobile={isMobile}
+          />
+        );
+      case 'dns':
+        return (
+          <DnsTab
+            templateSettings={templateSettings}
+            setTemplateSettings={setTemplateSettings}
+          />
+        );
+      case 'advanced':
+        return (
+          <>
+            <div className="advanced-meta">
+              <h4>{t('pages.xray.Template')}</h4>
+              <p>{t('pages.xray.TemplateDesc')}</p>
+            </div>
+            <Radio.Group
+              value={advSettings}
+              buttonStyle="solid"
+              size={isMobile ? 'small' : 'middle'}
+              style={{ margin: '12px 0' }}
+              onChange={(e) => setAdvSettings(e.target.value)}
+            >
+              <Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
+              <Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
+              <Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
+              <Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
+            </Radio.Group>
+            <JsonEditor
+              value={advancedText}
+              onChange={onAdvancedTextChange}
+              minHeight="420px"
+              maxHeight="720px"
+            />
+          </>
+        );
+      default:
+        return (
+          <BasicsTab
+            templateSettings={templateSettings}
+            setTemplateSettings={setTemplateSettings}
+            outboundTestUrl={outboundTestUrl}
+            onChangeOutboundTestUrl={setOutboundTestUrl}
+            onResetDefault={resetToDefault}
+          />
+        );
+    }
+  })();
+
   return (
     <ConfigProvider theme={antdThemeConfig}>
       {messageContextHolder}
@@ -298,145 +343,7 @@ export default function XrayPage() {
 
                   <Col span={24}>
                     <Card hoverable>
-                    <Tabs
-                      activeKey={activeTabKey}
-                      onChange={onTabChange}
-                      className={isMobile ? 'icons-only' : ''}
-                      items={[
-                        {
-                          key: 'tpl-basic',
-                          label: (
-                            <Tooltip title={isMobile ? t('pages.xray.basicTemplate') : ''}>
-                              <SettingOutlined />
-                              {!isMobile && <span>{` ${t('pages.xray.basicTemplate')}`}</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <BasicsTab
-                              templateSettings={templateSettings}
-                              setTemplateSettings={setTemplateSettings}
-                              outboundTestUrl={outboundTestUrl}
-                              onChangeOutboundTestUrl={setOutboundTestUrl}
-                              warpExist={warpExist}
-                              nordExist={nordExist}
-                              onShowWarp={() => setWarpOpen(true)}
-                              onShowNord={() => setNordOpen(true)}
-                              onResetDefault={resetToDefault}
-                            />
-                          ),
-                        },
-                        {
-                          key: 'tpl-routing',
-                          label: (
-                            <Tooltip title={isMobile ? t('pages.xray.Routings') : ''}>
-                              <SwapOutlined />
-                              {!isMobile && <span>{` ${t('pages.xray.Routings')}`}</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <RoutingTab
-                              templateSettings={templateSettings}
-                              setTemplateSettings={setTemplateSettings}
-                              inboundTags={inboundTags}
-                              clientReverseTags={clientReverseTags}
-                              isMobile={isMobile}
-                            />
-                          ),
-                        },
-                        {
-                          key: 'tpl-outbound',
-                          label: (
-                            <Tooltip title={isMobile ? t('pages.xray.Outbounds') : ''}>
-                              <UploadOutlined />
-                              {!isMobile && <span>{` ${t('pages.xray.Outbounds')}`}</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <OutboundsTab
-                              templateSettings={templateSettings}
-                              setTemplateSettings={setTemplateSettings}
-                              outboundsTraffic={outboundsTraffic}
-                              outboundTestStates={outboundTestStates}
-                              testingAll={testingAll}
-                              inboundTags={inboundTags}
-                              isMobile={isMobile}
-                              onResetTraffic={resetOutboundsTraffic}
-                              onTest={onTestOutbound}
-                              onTestAll={testAllOutbounds}
-                              onShowWarp={() => setWarpOpen(true)}
-                              onShowNord={() => setNordOpen(true)}
-                            />
-                          ),
-                        },
-                        {
-                          key: 'tpl-balancer',
-                          label: (
-                            <Tooltip title={isMobile ? t('pages.xray.Balancers') : ''}>
-                              <ClusterOutlined />
-                              {!isMobile && <span>{` ${t('pages.xray.Balancers')}`}</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <BalancersTab
-                              templateSettings={templateSettings}
-                              setTemplateSettings={setTemplateSettings}
-                              clientReverseTags={clientReverseTags}
-                              isMobile={isMobile}
-                            />
-                          ),
-                        },
-                        {
-                          key: 'tpl-dns',
-                          label: (
-                            <Tooltip title={isMobile ? 'DNS' : ''}>
-                              <DatabaseOutlined />
-                              {!isMobile && <span> DNS</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <DnsTab
-                              templateSettings={templateSettings}
-                              setTemplateSettings={setTemplateSettings}
-                            />
-                          ),
-                        },
-                        {
-                          key: 'tpl-advanced',
-                          label: (
-                            <Tooltip title={isMobile ? t('pages.xray.advancedTemplate') : ''}>
-                              <CodeOutlined />
-                              {!isMobile && <span>{` ${t('pages.xray.advancedTemplate')}`}</span>}
-                            </Tooltip>
-                          ),
-                          children: (
-                            <>
-                              <div className="advanced-meta">
-                                <h4>{t('pages.xray.Template')}</h4>
-                                <p>{t('pages.xray.TemplateDesc')}</p>
-                              </div>
-                              <Radio.Group
-                                value={advSettings}
-                                buttonStyle="solid"
-                                size={isMobile ? 'small' : 'middle'}
-                                style={{ margin: '12px 0' }}
-                                onChange={(e) => setAdvSettings(e.target.value)}
-                              >
-                                <Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
-                                <Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
-                                <Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
-                                <Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
-                              </Radio.Group>
-                              <JsonEditor
-                                value={advancedText}
-                                onChange={onAdvancedTextChange}
-                                minHeight="420px"
-                                maxHeight="720px"
-                              />
-                            </>
-                          ),
-                        },
-                      ]}
-                    />
+                      {sectionBody}
                     </Card>
                   </Col>
                 </Row>

+ 16 - 196
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -1,38 +1,33 @@
 import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
-import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
+import { Alert, Button, Input, Modal, Select, Space, Switch, Tabs } from 'antd';
+import {
+  BarChartOutlined,
+  FileTextOutlined,
+  ReloadOutlined,
+  SettingOutlined,
+} from '@ant-design/icons';
 
 import { OutboundDomainStrategies } from '@/schemas/primitives';
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from '@/pages/settings/catTabLabel';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import './BasicsTab.css';
 
 import {
   ACCESS_LOG,
-  BITTORRENT_PROTOCOLS,
-  BLOCK_DOMAINS_OPTIONS,
-  DOMAINS_OPTIONS,
   ERROR_LOG,
-  IPS_OPTIONS,
   LOG_LEVELS,
   MASK_ADDRESS,
   ROUTING_DOMAIN_STRATEGIES,
-  SERVICES_OPTIONS,
-  directSettings,
-  ipv4Settings,
 } from './constants';
-import { ruleGetter, ruleSetter, syncOutbound } from './helpers';
 
 interface BasicsTabProps {
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
   outboundTestUrl: string;
   onChangeOutboundTestUrl: (v: string) => void;
-  warpExist: boolean;
-  nordExist: boolean;
-  onShowWarp: () => void;
-  onShowNord: () => void;
   onResetDefault: () => void;
 }
 
@@ -41,13 +36,10 @@ export default function BasicsTab({
   setTemplateSettings,
   outboundTestUrl,
   onChangeOutboundTestUrl,
-  warpExist,
-  nordExist,
-  onShowWarp,
-  onShowNord,
   onResetDefault,
 }: BasicsTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
   const [modal, modalContextHolder] = Modal.useModal();
 
   const mutate = useCallback(
@@ -81,23 +73,10 @@ export default function BasicsTab({
   const log = (templateSettings?.log || {}) as Record<string, unknown>;
   const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
 
-  const blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip');
-  const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain');
-  const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol');
-  const directIPs = ruleGetter(templateSettings, 'direct', 'ip');
-  const directDomains = ruleGetter(templateSettings, 'direct', 'domain');
-  const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
-  const warpDomains = ruleGetter(templateSettings, 'warp', 'domain');
-  const nordTag =
-    templateSettings?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-'))?.tag || 'nord';
-  const nordDomains = ruleGetter(templateSettings, nordTag, 'domain');
-
-  const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
-
   const items = [
     {
       key: '1',
-      label: t('pages.xray.generalConfigs'),
+      label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
       children: (
         <>
           <Alert
@@ -161,7 +140,7 @@ export default function BasicsTab({
     },
     {
       key: '2',
-      label: t('pages.xray.statistics'),
+      label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
       children: (
         <>
           {[
@@ -191,7 +170,7 @@ export default function BasicsTab({
     },
     {
       key: '3',
-      label: t('pages.xray.logConfigs'),
+      label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
       children: (
         <>
           <Alert
@@ -266,171 +245,12 @@ export default function BasicsTab({
         </>
       ),
     },
-    {
-      key: '4',
-      label: t('pages.xray.basicRouting'),
-      children: (
-        <>
-          <Alert
-            type="warning"
-            showIcon
-            className="mb-12 hint-alert"
-            title={t('pages.xray.blockConnectionsConfigsDesc')}
-          />
-
-          <SettingListItem
-            title={t('pages.xray.Torrent')}
-            paddings="small"
-            control={
-              <Switch
-                checked={torrentActive}
-                onChange={(checked) => mutate((tt) => {
-                  const next = checked
-                    ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
-                    : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
-                  ruleSetter(tt, 'blocked', 'protocol', next);
-                })}
-              />
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.blockips')}
-            paddings="small"
-            control={
-              <Select
-                mode="tags"
-                value={blockedIPs}
-                style={{ width: '100%' }}
-                options={IPS_OPTIONS}
-                onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
-              />
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.blockdomains')}
-            paddings="small"
-            control={
-              <Select
-                mode="tags"
-                value={blockedDomains}
-                style={{ width: '100%' }}
-                options={BLOCK_DOMAINS_OPTIONS}
-                onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
-              />
-            }
-          />
-
-          <Alert
-            type="warning"
-            showIcon
-            className="mb-12 hint-alert"
-            title={t('pages.xray.directConnectionsConfigsDesc')}
-          />
-
-          <SettingListItem
-            title={t('pages.xray.directips')}
-            paddings="small"
-            control={
-              <Select
-                mode="tags"
-                value={directIPs}
-                style={{ width: '100%' }}
-                options={IPS_OPTIONS}
-                onChange={(v) => mutate((tt) => {
-                  ruleSetter(tt, 'direct', 'ip', v);
-                  syncOutbound(tt, 'direct', directSettings);
-                })}
-              />
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.directdomains')}
-            paddings="small"
-            control={
-              <Select
-                mode="tags"
-                value={directDomains}
-                style={{ width: '100%' }}
-                options={DOMAINS_OPTIONS}
-                onChange={(v) => mutate((tt) => {
-                  ruleSetter(tt, 'direct', 'domain', v);
-                  syncOutbound(tt, 'direct', directSettings);
-                })}
-              />
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.ipv4Routing')}
-            description={t('pages.xray.ipv4RoutingDesc')}
-            paddings="small"
-            control={
-              <Select
-                mode="tags"
-                value={ipv4Domains}
-                style={{ width: '100%' }}
-                options={SERVICES_OPTIONS}
-                onChange={(v) => mutate((tt) => {
-                  ruleSetter(tt, 'IPv4', 'domain', v);
-                  syncOutbound(tt, 'IPv4', ipv4Settings);
-                })}
-              />
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.warpRouting')}
-            description={t('pages.xray.warpRoutingDesc')}
-            paddings="small"
-            control={
-              warpExist ? (
-                <Select
-                  mode="tags"
-                  value={warpDomains}
-                  style={{ width: '100%' }}
-                  options={SERVICES_OPTIONS}
-                  onChange={(v) => mutate((tt) => ruleSetter(tt, 'warp', 'domain', v))}
-                />
-              ) : (
-                <Button type="primary" onClick={onShowWarp} icon={<CloudOutlined />}>
-                  WARP
-                </Button>
-              )
-            }
-          />
-
-          <SettingListItem
-            title={t('pages.xray.nordRouting')}
-            description={t('pages.xray.nordRoutingDesc')}
-            paddings="small"
-            control={
-              nordExist ? (
-                <Select
-                  mode="tags"
-                  value={nordDomains}
-                  style={{ width: '100%' }}
-                  options={SERVICES_OPTIONS}
-                  onChange={(v) => mutate((tt) => ruleSetter(tt, nordTag, 'domain', v))}
-                />
-              ) : (
-                <Button type="primary" onClick={onShowNord} icon={<ApiOutlined />}>
-                  NordVPN
-                </Button>
-              )
-            }
-          />
-        </>
-      ),
-    },
     {
       key: 'reset',
-      label: t('pages.settings.resetDefaultConfig'),
+      label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
       children: (
         <Space style={{ padding: '0 20px' }}>
-          <Button danger onClick={confirmResetDefault}>
+          <Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
             {t('pages.settings.resetDefaultConfig')}
           </Button>
         </Space>
@@ -441,7 +261,7 @@ export default function BasicsTab({
   return (
     <>
       {modalContextHolder}
-      <Collapse defaultActiveKey={['1']} items={items} />
+      <Tabs defaultActiveKey="1" items={items} />
     </>
   );
 }

+ 19 - 8
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -1,9 +1,19 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Collapse, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd';
-import { PlusOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons';
+import { Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
+import {
+  DatabaseOutlined,
+  DeleteOutlined,
+  ExperimentOutlined,
+  MenuOutlined,
+  PlusOutlined,
+  ProfileOutlined,
+  SettingOutlined,
+} from '@ant-design/icons';
 
 import { SettingListItem } from '@/components/ui';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { catTabLabel } from '@/pages/settings/catTabLabel';
 import DnsServerModal from './DnsServerModal';
 import type { DnsServerValue } from './DnsServerModal';
 import DnsPresetsModal from './DnsPresetsModal';
@@ -21,6 +31,7 @@ interface DnsTabProps {
 
 export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
   const { t } = useTranslation();
+  const { isMobile } = useMediaQuery();
   const [modal, modalContextHolder] = Modal.useModal();
   const [hostsList, setHostsList] = useState<HostRow[]>([]);
   const [serverModalOpen, setServerModalOpen] = useState(false);
@@ -199,7 +210,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     const out = [
       {
         key: '1',
-        label: t('pages.xray.generalConfigs'),
+        label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
         children: (
           <>
             <SettingListItem
@@ -292,7 +303,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     if (dnsEnabled) {
       out.push({
         key: 'hosts',
-        label: t('pages.xray.dns.hosts'),
+        label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
         children: hostsList.length === 0 ? (
           <Empty description={t('pages.xray.dns.hostsEmpty')}>
             <Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
@@ -335,7 +346,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
 
       out.push({
         key: '2',
-        label: 'DNS',
+        label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
         children: dnsServers.length === 0 ? (
           <Empty description={t('emptyDnsDesc')}>
             <Space>
@@ -374,7 +385,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
 
       out.push({
         key: '3',
-        label: 'Fake DNS',
+        label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
         children: fakeDnsList.length === 0 ? (
           <Empty description={t('emptyFakeDnsDesc')}>
             <Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
@@ -401,12 +412,12 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
 
     return out;
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [t, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
+  }, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
 
   return (
     <>
       {modalContextHolder}
-      <Collapse defaultActiveKey={['1']} items={items} />
+      <Tabs defaultActiveKey="1" items={items} />
       <DnsServerModal
         open={serverModalOpen}
         server={editingServer}

+ 160 - 0
frontend/src/pages/xray/routing/RoutingBasic.tsx

@@ -0,0 +1,160 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Select, Switch } from 'antd';
+
+import { SettingListItem } from '@/components/ui';
+import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
+import {
+  BITTORRENT_PROTOCOLS,
+  BLOCK_DOMAINS_OPTIONS,
+  DOMAINS_OPTIONS,
+  IPS_OPTIONS,
+  SERVICES_OPTIONS,
+  directSettings,
+  ipv4Settings,
+} from '../basics/constants';
+import { ruleGetter, ruleSetter, syncOutbound } from '../basics/helpers';
+
+interface RoutingBasicProps {
+  templateSettings: XraySettingsValue | null;
+  setTemplateSettings: SetTemplate;
+}
+
+export default function RoutingBasic({ templateSettings, setTemplateSettings }: RoutingBasicProps) {
+  const { t } = useTranslation();
+
+  const mutate = useCallback(
+    (mutator: (next: XraySettingsValue) => void) => {
+      setTemplateSettings((prev) => {
+        if (!prev) return prev;
+        const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
+        mutator(clone);
+        return clone;
+      });
+    },
+    [setTemplateSettings],
+  );
+
+  const blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip');
+  const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain');
+  const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol');
+  const directIPs = ruleGetter(templateSettings, 'direct', 'ip');
+  const directDomains = ruleGetter(templateSettings, 'direct', 'domain');
+  const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
+
+  const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
+
+  return (
+    <>
+      <Alert
+        type="warning"
+        showIcon
+        className="mb-12 hint-alert"
+        title={t('pages.xray.blockConnectionsConfigsDesc')}
+      />
+
+      <SettingListItem
+        title={t('pages.xray.Torrent')}
+        paddings="small"
+        control={
+          <Switch
+            checked={torrentActive}
+            onChange={(checked) => mutate((tt) => {
+              const next = checked
+                ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
+                : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
+              ruleSetter(tt, 'blocked', 'protocol', next);
+            })}
+          />
+        }
+      />
+
+      <SettingListItem
+        title={t('pages.xray.blockips')}
+        paddings="small"
+        control={
+          <Select
+            mode="tags"
+            value={blockedIPs}
+            style={{ width: '100%' }}
+            options={IPS_OPTIONS}
+            onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
+          />
+        }
+      />
+
+      <SettingListItem
+        title={t('pages.xray.blockdomains')}
+        paddings="small"
+        control={
+          <Select
+            mode="tags"
+            value={blockedDomains}
+            style={{ width: '100%' }}
+            options={BLOCK_DOMAINS_OPTIONS}
+            onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
+          />
+        }
+      />
+
+      <Alert
+        type="warning"
+        showIcon
+        className="mb-12 hint-alert"
+        title={t('pages.xray.directConnectionsConfigsDesc')}
+      />
+
+      <SettingListItem
+        title={t('pages.xray.directips')}
+        paddings="small"
+        control={
+          <Select
+            mode="tags"
+            value={directIPs}
+            style={{ width: '100%' }}
+            options={IPS_OPTIONS}
+            onChange={(v) => mutate((tt) => {
+              ruleSetter(tt, 'direct', 'ip', v);
+              syncOutbound(tt, 'direct', directSettings);
+            })}
+          />
+        }
+      />
+
+      <SettingListItem
+        title={t('pages.xray.directdomains')}
+        paddings="small"
+        control={
+          <Select
+            mode="tags"
+            value={directDomains}
+            style={{ width: '100%' }}
+            options={DOMAINS_OPTIONS}
+            onChange={(v) => mutate((tt) => {
+              ruleSetter(tt, 'direct', 'domain', v);
+              syncOutbound(tt, 'direct', directSettings);
+            })}
+          />
+        }
+      />
+
+      <SettingListItem
+        title={t('pages.xray.ipv4Routing')}
+        description={t('pages.xray.ipv4RoutingDesc')}
+        paddings="small"
+        control={
+          <Select
+            mode="tags"
+            value={ipv4Domains}
+            style={{ width: '100%' }}
+            options={SERVICES_OPTIONS}
+            onChange={(v) => mutate((tt) => {
+              ruleSetter(tt, 'IPv4', 'domain', v);
+              syncOutbound(tt, 'IPv4', ipv4Settings);
+            })}
+          />
+        }
+      />
+    </>
+  );
+}

+ 4 - 0
frontend/src/pages/xray/routing/RoutingTab.css

@@ -231,3 +231,7 @@
   opacity: 0.4;
 }
 
+.hint-alert {
+  text-align: center;
+}
+

+ 80 - 48
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -1,8 +1,10 @@
 import { useCallback, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Modal, Space, Table } from 'antd';
-import { PlusOutlined } from '@ant-design/icons';
+import { Button, Modal, Space, Table, Tabs } from 'antd';
+import { ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
 
+import { catTabLabel } from '@/pages/settings/catTabLabel';
+import RoutingBasic from './RoutingBasic';
 import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import RuleCardList from './RuleCardList';
@@ -226,9 +228,14 @@ export default function RoutingTab({
     document.addEventListener('pointercancel', onUp);
   }
 
+  const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
+  const hasBalancer = rows.some((r) => r.balancerTag);
+
   const desktopColumns = useRoutingColumns({
     isMobile,
     rowsLength: rows.length,
+    showSource: hasSource,
+    showBalancer: hasBalancer,
     onHandlePointerDown,
     openEdit,
     moveUp,
@@ -236,56 +243,81 @@ export default function RoutingTab({
     confirmDelete,
   });
 
+  const tableScrollX = desktopColumns.reduce((sum, c) => {
+    const col = c as { width?: number; hidden?: boolean };
+    return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0);
+  }, 0);
+
   return (
     <>
       {modalContextHolder}
-      <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
-        <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
-          {t('pages.xray.Routings')}
-        </Button>
-
-        {isMobile ? (
-          <RuleCardList
-            rows={rows}
-            draggedIndex={draggedIndex}
-            dropTargetIndex={dropTargetIndex}
-            onHandlePointerDown={onHandlePointerDown}
-            openEdit={openEdit}
-            moveUp={moveUp}
-            moveDown={moveDown}
-            confirmDelete={confirmDelete}
-          />
-        ) : (
-          <Table
-            columns={desktopColumns}
-            dataSource={rows}
-            rowKey={(r) => r.key}
-            pagination={false}
-            scroll={{ x: 1150 }}
-            size="small"
-            className="routing-table"
-            onRow={(_record, index) => {
-              const classes: string[] = [];
-              const i = index ?? -1;
-              if (draggedIndex === i) classes.push('row-dragging');
-              if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
-                classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
-              }
-              return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
-            }}
-          />
-        )}
+      <Tabs
+        defaultActiveKey="basic"
+        items={[
+          {
+            key: 'basic',
+            label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
+            children: (
+              <RoutingBasic
+                templateSettings={templateSettings}
+                setTemplateSettings={setTemplateSettings}
+              />
+            ),
+          },
+          {
+            key: 'rules',
+            label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
+            children: (
+              <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+                <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
+                  {t('pages.xray.Routings')}
+                </Button>
 
-        <RuleFormModal
-          open={ruleModalOpen}
-          rule={editingRule}
-          inboundTags={inboundTagOptions}
-          outboundTags={outboundTagOptions}
-          balancerTags={balancerTagOptions}
-          onClose={() => setRuleModalOpen(false)}
-          onConfirm={onRuleConfirm}
-        />
-      </Space>
+                {isMobile ? (
+                  <RuleCardList
+                    rows={rows}
+                    draggedIndex={draggedIndex}
+                    dropTargetIndex={dropTargetIndex}
+                    onHandlePointerDown={onHandlePointerDown}
+                    openEdit={openEdit}
+                    moveUp={moveUp}
+                    moveDown={moveDown}
+                    confirmDelete={confirmDelete}
+                  />
+                ) : (
+                  <Table
+                    columns={desktopColumns}
+                    dataSource={rows}
+                    rowKey={(r) => r.key}
+                    pagination={false}
+                    scroll={{ x: tableScrollX }}
+                    size="small"
+                    className="routing-table"
+                    onRow={(_record, index) => {
+                      const classes: string[] = [];
+                      const i = index ?? -1;
+                      if (draggedIndex === i) classes.push('row-dragging');
+                      if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
+                        classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
+                      }
+                      return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
+                    }}
+                  />
+                )}
+              </Space>
+            ),
+          },
+        ]}
+      />
+      <RuleFormModal
+        open={ruleModalOpen}
+        rule={editingRule}
+        inboundTags={inboundTagOptions}
+        outboundTags={outboundTagOptions}
+        balancerTags={balancerTagOptions}
+        onClose={() => setRuleModalOpen(false)}
+        onConfirm={onRuleConfirm}
+      />
     </>
   );
 }

+ 8 - 1
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -19,6 +19,8 @@ import type { RuleRow } from './types';
 interface RoutingColumnsParams {
   isMobile: boolean;
   rowsLength: number;
+  showSource: boolean;
+  showBalancer: boolean;
   onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
   openEdit: (idx: number) => void;
   moveUp: (idx: number) => void;
@@ -29,6 +31,8 @@ interface RoutingColumnsParams {
 export function useRoutingColumns({
   isMobile,
   rowsLength,
+  showSource,
+  showBalancer,
   onHandlePointerDown,
   openEdit,
   moveUp,
@@ -84,6 +88,7 @@ export function useRoutingColumns({
         align: 'left',
         width: 180,
         key: 'source',
+        hidden: !showSource,
         render: (_v, record) => (
           <div className="criterion-flow">
             {record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
@@ -110,6 +115,7 @@ export function useRoutingColumns({
       {
         title: t('pages.xray.rules.dest'),
         align: 'left',
+        width: 200,
         key: 'destination',
         render: (_v, record) => (
           <div className="criterion-flow">
@@ -153,6 +159,7 @@ export function useRoutingColumns({
         align: 'left',
         width: 150,
         key: 'balancer',
+        hidden: !showBalancer,
         render: (_v, record) =>
           record.balancerTag ? (
             <div className="target-row">
@@ -165,6 +172,6 @@ export function useRoutingColumns({
       },
     ],
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, isMobile, rowsLength],
+    [t, isMobile, rowsLength, showSource, showBalancer],
   );
 }

+ 1 - 1
frontend/src/schemas/protocols/inbound/shadowsocks.ts

@@ -29,7 +29,7 @@ export type ShadowsocksClient = z.infer<typeof ShadowsocksClientSchema>;
 export const ShadowsocksInboundSettingsSchema = z.object({
   method: SSMethodSchema.default('2022-blake3-aes-256-gcm'),
   password: z.string().default(''),
-  network: SSNetworkSchema.default('tcp'),
+  network: SSNetworkSchema.default('tcp,udp'),
   clients: z.array(ShadowsocksClientSchema).default([]),
   ivCheck: z.boolean().default(false),
 });

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -22,5 +22,6 @@ export const ExternalProxyEntrySchema = z.object({
     UtlsFingerprintSchema.optional(),
   ),
   alpn: z.array(AlpnSchema).optional(),
+  pinnedPeerCertSha256: z.array(z.string()).optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 1 - 1
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -12,7 +12,7 @@ exports[`createDefault*InboundSettings factories > shadowsocks 1`] = `
   "clients": [],
   "ivCheck": false,
   "method": "2022-blake3-aes-256-gcm",
-  "network": "tcp",
+  "network": "tcp,udp",
   "password": "ZmFrZS1zcy1zZWVk",
 }
 `;

+ 74 - 0
frontend/src/test/inbound-link.test.ts

@@ -196,6 +196,34 @@ describe('genHysteriaLink', () => {
         'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
     );
   });
+
+  it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
+    const [, raw] = fixtures[0];
+    const typed = InboundSchema.parse(raw);
+    const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
+
+    const link = genHysteriaLink({
+      inbound: typed,
+      address: 'edge.example.com',
+      port: 8443,
+      remark: 'ep-pin',
+      clientAuth: client.auth,
+      externalProxy: {
+        forceTls: 'tls',
+        dest: 'edge.example.com',
+        port: 8443,
+        remark: 'ep-pin',
+        // base64 SHA-256 — must come out hex-normalized for Hysteria.
+        pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
+      },
+    });
+
+    const url = new URL(link);
+    expect(url.searchParams.get('pinSHA256')).toBe(
+      'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
+    );
+    expect(url.searchParams.has('pcs')).toBe(false);
+  });
 });
 
 describe('genWireguardLink + genWireguardConfig', () => {
@@ -356,3 +384,49 @@ describe('genShadowsocksLink', () => {
     });
   }
 });
+
+describe('external proxy pinned cert (pcs)', () => {
+  const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
+  const typed = InboundSchema.parse(raw);
+  const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
+
+  it('emits the external proxy pin list as pcs when forcing TLS', () => {
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'edge.example.com',
+      port: 8443,
+      forceTls: 'tls',
+      remark: 'ep-pin',
+      clientId,
+      externalProxy: {
+        forceTls: 'tls',
+        dest: 'edge.example.com',
+        port: 8443,
+        remark: 'ep-pin',
+        pinnedPeerCertSha256: ['aa11', 'bb22'],
+      },
+    });
+
+    expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
+  });
+
+  it('omits pcs when the external proxy forces security off', () => {
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'edge.example.com',
+      port: 8080,
+      forceTls: 'none',
+      remark: 'ep-none',
+      clientId,
+      externalProxy: {
+        forceTls: 'none',
+        dest: 'edge.example.com',
+        port: 8080,
+        remark: 'ep-none',
+        pinnedPeerCertSha256: ['aa11'],
+      },
+    });
+
+    expect(new URL(link).searchParams.has('pcs')).toBe(false);
+  });
+});

+ 79 - 5
sub/subService.go

@@ -667,8 +667,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			}
 			epRemark, _ := ep["remark"].(string)
 
+			epParams := cloneStringMap(params)
+			applyExternalProxyHysteriaParams(ep, epParams)
+
 			link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF))
-			links = append(links, buildLinkWithParams(link, params, s.genRemark(inbound, email, epRemark)))
+			links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
 		}
 		return strings.Join(links, "\n")
 	}
@@ -1017,7 +1020,7 @@ func buildVmessLink(obj map[string]any) string {
 func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
 	newObj := map[string]any{}
 	for key, value := range baseObj {
-		if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) {
+		if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "pcs")) {
 			newObj[key] = value
 		}
 	}
@@ -1037,6 +1040,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
 	if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
 		obj["alpn"] = alpn
 	}
+	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
+		obj["pcs"] = joinAnyStrings(pins)
+	}
 }
 
 func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
@@ -1052,6 +1058,29 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
 	if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
 		params["alpn"] = alpn
 	}
+	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
+		params["pcs"] = joinAnyStrings(pins)
+	}
+}
+
+// applyExternalProxyHysteriaParams overrides the cert pin for a single
+// external-proxy entry on a Hysteria link. Hysteria carries the pin as a hex
+// `pinSHA256` (not the `pcs` the URL-param protocols use), so each entry is
+// coerced through hysteriaPinHex like the main pin. sni/fp/alpn are left as
+// the inbound's own — Hysteria external proxies are typically alternate
+// endpoints (port-hop / CDN) fronting the same certificate.
+func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]string) {
+	pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"])
+	if !ok {
+		return
+	}
+	hexPins := make([]string, 0, len(pins))
+	for _, p := range pins {
+		if s, ok := p.(string); ok {
+			hexPins = append(hexPins, hysteriaPinHex(s))
+		}
+	}
+	params["pinSHA256"] = strings.Join(hexPins, ",")
 }
 
 // cloneStreamForExternalProxy returns a shallow clone of stream with
@@ -1096,6 +1125,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
 	if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
 		tlsSettings["alpn"] = alpn
 	}
+	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
+		settings, _ := tlsSettings["settings"].(map[string]any)
+		if settings == nil {
+			settings = map[string]any{}
+			tlsSettings["settings"] = settings
+		}
+		settings["pinnedPeerCertSha256"] = pins
+	}
 }
 
 func externalProxySNI(ep map[string]any) (string, bool) {
@@ -1165,6 +1202,43 @@ func externalProxyALPNList(value any) ([]any, bool) {
 	}
 }
 
+// externalProxyPins extracts an external-proxy entry's pinnedPeerCertSha256
+// as a []any of non-empty strings. The []any element type matches what the
+// JSON/Clash sub builders expect when reading the value back off the cloned
+// stream's tlsSettings.settings.
+func externalProxyPins(value any) ([]any, bool) {
+	switch v := value.(type) {
+	case []string:
+		out := make([]any, 0, len(v))
+		for _, item := range v {
+			if item != "" {
+				out = append(out, item)
+			}
+		}
+		return out, len(out) > 0
+	case []any:
+		out := make([]any, 0, len(v))
+		for _, item := range v {
+			if s, ok := item.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out, len(out) > 0
+	default:
+		return nil, false
+	}
+}
+
+func joinAnyStrings(items []any) string {
+	parts := make([]string, 0, len(items))
+	for _, item := range items {
+		if s, ok := item.(string); ok {
+			parts = append(parts, s)
+		}
+	}
+	return strings.Join(parts, ",")
+}
+
 func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
 	var links strings.Builder
 	for index, externalProxy := range externalProxies {
@@ -1204,8 +1278,8 @@ func buildLinkWithParams(link string, params map[string]string, fragment string)
 
 // buildLinkWithParamsAndSecurity is buildLinkWithParams plus an
 // external-proxy override: the `security` key in params is replaced with
-// the supplied value, and TLS hint fields (alpn/sni/fp) are stripped when
-// the override is `none`.
+// the supplied value, and TLS hint fields (alpn/sni/fp/pcs) are stripped
+// when the override is `none`.
 func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string {
 	return appendQueryAndFragment(link, params, fragment, security, omitTLSFields)
 }
@@ -1220,7 +1294,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 			if securityOverride != "" && k == "security" {
 				v = securityOverride
 			}
-			if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") {
+			if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs") {
 				continue
 			}
 			q.Set(k, v)

+ 79 - 0
sub/subService_test.go

@@ -617,6 +617,85 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 	}
 }
 
+func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
+	params := map[string]string{"security": "tls"}
+	ep := map[string]any{
+		"dest":                 "proxy.example.com",
+		"pinnedPeerCertSha256": []any{"aa11", "bb22"},
+	}
+
+	applyExternalProxyTLSParams(ep, params, "tls")
+
+	if params["pcs"] != "aa11,bb22" {
+		t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"])
+	}
+}
+
+func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
+	obj := map[string]any{"tls": "tls"}
+	ep := map[string]any{
+		"dest":                 "proxy.example.com",
+		"pinnedPeerCertSha256": []any{"aa11"},
+	}
+
+	applyExternalProxyTLSObj(ep, obj, "tls")
+
+	if obj["pcs"] != "aa11" {
+		t.Fatalf("pcs = %v, want aa11", obj["pcs"])
+	}
+}
+
+func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) {
+	stream := map[string]any{
+		"security":    "tls",
+		"tlsSettings": map[string]any{"serverName": "upstream.example.com"},
+	}
+	ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}}
+
+	working := cloneStreamForExternalProxy(stream)
+	applyExternalProxyTLSToStream(ep, working, "tls")
+
+	ts := working["tlsSettings"].(map[string]any)
+	settings, _ := ts["settings"].(map[string]any)
+	pins, ok := settings["pinnedPeerCertSha256"].([]any)
+	if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" {
+		t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"])
+	}
+}
+
+func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) {
+	// base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's
+	// pinSHA256, which other (pcs) protocols leave untouched.
+	params := map[string]string{"security": "tls", "sni": "server.example.com"}
+	ep := map[string]any{
+		"dest":                 "edge.example.com",
+		"pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="},
+	}
+
+	applyExternalProxyHysteriaParams(ep, params)
+
+	if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" {
+		t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"])
+	}
+	if _, ok := params["pcs"]; ok {
+		t.Fatalf("pcs must not be set for Hysteria, got %v", params)
+	}
+	if params["sni"] != "server.example.com" {
+		t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"])
+	}
+}
+
+func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) {
+	params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"}
+	ep := map[string]any{"dest": "edge.example.com"}
+
+	applyExternalProxyHysteriaParams(ep, params)
+
+	if params["pinSHA256"] != "deadbeef" {
+		t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"])
+	}
+}
+
 func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
 	params := map[string]string{
 		"security": "none",

+ 6 - 0
web/controller/server.go

@@ -33,6 +33,7 @@ type ServerController struct {
 // NewServerController creates a new ServerController, initializes routes, and starts background tasks.
 func NewServerController(g *gin.RouterGroup) *ServerController {
 	a := &ServerController{}
+	service.RestoreSystemMetrics()
 	a.initRouter(g)
 	a.startTask()
 	return a
@@ -84,6 +85,11 @@ func (a *ServerController) startTask() {
 		a.xrayMetricsService.Sample(time.Now())
 		websocket.BroadcastStatus(status)
 	})
+	c.AddFunc("@every 1m", func() {
+		if err := service.PersistSystemMetrics(); err != nil {
+			logger.Warning("persist system metrics failed:", err)
+		}
+	})
 }
 
 // status returns the current server status information.

+ 32 - 9
web/service/custom_geo.go

@@ -18,6 +18,7 @@ import (
 	"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/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/util/netsafe"
 )
 
@@ -73,6 +74,7 @@ type CustomGeoService struct {
 	updateAllGetAll  func() ([]model.CustomGeoResource, error)
 	updateAllApply   func(id int, onStartup bool) (string, error)
 	updateAllRestart func() error
+	getPanelProxy    func() (string, error)
 }
 
 func NewCustomGeoService() *CustomGeoService {
@@ -82,6 +84,7 @@ func NewCustomGeoService() *CustomGeoService {
 	s.updateAllGetAll = s.GetAll
 	s.updateAllApply = s.applyDownloadAndPersist
 	s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
+	s.getPanelProxy = (&SettingService{}).GetPanelProxy
 	return s
 }
 
@@ -206,12 +209,32 @@ func ssrfSafeTransport() http.RoundTripper {
 	return cloned
 }
 
-func probeCustomGeoURLWithGET(rawURL string) error {
-	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+func (s *CustomGeoService) httpClient(timeout time.Duration) *http.Client {
+	proxyURL := ""
+	if s.getPanelProxy != nil {
+		if p, err := s.getPanelProxy(); err != nil {
+			logger.Warning("custom geo: read panel proxy:", err)
+		} else {
+			proxyURL = strings.TrimSpace(p)
+		}
+	}
+	if proxyURL != "" {
+		client, err := netproxy.NewHTTPClient(proxyURL, timeout)
+		if err != nil {
+			logger.Warningf("custom geo: invalid panel proxy %q, using direct connection: %v", proxyURL, err)
+		} else {
+			return client
+		}
+	}
+	return &http.Client{Timeout: timeout, Transport: ssrfSafeTransport()}
+}
+
+func (s *CustomGeoService) probeCustomGeoURLWithGET(rawURL string) error {
+	sanitizedURL, err := s.sanitizeURL(rawURL)
 	if err != nil {
 		return err
 	}
-	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	client := s.httpClient(customGeoProbeTimeout)
 	req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
 	if err != nil {
 		return err
@@ -231,12 +254,12 @@ func probeCustomGeoURLWithGET(rawURL string) error {
 	}
 }
 
-func probeCustomGeoURL(rawURL string) error {
-	sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
+func (s *CustomGeoService) probeCustomGeoURL(rawURL string) error {
+	sanitizedURL, err := s.sanitizeURL(rawURL)
 	if err != nil {
 		return err
 	}
-	client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
+	client := s.httpClient(customGeoProbeTimeout)
 	req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
 	if err != nil {
 		return err
@@ -251,7 +274,7 @@ func probeCustomGeoURL(rawURL string) error {
 		return nil
 	}
 	if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
-		return probeCustomGeoURLWithGET(rawURL)
+		return s.probeCustomGeoURLWithGET(rawURL)
 	}
 	return fmt.Errorf("head status %d", sc)
 }
@@ -283,7 +306,7 @@ func (s *CustomGeoService) EnsureOnStartup() {
 			continue
 		}
 		logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
-		if err := probeCustomGeoURL(r.Url); err != nil {
+		if err := s.probeCustomGeoURL(r.Url); err != nil {
 			logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
 		}
 		_, _ = s.applyDownloadAndPersist(r.Id, true)
@@ -366,7 +389,7 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last
 		}
 	}
 
-	client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
+	client := s.httpClient(10 * time.Minute)
 	// lgtm[go/request-forgery]
 	resp, err := client.Do(req)
 	if err != nil {

+ 2 - 2
web/service/custom_geo_test.go

@@ -322,7 +322,7 @@ func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
 		w.WriteHeader(http.StatusOK)
 	}))
 	defer ts.Close()
-	if err := probeCustomGeoURL(ts.URL); err != nil {
+	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -342,7 +342,7 @@ func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
 		w.WriteHeader(http.StatusBadRequest)
 	}))
 	defer ts.Close()
-	if err := probeCustomGeoURL(ts.URL); err != nil {
+	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
 		t.Fatal(err)
 	}
 }

+ 140 - 67
web/service/inbound.go

@@ -1877,71 +1877,101 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 }
 
 func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
-	inboundIds := make([]int, 0, len(dbClientTraffics))
-	for _, dbClientTraffic := range dbClientTraffics {
-		if dbClientTraffic.ExpiryTime < 0 {
-			inboundIds = append(inboundIds, dbClientTraffic.InboundId)
+	now := time.Now().UnixMilli()
+
+	// "Start After First Use" stores a negative expiry (the duration). On the
+	// first traffic tick it becomes an absolute deadline of now+duration. Compute
+	// it once per email so every inbound the client is attached to lands on the
+	// same value (recomputing per inbound would skip all but the first one).
+	newExpiryByEmail := make(map[string]int64, len(dbClientTraffics))
+	for traffic_index := range dbClientTraffics {
+		if dbClientTraffics[traffic_index].ExpiryTime < 0 {
+			newExpiryByEmail[dbClientTraffics[traffic_index].Email] = now - dbClientTraffics[traffic_index].ExpiryTime
 		}
 	}
+	if len(newExpiryByEmail) == 0 {
+		return dbClientTraffics, nil
+	}
 
-	if len(inboundIds) > 0 {
-		var inbounds []*model.Inbound
-		err := tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
-		if err != nil {
-			return nil, err
-		}
-		for inbound_index := range inbounds {
-			settings := map[string]any{}
-			json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
-			clients, ok := settings["clients"].([]any)
-			if ok {
-				var newClients []any
-				for client_index := range clients {
-					c := clients[client_index].(map[string]any)
-					for traffic_index := range dbClientTraffics {
-						if dbClientTraffics[traffic_index].ExpiryTime < 0 && c["email"] == dbClientTraffics[traffic_index].Email {
-							oldExpiryTime := c["expiryTime"].(float64)
-							newExpiryTime := (time.Now().Unix() * 1000) - int64(oldExpiryTime)
-							c["expiryTime"] = newExpiryTime
-							c["updated_at"] = time.Now().Unix() * 1000
-							dbClientTraffics[traffic_index].ExpiryTime = newExpiryTime
-							break
-						}
-					}
-					if _, ok := c["created_at"]; !ok {
-						c["created_at"] = time.Now().Unix() * 1000
-					}
-					if _, ok := c["updated_at"]; !ok {
-						c["updated_at"] = time.Now().Unix() * 1000
-					}
-					newClients = append(newClients, any(c))
-				}
-				settings["clients"] = newClients
-				modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
-				if err != nil {
-					return nil, err
-				}
+	delayedEmails := make([]string, 0, len(newExpiryByEmail))
+	for email := range newExpiryByEmail {
+		delayedEmails = append(delayedEmails, email)
+	}
 
-				inbounds[inbound_index].Settings = string(modifiedSettings)
-			}
-		}
-		err = tx.Save(inbounds).Error
-		if err != nil {
-			logger.Warning("AddClientTraffic update inbounds ", err)
-			logger.Error(inbounds)
-		} else {
-			for _, ib := range inbounds {
-				if ib == nil {
-					continue
+	// Resolve the owning inbounds through the client_inbounds link, which is
+	// authoritative. client_traffics.inbound_id goes stale when an inbound is
+	// deleted and recreated, which would leave the negative expiry unconverted.
+	var inboundIds []int
+	err := tx.Table("client_inbounds").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email IN (?)", delayedEmails).
+		Distinct().
+		Pluck("client_inbounds.inbound_id", &inboundIds).Error
+	if err != nil {
+		return nil, err
+	}
+	if len(inboundIds) == 0 {
+		return dbClientTraffics, nil
+	}
+
+	var inbounds []*model.Inbound
+	err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
+	if err != nil {
+		return nil, err
+	}
+	for inbound_index := range inbounds {
+		settings := map[string]any{}
+		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
+		clients, ok := settings["clients"].([]any)
+		if ok {
+			var newClients []any
+			for client_index := range clients {
+				c := clients[client_index].(map[string]any)
+				email, _ := c["email"].(string)
+				if newExpiry, ok := newExpiryByEmail[email]; ok {
+					c["expiryTime"] = newExpiry
+					c["updated_at"] = now
 				}
-				cs, gcErr := s.GetClients(ib)
-				if gcErr != nil {
-					logger.Warning("AddClientTraffic sync clients: GetClients failed", gcErr)
-					continue
+				if _, ok := c["created_at"]; !ok {
+					c["created_at"] = now
 				}
-				if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil {
-					logger.Warning("AddClientTraffic sync clients: SyncInbound failed", syncErr)
+				if _, ok := c["updated_at"]; !ok {
+					c["updated_at"] = now
 				}
+				newClients = append(newClients, any(c))
+			}
+			settings["clients"] = newClients
+			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+			if err != nil {
+				return nil, err
+			}
+
+			inbounds[inbound_index].Settings = string(modifiedSettings)
+		}
+	}
+
+	for traffic_index := range dbClientTraffics {
+		if newExpiry, ok := newExpiryByEmail[dbClientTraffics[traffic_index].Email]; ok {
+			dbClientTraffics[traffic_index].ExpiryTime = newExpiry
+		}
+	}
+
+	err = tx.Save(inbounds).Error
+	if err != nil {
+		logger.Warning("AddClientTraffic update inbounds ", err)
+		logger.Error(inbounds)
+	} else {
+		for _, ib := range inbounds {
+			if ib == nil {
+				continue
+			}
+			cs, gcErr := s.GetClients(ib)
+			if gcErr != nil {
+				logger.Warning("AddClientTraffic sync clients: GetClients failed", gcErr)
+				continue
+			}
+			if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil {
+				logger.Warning("AddClientTraffic sync clients: SyncInbound failed", syncErr)
 			}
 		}
 	}
@@ -1976,8 +2006,23 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 		client   map[string]any
 	}
 
+	// Resolve the inbounds to renew through the client_inbounds link rather than
+	// client_traffics.inbound_id, which goes stale after an inbound is deleted and
+	// recreated and would otherwise skip the renew entirely.
+	renewEmails := make([]string, 0, len(traffics))
 	for _, traffic := range traffics {
-		inbound_ids = append(inbound_ids, traffic.InboundId)
+		renewEmails = append(renewEmails, traffic.Email)
+	}
+	for _, batch := range chunkStrings(renewEmails, sqliteMaxVars) {
+		var ids []int
+		if err = tx.Table("client_inbounds").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ?", batch).
+			Distinct().
+			Pluck("client_inbounds.inbound_id", &ids).Error; err != nil {
+			return false, 0, err
+		}
+		inbound_ids = append(inbound_ids, ids...)
 	}
 	// Dedupe so an inbound hosting N expired clients is fetched and saved once
 	// per tick instead of N times across chunk boundaries.
@@ -2401,11 +2446,24 @@ func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xr
 		logger.Warningf("Error retrieving ClientTraffic with trafficId %d: %v", trafficId, err)
 		return nil, nil, err
 	}
-	if len(traffics) > 0 {
-		inbound, err = s.GetInbound(traffics[0].InboundId)
-		return traffics[0], inbound, err
+	if len(traffics) == 0 {
+		return nil, nil, nil
+	}
+	traffic = traffics[0]
+
+	inbound, err = s.GetInbound(traffic.InboundId)
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		// client_traffics.inbound_id goes stale when an inbound is deleted and
+		// recreated; fall back to the authoritative client_inbounds link by email.
+		ids, idErr := s.clientService.GetInboundIdsForEmail(db, traffic.Email)
+		if idErr != nil {
+			return traffic, nil, idErr
+		}
+		if len(ids) > 0 {
+			inbound, err = s.GetInbound(ids[0])
+		}
 	}
-	return nil, nil, nil
+	return traffic, inbound, err
 }
 
 func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
@@ -2416,11 +2474,26 @@ func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.Cl
 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
 		return nil, nil, err
 	}
-	if len(traffics) > 0 {
-		inbound, err = s.GetInbound(traffics[0].InboundId)
-		return traffics[0], inbound, err
+	if len(traffics) == 0 {
+		return nil, nil, nil
+	}
+	traffic = traffics[0]
+
+	inbound, err = s.GetInbound(traffic.InboundId)
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		// client_traffics.inbound_id is a legacy single-inbound pointer that goes
+		// stale when an inbound is deleted and recreated: the email-keyed traffic
+		// row survives but still references the missing inbound. Fall back to the
+		// authoritative client_inbounds link so email lookups (reset, info, …) work.
+		ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
+		if idErr != nil {
+			return traffic, nil, idErr
+		}
+		if len(ids) > 0 {
+			inbound, err = s.GetInbound(ids[0])
+		}
 	}
-	return nil, nil, nil
+	return traffic, inbound, err
 }
 
 func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {

+ 72 - 0
web/service/inbound_client_traffic_test.go

@@ -3,6 +3,7 @@ package service
 import (
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -76,3 +77,74 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
 		t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
 	}
 }
+
+// TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId covers "Start After
+// First Use": a delayed-start client carries a negative expiry (the duration) that
+// must convert to an absolute deadline on its first traffic tick. When the client's
+// email-keyed client_traffics row still points at a deleted inbound (stale inbound_id
+// after an inbound delete+recreate), the conversion used to resolve no inbound and
+// silently skip, leaving the client perpetually "not started". The fix resolves the
+// owning inbound via the client_inbounds link instead.
+func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const email = "delayed-user"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
+	const sevenDays = int64(7 * 86400000)
+
+	client := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, ExpiryTime: -sevenDays}
+	inbound := &model.Inbound{
+		Tag: "vless-delayed", Enable: true, Port: 45001, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`,
+		Settings:       clientsSettings(t, []model.Client{client}),
+	}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := InboundService{}
+	if err := svc.clientService.SyncInbound(db, inbound.Id, []model.Client{client}); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+
+	// The email-keyed traffic row survives an inbound delete+recreate pointing at a
+	// dead inbound id; client_inbounds still links the client to the live inbound.
+	if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: email, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
+		t.Fatalf("create stale traffic row: %v", err)
+	}
+
+	before := time.Now().UnixMilli()
+	if err := svc.addClientTraffic(db, []*xray.ClientTraffic{{Email: email, Up: 100, Down: 200}}); err != nil {
+		t.Fatalf("addClientTraffic: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("reload traffic row: %v", err)
+	}
+	if row.ExpiryTime <= 0 {
+		t.Fatalf("delayed-start expiry not converted: still %d (stale inbound_id skipped the conversion)", row.ExpiryTime)
+	}
+	if row.ExpiryTime < before+sevenDays-5000 || row.ExpiryTime > before+sevenDays+5000 {
+		t.Errorf("converted expiry = %d, want ~now+7d (%d)", row.ExpiryTime, before+sevenDays)
+	}
+
+	reloaded, err := svc.GetInbound(inbound.Id)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	cs, err := svc.GetClients(reloaded)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	if len(cs) != 1 || cs[0].ExpiryTime <= 0 {
+		t.Errorf("inbound settings expiry not converted: %#v", cs)
+	}
+}

+ 86 - 1
web/service/metric_history.go

@@ -1,8 +1,14 @@
 package service
 
 import (
+	"encoding/gob"
+	"os"
+	"path/filepath"
 	"sync"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
 )
 
 // MetricSample is one point of any time-series we keep in memory.
@@ -59,6 +65,34 @@ func (h *metricHistory) drop(metric string) {
 	h.mu.Unlock()
 }
 
+// snapshot returns a deep copy of every series, safe to serialize without
+// holding the lock during disk I/O.
+func (h *metricHistory) snapshot() map[string][]MetricSample {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	out := make(map[string][]MetricSample, len(h.metrics))
+	for k, v := range h.metrics {
+		cp := make([]MetricSample, len(v))
+		copy(cp, v)
+		out[k] = cp
+	}
+	return out
+}
+
+// restore replaces the in-memory series with a previously persisted set,
+// re-applying the per-series capacity cap so a tampered or oversized file
+// can't grow the working set unbounded.
+func (h *metricHistory) restore(data map[string][]MetricSample) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	for k, v := range data {
+		if len(v) > metricCapacityDefault {
+			v = v[len(v)-metricCapacityDefault:]
+		}
+		h.metrics[k] = v
+	}
+}
+
 // aggregate returns up to maxPoints buckets of size bucketSeconds,
 // each bucket carrying the arithmetic mean of the underlying samples.
 // Bucket alignment is to absolute Unix-second boundaries so two
@@ -137,7 +171,7 @@ var (
 // status sample. Exposed for documentation/test purposes; the
 // controller validates incoming names against an allow-list.
 var SystemMetricKeys = []string{
-	"cpu", "mem", "netUp", "netDown", "online", "load1", "load5", "load15",
+	"cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
 }
 
 // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
@@ -150,3 +184,54 @@ var NodeMetricKeys = []string{"cpu", "mem"}
 var XrayMetricKeys = []string{
 	"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
 }
+
+// systemMetricsStorePath is where the host time-series is persisted between
+// restarts. It lives next to the database so a single volume mount carries
+// both. Only systemMetrics is persisted — node and xray series are cheap to
+// rebuild and tied to live connections.
+func systemMetricsStorePath() string {
+	return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
+}
+
+// PersistSystemMetrics writes the host time-series to disk via a temp file +
+// rename so a crash mid-write can't corrupt the previous snapshot. Called on a
+// timer and at shutdown.
+func PersistSystemMetrics() error {
+	path := systemMetricsStorePath()
+	tmp := path + ".tmp"
+	f, err := os.Create(tmp)
+	if err != nil {
+		return err
+	}
+	if err := gob.NewEncoder(f).Encode(systemMetrics.snapshot()); err != nil {
+		f.Close()
+		os.Remove(tmp)
+		return err
+	}
+	if err := f.Close(); err != nil {
+		os.Remove(tmp)
+		return err
+	}
+	return os.Rename(tmp, path)
+}
+
+// RestoreSystemMetrics loads a previously persisted host time-series on startup.
+// A missing file is not an error (first boot). Aggregation already windows by
+// time, so any gap from downtime is handled by the readers.
+func RestoreSystemMetrics() {
+	path := systemMetricsStorePath()
+	f, err := os.Open(path)
+	if err != nil {
+		if !os.IsNotExist(err) {
+			logger.Warning("restore system metrics failed:", err)
+		}
+		return
+	}
+	defer f.Close()
+	var data map[string][]MetricSample
+	if err := gob.NewDecoder(f).Decode(&data); err != nil {
+		logger.Warning("decode system metrics failed:", err)
+		return
+	}
+	systemMetrics.restore(data)
+}

+ 103 - 0
web/service/panel_proxy_test.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/util/netproxy"
+)
+
+func recordingProxy(t *testing.T, hits *int64) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		atomic.AddInt64(hits, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+}
+
+func originServer(t *testing.T, hits *int64) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		atomic.AddInt64(hits, 1)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+}
+
+func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	client, err := netproxy.NewHTTPClient(proxy.URL, 5*time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	resp, err := client.Get(origin.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = resp.Body.Close()
+
+	if atomic.LoadInt64(&proxyHits) != 1 {
+		t.Fatalf("expected panel proxy to be hit once, got %d (origin hits=%d)", proxyHits, originHits)
+	}
+}
+
+func TestPanelProxy_CustomGeoDownloadUsesProxy(t *testing.T) {
+	disableSSRFCheck(t)
+
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geosite_repro.dat")
+
+	s := CustomGeoService{getPanelProxy: func() (string, error) { return proxy.URL, nil }}
+	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
+		t.Fatalf("download failed: %v", err)
+	}
+	if _, err := os.Stat(dest); err != nil {
+		t.Fatalf("expected file to be written: %v", err)
+	}
+
+	if got := atomic.LoadInt64(&proxyHits); got != 1 {
+		t.Fatalf("custom geo download did not route through the Panel Network Proxy "+
+			"(proxy hits=%d, origin hits=%d)", got, atomic.LoadInt64(&originHits))
+	}
+}
+
+func TestPanelProxy_CustomGeoDownloadDirectWhenUnset(t *testing.T) {
+	disableSSRFCheck(t)
+
+	var proxyHits, originHits int64
+	proxy := recordingProxy(t, &proxyHits)
+	defer proxy.Close()
+	origin := originServer(t, &originHits)
+	defer origin.Close()
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geosite_direct.dat")
+
+	s := CustomGeoService{}
+	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
+		t.Fatalf("download failed: %v", err)
+	}
+	if atomic.LoadInt64(&proxyHits) != 0 || atomic.LoadInt64(&originHits) != 1 {
+		t.Fatalf("expected direct connection (proxy=0, origin=1), got proxy=%d origin=%d",
+			atomic.LoadInt64(&proxyHits), atomic.LoadInt64(&originHits))
+	}
+}

+ 65 - 5
web/service/server.go

@@ -67,6 +67,14 @@ type Status struct {
 		Current uint64 `json:"current"`
 		Total   uint64 `json:"total"`
 	} `json:"disk"`
+	DiskIO struct {
+		Read  uint64 `json:"read"`
+		Write uint64 `json:"write"`
+	} `json:"diskIO"`
+	DiskTraffic struct {
+		Read  uint64 `json:"read"`
+		Write uint64 `json:"write"`
+	} `json:"diskTraffic"`
 	Xray struct {
 		State    ProcessState `json:"state"`
 		ErrorMsg string       `json:"errorMsg"`
@@ -78,12 +86,16 @@ type Status struct {
 	TcpCount     int       `json:"tcpCount"`
 	UdpCount     int       `json:"udpCount"`
 	NetIO        struct {
-		Up   uint64 `json:"up"`
-		Down uint64 `json:"down"`
+		Up      uint64 `json:"up"`
+		Down    uint64 `json:"down"`
+		PktUp   uint64 `json:"pktUp"`
+		PktDown uint64 `json:"pktDown"`
 	} `json:"netIO"`
 	NetTraffic struct {
-		Sent uint64 `json:"sent"`
-		Recv uint64 `json:"recv"`
+		Sent    uint64 `json:"sent"`
+		Recv    uint64 `json:"recv"`
+		PktSent uint64 `json:"pktSent"`
+		PktRecv uint64 `json:"pktRecv"`
 	} `json:"netTraffic"`
 	PublicIP struct {
 		IPv4 string `json:"ipv4"`
@@ -383,6 +395,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		status.Disk.Total = diskInfo.Total
 	}
 
+	diskIOStats, err := disk.IOCounters()
+	if err != nil {
+		logger.Warning("get disk io counters failed:", err)
+	} else {
+		var totalRead, totalWrite uint64
+		for _, counter := range diskIOStats {
+			totalRead += counter.ReadBytes
+			totalWrite += counter.WriteBytes
+		}
+		status.DiskTraffic.Read = totalRead
+		status.DiskTraffic.Write = totalWrite
+
+		if lastStatus != nil {
+			duration := now.Sub(lastStatus.T)
+			seconds := float64(duration) / float64(time.Second)
+			if seconds > 0 && status.DiskTraffic.Read >= lastStatus.DiskTraffic.Read {
+				status.DiskIO.Read = uint64(float64(status.DiskTraffic.Read-lastStatus.DiskTraffic.Read) / seconds)
+			}
+			if seconds > 0 && status.DiskTraffic.Write >= lastStatus.DiskTraffic.Write {
+				status.DiskIO.Write = uint64(float64(status.DiskTraffic.Write-lastStatus.DiskTraffic.Write) / seconds)
+			}
+		}
+	}
+
 	// Load averages
 	avgState, err := load.Avg()
 	if err != nil {
@@ -396,7 +432,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	if err != nil {
 		logger.Warning("get io counters failed:", err)
 	} else {
-		var totalSent, totalRecv uint64
+		var totalSent, totalRecv, totalPktSent, totalPktRecv uint64
 		for _, iface := range ioStats {
 			name := strings.ToLower(iface.Name)
 			if isVirtualInterface(name) {
@@ -404,9 +440,13 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 			}
 			totalSent += iface.BytesSent
 			totalRecv += iface.BytesRecv
+			totalPktSent += iface.PacketsSent
+			totalPktRecv += iface.PacketsRecv
 		}
 		status.NetTraffic.Sent = totalSent
 		status.NetTraffic.Recv = totalRecv
+		status.NetTraffic.PktSent = totalPktSent
+		status.NetTraffic.PktRecv = totalPktRecv
 
 		if lastStatus != nil {
 			duration := now.Sub(lastStatus.T)
@@ -415,6 +455,12 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 			down := uint64(float64(status.NetTraffic.Recv-lastStatus.NetTraffic.Recv) / seconds)
 			status.NetIO.Up = up
 			status.NetIO.Down = down
+			if seconds > 0 && status.NetTraffic.PktSent >= lastStatus.NetTraffic.PktSent {
+				status.NetIO.PktUp = uint64(float64(status.NetTraffic.PktSent-lastStatus.NetTraffic.PktSent) / seconds)
+			}
+			if seconds > 0 && status.NetTraffic.PktRecv >= lastStatus.NetTraffic.PktRecv {
+				status.NetIO.PktDown = uint64(float64(status.NetTraffic.PktRecv-lastStatus.NetTraffic.PktRecv) / seconds)
+			}
 		}
 	}
 
@@ -519,8 +565,22 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
 	if status.Mem.Total > 0 {
 		systemMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
 	}
+	if status.Swap.Total > 0 {
+		systemMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
+	} else {
+		systemMetrics.append("swap", t, 0)
+	}
 	systemMetrics.append("netUp", t, float64(status.NetIO.Up))
 	systemMetrics.append("netDown", t, float64(status.NetIO.Down))
+	systemMetrics.append("diskRead", t, float64(status.DiskIO.Read))
+	systemMetrics.append("diskWrite", t, float64(status.DiskIO.Write))
+	if status.Disk.Total > 0 {
+		systemMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
+	}
+	systemMetrics.append("pktUp", t, float64(status.NetIO.PktUp))
+	systemMetrics.append("pktDown", t, float64(status.NetIO.PktDown))
+	systemMetrics.append("tcpCount", t, float64(status.TcpCount))
+	systemMetrics.append("udpCount", t, float64(status.UdpCount))
 	online := 0
 	if p != nil && p.IsRunning() {
 		online = len(p.GetOnlineClients())

+ 17 - 12
web/service/tgbot.go

@@ -247,12 +247,11 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 	}
 
 	// Fall back to the panel-wide proxy when no dedicated bot proxy is set.
-	// The bot's fasthttp dialer only supports SOCKS5, so other schemes are ignored.
 	if tgBotProxy == "" {
 		panelProxy, perr := t.settingService.GetPanelProxy()
 		if perr != nil {
 			logger.Warning("Failed to get panel proxy URL:", perr)
-		} else if strings.HasPrefix(panelProxy, "socks5://") {
+		} else if isSupportedBotProxyScheme(panelProxy) {
 			tgBotProxy = panelProxy
 		}
 	}
@@ -304,6 +303,12 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
 	}
 }
 
+func isSupportedBotProxyScheme(proxyUrl string) bool {
+	return strings.HasPrefix(proxyUrl, "socks5://") ||
+		strings.HasPrefix(proxyUrl, "http://") ||
+		strings.HasPrefix(proxyUrl, "https://")
+}
+
 // createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
 func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 	client := &fasthttp.Client{
@@ -326,9 +331,12 @@ func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 		},
 	}
 
-	// Set proxy if provided
 	if proxyUrl != "" {
-		client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
+		if strings.HasPrefix(proxyUrl, "socks5://") {
+			client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
+		} else {
+			client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
+		}
 	}
 
 	return client
@@ -338,15 +346,12 @@ func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
 func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
 	// Validate proxy URL if provided
 	if proxyUrl != "" {
-		if !strings.HasPrefix(proxyUrl, "socks5://") {
-			logger.Warning("Invalid socks5 URL, ignoring proxy")
+		if !isSupportedBotProxyScheme(proxyUrl) {
+			logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
 			proxyUrl = "" // Clear invalid proxy
-		} else {
-			_, err := url.Parse(proxyUrl)
-			if err != nil {
-				logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
-				proxyUrl = ""
-			}
+		} else if _, err := url.Parse(proxyUrl); err != nil {
+			logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
+			proxyUrl = ""
 		}
 	}
 

+ 88 - 0
web/service/tgbot_test.go

@@ -1,8 +1,11 @@
 package service
 
 import (
+	"io"
+	"net"
 	"reflect"
 	"testing"
+	"time"
 )
 
 func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
@@ -11,3 +14,88 @@ func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
 		t.Fatal("LoginAttempt must not carry attempted passwords")
 	}
 }
+
+func TestIsSupportedBotProxyScheme(t *testing.T) {
+	supported := []string{
+		"socks5://127.0.0.1:1080",
+		"http://127.0.0.1:8080",
+		"https://127.0.0.1:8080",
+	}
+	for _, p := range supported {
+		if !isSupportedBotProxyScheme(p) {
+			t.Errorf("expected %q to be supported", p)
+		}
+	}
+	unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
+	for _, p := range unsupported {
+		if isSupportedBotProxyScheme(p) {
+			t.Errorf("expected %q to be unsupported", p)
+		}
+	}
+}
+
+func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got = make(chan []byte, 1)
+	t.Cleanup(func() { _ = ln.Close() })
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+		buf := make([]byte, n)
+		m, _ := io.ReadFull(conn, buf)
+		got <- buf[:m]
+	}()
+	return ln.Addr().String(), got
+}
+
+func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
+	addr, got := recordingDialTarget(t, len("CONNECT "))
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("http://" + addr)
+	if client.Dial == nil {
+		t.Fatal("Dial must be set for an http:// proxy")
+	}
+	go func() { _, _ = client.Dial("example.com:443") }()
+	select {
+	case b := <-got:
+		if string(b) != "CONNECT " {
+			t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("proxy never received a connection")
+	}
+}
+
+func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
+	addr, got := recordingDialTarget(t, 1)
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("socks5://" + addr)
+	if client.Dial == nil {
+		t.Fatal("Dial must be set for a socks5:// proxy")
+	}
+	go func() { _, _ = client.Dial("example.com:443") }()
+	select {
+	case b := <-got:
+		if len(b) != 1 || b[0] != 0x05 {
+			t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("proxy never received a connection")
+	}
+}
+
+func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
+	tg := &Tgbot{}
+	client := tg.createRobustFastHTTPClient("")
+	if client.Dial != nil {
+		t.Fatal("Dial must be nil when no proxy is configured")
+	}
+}

+ 32 - 3
web/translation/ar-EG.json

@@ -128,12 +128,12 @@
     },
     "index": {
       "title": "نظرة عامة",
-      "cpu": "CPU",
+      "cpu": "المعالج",
       "logicalProcessors": "المعالجات المنطقية",
       "frequency": "التردد",
-      "swap": "Swap",
+      "swap": "التبديل",
       "storage": "تخزين",
-      "memory": "RAM",
+      "memory": "الذاكرة",
       "threads": "خيوط",
       "xrayStatus": "Xray",
       "stopXray": "إيقاف",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
       "operationHours": "مدة التشغيل",
       "systemHistoryTitle": "تاريخ النظام",
+      "historyTitleCpu": "استخدام المعالج",
+      "historyTitleMem": "استخدام الذاكرة",
+      "historyTitleNetwork": "عرض النطاق الترددي للشبكة",
+      "historyTitlePackets": "حزم الشبكة",
+      "historyTitleDisk": "إدخال/إخراج القرص",
+      "historyTitleOnline": "العملاء المتصلون",
+      "historyTitleLoad": "متوسط حمل النظام (1 / 5 / 15 دقيقة)",
+      "historyTitleConnections": "الاتصالات النشطة (TCP / UDP)",
+      "historyTitleDiskUsage": "استخدام مساحة القرص",
+      "historyTabBandwidth": "عرض النطاق",
+      "historyTabPackets": "الحزم",
+      "historyTabDisk": "قرص I/O",
+      "historyTabOnline": "متصل",
+      "historyTabLoad": "الحِمل",
+      "historyTabConnections": "الاتصالات",
+      "historyTabDiskUsage": "استخدام القرص",
       "charts": "الرسوم البيانية",
       "xrayMetricsTitle": "مقاييس Xray",
+      "xrayTitleHeap": "ذاكرة الكومة المخصصة",
+      "xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
+      "xrayTitleObjects": "كائنات الكومة النشطة",
+      "xrayTitleGcCount": "دورات GC المكتملة",
+      "xrayTitleGcPause": "مدة توقف GC",
+      "xrayTitleObservatory": "صحة الاتصال الصادر",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "الكائنات",
+      "xrayTabGcCount": "عدد GC",
+      "xrayTabGcPause": "توقف GC",
+      "xrayTabObservatory": "المرصد",
       "xrayMetricsDisabled": "نقطة نهاية مقاييس Xray غير مهيأة",
       "xrayMetricsHint": "أضف كتلة metrics على المستوى الأعلى في إعدادات xray مع tag باسم metrics_out و listen على 127.0.0.1:11111، ثم أعد تشغيل xray.",
       "xrayObservatoryEmpty": "لا توجد بيانات Observatory بعد",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "معامل CWND",
         "maxSendingWindow": "أقصى نافذة إرسال",
         "externalProxy": "وكيل خارجي",
+        "forceTls": "فرض TLS",
         "sniPlaceholder": "SNI (افتراضياً host)",
         "fingerprint": "بصمة",
         "defaultOption": "افتراضي",

+ 29 - 0
web/translation/en-US.json

@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "An error occurred while running Xray",
       "operationHours": "Uptime",
       "systemHistoryTitle": "System History",
+      "historyTitleCpu": "CPU Usage",
+      "historyTitleMem": "Memory Usage",
+      "historyTitleNetwork": "Network Bandwidth",
+      "historyTitlePackets": "Network Packets",
+      "historyTitleDisk": "Disk I/O",
+      "historyTitleOnline": "Online Clients",
+      "historyTitleLoad": "System Load Average (1m / 5m / 15m)",
+      "historyTitleConnections": "Active Connections (TCP / UDP)",
+      "historyTitleDiskUsage": "Disk Space Usage",
+      "historyTabBandwidth": "Bandwidth",
+      "historyTabPackets": "Packets",
+      "historyTabDisk": "Disk I/O",
+      "historyTabOnline": "Online",
+      "historyTabLoad": "Load",
+      "historyTabConnections": "Connections",
+      "historyTabDiskUsage": "Disk Usage",
       "charts": "Charts",
       "xrayMetricsTitle": "Xray Metrics",
+      "xrayTitleHeap": "Allocated Heap Memory",
+      "xrayTitleSys": "Memory Reserved from OS",
+      "xrayTitleObjects": "Live Heap Objects",
+      "xrayTitleGcCount": "Completed GC Cycles",
+      "xrayTitleGcPause": "GC Pause Duration",
+      "xrayTitleObservatory": "Outbound Connection Health",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Objects",
+      "xrayTabGcCount": "GC Count",
+      "xrayTabGcPause": "GC Pause",
+      "xrayTabObservatory": "Observatory",
       "xrayMetricsDisabled": "Xray metrics endpoint not configured",
       "xrayMetricsHint": "Add a top-level metrics block to the xray config with tag metrics_out and listen 127.0.0.1:11111, then restart xray.",
       "xrayObservatoryEmpty": "No observatory data yet",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "CWND Multiplier",
         "maxSendingWindow": "Max Sending Window",
         "externalProxy": "External Proxy",
+        "forceTls": "Force TLS",
         "sniPlaceholder": "SNI (defaults to host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",

+ 30 - 1
web/translation/es-ES.json

@@ -133,7 +133,7 @@
       "frequency": "Frecuencia",
       "swap": "Swap",
       "storage": "Almacenamiento",
-      "memory": "RAM",
+      "memory": "Memoria",
       "threads": "Hilos",
       "xrayStatus": "Xray",
       "stopXray": "Detener",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
       "operationHours": "Tiempo de Funcionamiento",
       "systemHistoryTitle": "Historial del Sistema",
+      "historyTitleCpu": "Uso de CPU",
+      "historyTitleMem": "Uso de Memoria",
+      "historyTitleNetwork": "Ancho de Banda de Red",
+      "historyTitlePackets": "Paquetes de Red",
+      "historyTitleDisk": "E/S de Disco",
+      "historyTitleOnline": "Clientes en Línea",
+      "historyTitleLoad": "Carga Media del Sistema (1 / 5 / 15 min)",
+      "historyTitleConnections": "Conexiones Activas (TCP / UDP)",
+      "historyTitleDiskUsage": "Uso del Espacio en Disco",
+      "historyTabBandwidth": "Ancho de Banda",
+      "historyTabPackets": "Paquetes",
+      "historyTabDisk": "Disco I/O",
+      "historyTabOnline": "En línea",
+      "historyTabLoad": "Carga",
+      "historyTabConnections": "Conexiones",
+      "historyTabDiskUsage": "Uso de Disco",
       "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas de Xray",
+      "xrayTitleHeap": "Memoria Heap Asignada",
+      "xrayTitleSys": "Memoria Reservada del SO",
+      "xrayTitleObjects": "Objetos Heap Activos",
+      "xrayTitleGcCount": "Ciclos de GC Completados",
+      "xrayTitleGcPause": "Duración de Pausa de GC",
+      "xrayTitleObservatory": "Estado de Conexiones Salientes",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Objetos",
+      "xrayTabGcCount": "Recuento GC",
+      "xrayTabGcPause": "Pausa GC",
+      "xrayTabObservatory": "Observatorio",
       "xrayMetricsDisabled": "Endpoint de métricas de Xray no configurado",
       "xrayMetricsHint": "Añade un bloque metrics de nivel superior a la configuración de xray con tag metrics_out y listen 127.0.0.1:11111, luego reinicia xray.",
       "xrayObservatoryEmpty": "Aún no hay datos de Observatory",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Multiplicador CWND",
         "maxSendingWindow": "Máx. ventana de envío",
         "externalProxy": "Proxy externo",
+        "forceTls": "Forzar TLS",
         "sniPlaceholder": "SNI (por defecto = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Por defecto",

+ 32 - 3
web/translation/fa-IR.json

@@ -128,12 +128,12 @@
     },
     "index": {
       "title": "نمای کلی",
-      "cpu": "CPU",
+      "cpu": "پردازنده",
       "logicalProcessors": "پردازنده‌های منطقی",
       "frequency": "فرکانس",
-      "swap": "Swap",
+      "swap": "سواپ",
       "storage": "ذخیره‌سازی",
-      "memory": "RAM",
+      "memory": "حافظه",
       "threads": "نخ‌ها",
       "xrayStatus": "Xray",
       "stopXray": "توقف",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
       "operationHours": "مدت‌کارکرد",
       "systemHistoryTitle": "تاریخچه سیستم",
+      "historyTitleCpu": "مصرف پردازنده",
+      "historyTitleMem": "مصرف حافظه",
+      "historyTitleNetwork": "پهنای باند شبکه",
+      "historyTitlePackets": "بسته‌های شبکه",
+      "historyTitleDisk": "ورودی/خروجی دیسک",
+      "historyTitleOnline": "کاربران آنلاین",
+      "historyTitleLoad": "میانگین بار سیستم (۱ / ۵ / ۱۵ دقیقه)",
+      "historyTitleConnections": "اتصالات فعال (TCP / UDP)",
+      "historyTitleDiskUsage": "مصرف فضای دیسک",
+      "historyTabBandwidth": "پهنای باند",
+      "historyTabPackets": "بسته‌ها",
+      "historyTabDisk": "دیسک I/O",
+      "historyTabOnline": "آنلاین",
+      "historyTabLoad": "بار",
+      "historyTabConnections": "اتصالات",
+      "historyTabDiskUsage": "مصرف دیسک",
       "charts": "نمودارها",
       "xrayMetricsTitle": "متریک‌های Xray",
+      "xrayTitleHeap": "حافظه‌ی Heap تخصیص‌یافته",
+      "xrayTitleSys": "حافظه‌ی رزروشده از سیستم‌عامل",
+      "xrayTitleObjects": "اشیای زنده‌ی Heap",
+      "xrayTitleGcCount": "چرخه‌های کامل‌شده‌ی GC",
+      "xrayTitleGcPause": "مدت مکث GC",
+      "xrayTitleObservatory": "سلامت اتصال خروجی",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "اشیا",
+      "xrayTabGcCount": "تعداد GC",
+      "xrayTabGcPause": "مکث GC",
+      "xrayTabObservatory": "رصدخانه",
       "xrayMetricsDisabled": "نقطه پایانی متریک‌های Xray پیکربندی نشده",
       "xrayMetricsHint": "یک بلاک metrics در سطح بالای پیکربندی xray با tag برابر metrics_out و listen برابر 127.0.0.1:11111 اضافه کنید، سپس xray را راه‌اندازی مجدد کنید.",
       "xrayObservatoryEmpty": "هنوز داده‌ای از Observatory دریافت نشده",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "ضریب CWND",
         "maxSendingWindow": "حداکثر پنجره ارسال",
         "externalProxy": "پراکسی خارجی",
+        "forceTls": "اجبار TLS",
         "sniPlaceholder": "SNI (پیش‌فرض همان host)",
         "fingerprint": "اثرانگشت",
         "defaultOption": "پیش‌فرض",

+ 30 - 1
web/translation/id-ID.json

@@ -133,7 +133,7 @@
       "frequency": "Frekuensi",
       "swap": "Swap",
       "storage": "Penyimpanan",
-      "memory": "RAM",
+      "memory": "Memori",
       "threads": "Thread",
       "xrayStatus": "Xray",
       "stopXray": "Hentikan",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
       "operationHours": "Waktu Aktif",
       "systemHistoryTitle": "Riwayat Sistem",
+      "historyTitleCpu": "Penggunaan CPU",
+      "historyTitleMem": "Penggunaan Memori",
+      "historyTitleNetwork": "Bandwidth Jaringan",
+      "historyTitlePackets": "Paket Jaringan",
+      "historyTitleDisk": "I/O Disk",
+      "historyTitleOnline": "Klien Online",
+      "historyTitleLoad": "Rata-rata Beban Sistem (1 / 5 / 15 mnt)",
+      "historyTitleConnections": "Koneksi Aktif (TCP / UDP)",
+      "historyTitleDiskUsage": "Penggunaan Ruang Disk",
+      "historyTabBandwidth": "Bandwidth",
+      "historyTabPackets": "Paket",
+      "historyTabDisk": "Disk I/O",
+      "historyTabOnline": "Online",
+      "historyTabLoad": "Beban",
+      "historyTabConnections": "Koneksi",
+      "historyTabDiskUsage": "Penggunaan Disk",
       "charts": "Grafik",
       "xrayMetricsTitle": "Metrik Xray",
+      "xrayTitleHeap": "Memori Heap Teralokasi",
+      "xrayTitleSys": "Memori Dicadangkan dari OS",
+      "xrayTitleObjects": "Objek Heap Aktif",
+      "xrayTitleGcCount": "Siklus GC Selesai",
+      "xrayTitleGcPause": "Durasi Jeda GC",
+      "xrayTitleObservatory": "Kesehatan Koneksi Keluar",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Objek",
+      "xrayTabGcCount": "Jumlah GC",
+      "xrayTabGcPause": "Jeda GC",
+      "xrayTabObservatory": "Observatorium",
       "xrayMetricsDisabled": "Endpoint metrik Xray belum dikonfigurasi",
       "xrayMetricsHint": "Tambahkan blok metrics tingkat atas ke konfigurasi xray dengan tag metrics_out dan listen 127.0.0.1:11111, lalu mulai ulang xray.",
       "xrayObservatoryEmpty": "Belum ada data Observatory",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Pengganda CWND",
         "maxSendingWindow": "Maks. jendela pengiriman",
         "externalProxy": "Proxy eksternal",
+        "forceTls": "Paksa TLS",
         "sniPlaceholder": "SNI (default = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",

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

@@ -131,9 +131,9 @@
       "cpu": "CPU",
       "logicalProcessors": "論理プロセッサ",
       "frequency": "周波数",
-      "swap": "Swap",
+      "swap": "スワップ",
       "storage": "ストレージ",
-      "memory": "RAM",
+      "memory": "メモリ",
       "threads": "スレッド",
       "xrayStatus": "Xray",
       "stopXray": "停止",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
       "operationHours": "システム稼働時間",
       "systemHistoryTitle": "システム履歴",
+      "historyTitleCpu": "CPU 使用率",
+      "historyTitleMem": "メモリ使用率",
+      "historyTitleNetwork": "ネットワーク帯域幅",
+      "historyTitlePackets": "ネットワークパケット",
+      "historyTitleDisk": "ディスク I/O",
+      "historyTitleOnline": "オンラインクライアント",
+      "historyTitleLoad": "システム平均負荷(1分 / 5分 / 15分)",
+      "historyTitleConnections": "アクティブな接続 (TCP / UDP)",
+      "historyTitleDiskUsage": "ディスク使用率",
+      "historyTabBandwidth": "帯域幅",
+      "historyTabPackets": "パケット",
+      "historyTabDisk": "ディスク I/O",
+      "historyTabOnline": "オンライン",
+      "historyTabLoad": "負荷",
+      "historyTabConnections": "接続数",
+      "historyTabDiskUsage": "ディスク使用量",
       "charts": "チャート",
       "xrayMetricsTitle": "Xray メトリクス",
+      "xrayTitleHeap": "割り当て済みヒープメモリ",
+      "xrayTitleSys": "OS から確保したメモリ",
+      "xrayTitleObjects": "ヒープオブジェクト数",
+      "xrayTitleGcCount": "完了した GC サイクル",
+      "xrayTitleGcPause": "GC 一時停止時間",
+      "xrayTitleObservatory": "アウトバウンド接続の状態",
+      "xrayTabHeap": "ヒープ",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "オブジェクト",
+      "xrayTabGcCount": "GC 回数",
+      "xrayTabGcPause": "GC 一時停止",
+      "xrayTabObservatory": "オブザーバトリ",
       "xrayMetricsDisabled": "Xray メトリクスエンドポイントが設定されていません",
       "xrayMetricsHint": "xray 設定にトップレベルの metrics ブロック(tag: metrics_out、listen: 127.0.0.1:11111)を追加し、xray を再起動してください。",
       "xrayObservatoryEmpty": "Observatory データはまだありません",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "CWND 倍率",
         "maxSendingWindow": "最大送信ウィンドウ",
         "externalProxy": "外部プロキシ",
+        "forceTls": "TLS を強制",
         "sniPlaceholder": "SNI (デフォルトは host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "デフォルト",

+ 30 - 1
web/translation/pt-BR.json

@@ -133,7 +133,7 @@
       "frequency": "Frequência",
       "swap": "Swap",
       "storage": "Armazenamento",
-      "memory": "RAM",
+      "memory": "Memória",
       "threads": "Threads",
       "xrayStatus": "Xray",
       "stopXray": "Parar",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
       "operationHours": "Tempo de Atividade",
       "systemHistoryTitle": "Histórico do Sistema",
+      "historyTitleCpu": "Uso da CPU",
+      "historyTitleMem": "Uso de Memória",
+      "historyTitleNetwork": "Largura de Banda da Rede",
+      "historyTitlePackets": "Pacotes de Rede",
+      "historyTitleDisk": "E/S de Disco",
+      "historyTitleOnline": "Clientes Online",
+      "historyTitleLoad": "Média de Carga do Sistema (1 / 5 / 15 min)",
+      "historyTitleConnections": "Conexões Ativas (TCP / UDP)",
+      "historyTitleDiskUsage": "Uso do Espaço em Disco",
+      "historyTabBandwidth": "Largura de Banda",
+      "historyTabPackets": "Pacotes",
+      "historyTabDisk": "Disco I/O",
+      "historyTabOnline": "Online",
+      "historyTabLoad": "Carga",
+      "historyTabConnections": "Conexões",
+      "historyTabDiskUsage": "Uso de Disco",
       "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas do Xray",
+      "xrayTitleHeap": "Memória Heap Alocada",
+      "xrayTitleSys": "Memória Reservada do SO",
+      "xrayTitleObjects": "Objetos Heap Ativos",
+      "xrayTitleGcCount": "Ciclos de GC Concluídos",
+      "xrayTitleGcPause": "Duração da Pausa do GC",
+      "xrayTitleObservatory": "Saúde das Conexões de Saída",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Objetos",
+      "xrayTabGcCount": "Contagem GC",
+      "xrayTabGcPause": "Pausa GC",
+      "xrayTabObservatory": "Observatório",
       "xrayMetricsDisabled": "Endpoint de métricas do Xray não configurado",
       "xrayMetricsHint": "Adicione um bloco metrics de nível superior à configuração do xray com tag metrics_out e listen 127.0.0.1:11111, depois reinicie o xray.",
       "xrayObservatoryEmpty": "Ainda não há dados do Observatory",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Multiplicador CWND",
         "maxSendingWindow": "Máx. janela de envio",
         "externalProxy": "Proxy externo",
+        "forceTls": "Forçar TLS",
         "sniPlaceholder": "SNI (padrão = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Padrão",

+ 32 - 3
web/translation/ru-RU.json

@@ -128,12 +128,12 @@
     },
     "index": {
       "title": "Дашборд",
-      "cpu": "CPU",
+      "cpu": "ЦП",
       "logicalProcessors": "Логические процессоры",
       "frequency": "Частота",
-      "swap": "Swap",
+      "swap": "Подкачка",
       "storage": "Диск",
-      "memory": "RAM",
+      "memory": "Память",
       "threads": "Потоки",
       "xrayStatus": "Xray",
       "stopXray": "Стоп",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Ошибка при запуске Xray",
       "operationHours": "Время работы системы",
       "systemHistoryTitle": "История системы",
+      "historyTitleCpu": "Загрузка ЦП",
+      "historyTitleMem": "Использование памяти",
+      "historyTitleNetwork": "Пропускная способность сети",
+      "historyTitlePackets": "Сетевые пакеты",
+      "historyTitleDisk": "Дисковый ввод-вывод",
+      "historyTitleOnline": "Клиенты онлайн",
+      "historyTitleLoad": "Средняя нагрузка системы (1 / 5 / 15 мин)",
+      "historyTitleConnections": "Активные соединения (TCP / UDP)",
+      "historyTitleDiskUsage": "Использование дискового пространства",
+      "historyTabBandwidth": "Пропускная способность",
+      "historyTabPackets": "Пакеты",
+      "historyTabDisk": "Диск I/O",
+      "historyTabOnline": "Онлайн",
+      "historyTabLoad": "Нагрузка",
+      "historyTabConnections": "Соединения",
+      "historyTabDiskUsage": "Использование диска",
       "charts": "Графики",
       "xrayMetricsTitle": "Метрики Xray",
+      "xrayTitleHeap": "Выделенная память кучи",
+      "xrayTitleSys": "Память, зарезервированная у ОС",
+      "xrayTitleObjects": "Активные объекты кучи",
+      "xrayTitleGcCount": "Завершённые циклы GC",
+      "xrayTitleGcPause": "Длительность паузы GC",
+      "xrayTitleObservatory": "Состояние исходящих соединений",
+      "xrayTabHeap": "Куча",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Объекты",
+      "xrayTabGcCount": "Счётчик GC",
+      "xrayTabGcPause": "Пауза GC",
+      "xrayTabObservatory": "Обсерватория",
       "xrayMetricsDisabled": "Конечная точка метрик Xray не настроена",
       "xrayMetricsHint": "Добавьте блок metrics верхнего уровня в конфигурацию xray с tag metrics_out и listen 127.0.0.1:11111, затем перезапустите xray.",
       "xrayObservatoryEmpty": "Данных Observatory пока нет",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Множитель CWND",
         "maxSendingWindow": "Макс. окно отправки",
         "externalProxy": "External Proxy",
+        "forceTls": "Принудительный TLS",
         "sniPlaceholder": "SNI (по умолчанию = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "По умолчанию",

+ 31 - 2
web/translation/tr-TR.json

@@ -131,9 +131,9 @@
       "cpu": "CPU",
       "logicalProcessors": "Mantıksal işlemciler",
       "frequency": "Frekans",
-      "swap": "Swap",
+      "swap": "Takas",
       "storage": "Depolama",
-      "memory": "RAM",
+      "memory": "Bellek",
       "threads": "İş parçacığı",
       "xrayStatus": "Xray",
       "stopXray": "Durdur",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
       "operationHours": "Çalışma Süresi",
       "systemHistoryTitle": "Sistem Geçmişi",
+      "historyTitleCpu": "CPU Kullanımı",
+      "historyTitleMem": "Bellek Kullanımı",
+      "historyTitleNetwork": "Ağ Bant Genişliği",
+      "historyTitlePackets": "Ağ Paketleri",
+      "historyTitleDisk": "Disk G/Ç",
+      "historyTitleOnline": "Çevrimiçi İstemciler",
+      "historyTitleLoad": "Sistem Yük Ortalaması (1d / 5d / 15d)",
+      "historyTitleConnections": "Etkin Bağlantılar (TCP / UDP)",
+      "historyTitleDiskUsage": "Disk Alanı Kullanımı",
+      "historyTabBandwidth": "Bant Genişliği",
+      "historyTabPackets": "Paketler",
+      "historyTabDisk": "Disk G/Ç",
+      "historyTabOnline": "Çevrimiçi",
+      "historyTabLoad": "Yük",
+      "historyTabConnections": "Bağlantılar",
+      "historyTabDiskUsage": "Disk Kullanımı",
       "charts": "Grafikler",
       "xrayMetricsTitle": "Xray Metrikleri",
+      "xrayTitleHeap": "Ayrılan Yığın Belleği",
+      "xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
+      "xrayTitleObjects": "Aktif Yığın Nesneleri",
+      "xrayTitleGcCount": "Tamamlanan GC Döngüleri",
+      "xrayTitleGcPause": "GC Duraklama Süresi",
+      "xrayTitleObservatory": "Giden Bağlantı Durumu",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Nesneler",
+      "xrayTabGcCount": "GC Sayısı",
+      "xrayTabGcPause": "GC Duraklaması",
+      "xrayTabObservatory": "Gözlemevi",
       "xrayMetricsDisabled": "Xray metrik uç noktası yapılandırılmadı",
       "xrayMetricsHint": "xray yapılandırmasına tag metrics_out ve listen 127.0.0.1:11111 olan üst düzey bir metrics bloğu ekleyin, sonra xray'i yeniden başlatın.",
       "xrayObservatoryEmpty": "Henüz Observatory verisi yok",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "CWND çarpanı",
         "maxSendingWindow": "Maks. gönderme penceresi",
         "externalProxy": "Harici proxy",
+        "forceTls": "TLS zorla",
         "sniPlaceholder": "SNI (varsayılan host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Varsayılan",

+ 32 - 3
web/translation/uk-UA.json

@@ -128,12 +128,12 @@
     },
     "index": {
       "title": "Огляд",
-      "cpu": "CPU",
+      "cpu": "ЦП",
       "logicalProcessors": "Логічні процесори",
       "frequency": "Частота",
-      "swap": "Swap",
+      "swap": "Підкачка",
       "storage": "Сховище",
-      "memory": "RAM",
+      "memory": "Пам’ять",
       "threads": "Потоки",
       "xrayStatus": "Xray",
       "stopXray": "Стоп",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
       "operationHours": "Час роботи",
       "systemHistoryTitle": "Історія системи",
+      "historyTitleCpu": "Завантаження ЦП",
+      "historyTitleMem": "Використання пам’яті",
+      "historyTitleNetwork": "Пропускна здатність мережі",
+      "historyTitlePackets": "Мережеві пакети",
+      "historyTitleDisk": "Дисковий ввід-вивід",
+      "historyTitleOnline": "Клієнти онлайн",
+      "historyTitleLoad": "Середнє навантаження системи (1 / 5 / 15 хв)",
+      "historyTitleConnections": "Активні з’єднання (TCP / UDP)",
+      "historyTitleDiskUsage": "Використання дискового простору",
+      "historyTabBandwidth": "Пропускна здатність",
+      "historyTabPackets": "Пакети",
+      "historyTabDisk": "Диск I/O",
+      "historyTabOnline": "Онлайн",
+      "historyTabLoad": "Навантаження",
+      "historyTabConnections": "З’єднання",
+      "historyTabDiskUsage": "Використання диска",
       "charts": "Графіки",
       "xrayMetricsTitle": "Метрики Xray",
+      "xrayTitleHeap": "Виділена пам’ять купи",
+      "xrayTitleSys": "Пам’ять, зарезервована в ОС",
+      "xrayTitleObjects": "Активні об’єкти купи",
+      "xrayTitleGcCount": "Завершені цикли GC",
+      "xrayTitleGcPause": "Тривалість паузи GC",
+      "xrayTitleObservatory": "Стан вихідних з’єднань",
+      "xrayTabHeap": "Купа",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Об’єкти",
+      "xrayTabGcCount": "Лічильник GC",
+      "xrayTabGcPause": "Пауза GC",
+      "xrayTabObservatory": "Обсерваторія",
       "xrayMetricsDisabled": "Кінцева точка метрик Xray не налаштована",
       "xrayMetricsHint": "Додайте блок metrics верхнього рівня до конфігурації xray з tag metrics_out і listen 127.0.0.1:11111, потім перезапустіть xray.",
       "xrayObservatoryEmpty": "Даних Observatory ще немає",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Множник CWND",
         "maxSendingWindow": "Макс. вікно відправки",
         "externalProxy": "External Proxy",
+        "forceTls": "Примусовий TLS",
         "sniPlaceholder": "SNI (за замовчуванням = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "За замовчуванням",

+ 30 - 1
web/translation/vi-VN.json

@@ -133,7 +133,7 @@
       "frequency": "Tần số",
       "swap": "Swap",
       "storage": "Lưu trữ",
-      "memory": "RAM",
+      "memory": "Bộ nhớ",
       "threads": "Luồng",
       "xrayStatus": "Xray",
       "stopXray": "Dừng",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
       "operationHours": "Thời gian hoạt động",
       "systemHistoryTitle": "Lịch sử hệ thống",
+      "historyTitleCpu": "Mức sử dụng CPU",
+      "historyTitleMem": "Mức sử dụng bộ nhớ",
+      "historyTitleNetwork": "Băng thông mạng",
+      "historyTitlePackets": "Gói tin mạng",
+      "historyTitleDisk": "I/O đĩa",
+      "historyTitleOnline": "Máy khách trực tuyến",
+      "historyTitleLoad": "Tải trung bình hệ thống (1 / 5 / 15 phút)",
+      "historyTitleConnections": "Kết nối đang hoạt động (TCP / UDP)",
+      "historyTitleDiskUsage": "Sử dụng dung lượng đĩa",
+      "historyTabBandwidth": "Băng thông",
+      "historyTabPackets": "Gói tin",
+      "historyTabDisk": "Đĩa I/O",
+      "historyTabOnline": "Trực tuyến",
+      "historyTabLoad": "Tải",
+      "historyTabConnections": "Kết nối",
+      "historyTabDiskUsage": "Sử dụng đĩa",
       "charts": "Biểu đồ",
       "xrayMetricsTitle": "Chỉ số Xray",
+      "xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
+      "xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
+      "xrayTitleObjects": "Đối tượng Heap đang hoạt động",
+      "xrayTitleGcCount": "Chu kỳ GC đã hoàn thành",
+      "xrayTitleGcPause": "Thời lượng tạm dừng GC",
+      "xrayTitleObservatory": "Tình trạng kết nối đi",
+      "xrayTabHeap": "Heap",
+      "xrayTabSys": "Sys",
+      "xrayTabObjects": "Đối tượng",
+      "xrayTabGcCount": "Số lần GC",
+      "xrayTabGcPause": "Tạm dừng GC",
+      "xrayTabObservatory": "Đài quan sát",
       "xrayMetricsDisabled": "Điểm cuối chỉ số Xray chưa được cấu hình",
       "xrayMetricsHint": "Thêm khối metrics cấp cao nhất vào cấu hình xray với tag là metrics_out và listen là 127.0.0.1:11111, sau đó khởi động lại xray.",
       "xrayObservatoryEmpty": "Chưa có dữ liệu Observatory",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "Hệ số CWND",
         "maxSendingWindow": "Cửa sổ gửi tối đa",
         "externalProxy": "Proxy ngoài",
+        "forceTls": "Bắt buộc TLS",
         "sniPlaceholder": "SNI (mặc định = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Mặc định",

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

@@ -131,9 +131,9 @@
       "cpu": "CPU",
       "logicalProcessors": "逻辑处理器",
       "frequency": "频率",
-      "swap": "Swap",
+      "swap": "交换空间",
       "storage": "存储",
-      "memory": "RAM",
+      "memory": "内存",
       "threads": "线程",
       "xrayStatus": "Xray",
       "stopXray": "停止",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "运行Xray时发生错误",
       "operationHours": "系统正常运行时间",
       "systemHistoryTitle": "系统历史",
+      "historyTitleCpu": "CPU 使用率",
+      "historyTitleMem": "内存使用率",
+      "historyTitleNetwork": "网络带宽",
+      "historyTitlePackets": "网络数据包",
+      "historyTitleDisk": "磁盘 I/O",
+      "historyTitleOnline": "在线客户端",
+      "historyTitleLoad": "系统平均负载(1 分钟 / 5 分钟 / 15 分钟)",
+      "historyTitleConnections": "活动连接 (TCP / UDP)",
+      "historyTitleDiskUsage": "磁盘空间使用率",
+      "historyTabBandwidth": "带宽",
+      "historyTabPackets": "数据包",
+      "historyTabDisk": "磁盘 I/O",
+      "historyTabOnline": "在线",
+      "historyTabLoad": "负载",
+      "historyTabConnections": "连接数",
+      "historyTabDiskUsage": "磁盘使用量",
       "charts": "图表",
       "xrayMetricsTitle": "Xray 指标",
+      "xrayTitleHeap": "已分配的堆内存",
+      "xrayTitleSys": "向操作系统保留的内存",
+      "xrayTitleObjects": "存活的堆对象",
+      "xrayTitleGcCount": "已完成的 GC 周期",
+      "xrayTitleGcPause": "GC 暂停时间",
+      "xrayTitleObservatory": "出站连接健康状态",
+      "xrayTabHeap": "堆",
+      "xrayTabSys": "系统",
+      "xrayTabObjects": "对象",
+      "xrayTabGcCount": "GC 次数",
+      "xrayTabGcPause": "GC 暂停",
+      "xrayTabObservatory": "观测站",
       "xrayMetricsDisabled": "未配置 Xray 指标端点",
       "xrayMetricsHint": "在 xray 配置中添加顶级 metrics 块,tag 为 metrics_out,listen 为 127.0.0.1:11111,然后重启 xray。",
       "xrayObservatoryEmpty": "暂无 Observatory 数据",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "CWND 倍数",
         "maxSendingWindow": "最大发送窗口",
         "externalProxy": "外部代理",
+        "forceTls": "强制 TLS",
         "sniPlaceholder": "SNI (默认为 host)",
         "fingerprint": "指纹",
         "defaultOption": "默认",

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

@@ -131,9 +131,9 @@
       "cpu": "CPU",
       "logicalProcessors": "邏輯處理器",
       "frequency": "頻率",
-      "swap": "Swap",
+      "swap": "交換空間",
       "storage": "儲存",
-      "memory": "RAM",
+      "memory": "記憶體",
       "threads": "執行緒",
       "xrayStatus": "Xray",
       "stopXray": "停止",
@@ -155,8 +155,36 @@
       "xrayErrorPopoverTitle": "執行Xray時發生錯誤",
       "operationHours": "系統正常執行時間",
       "systemHistoryTitle": "系統歷史",
+      "historyTitleCpu": "CPU 使用率",
+      "historyTitleMem": "記憶體使用率",
+      "historyTitleNetwork": "網路頻寬",
+      "historyTitlePackets": "網路封包",
+      "historyTitleDisk": "磁碟 I/O",
+      "historyTitleOnline": "線上用戶端",
+      "historyTitleLoad": "系統平均負載(1 分鐘 / 5 分鐘 / 15 分鐘)",
+      "historyTitleConnections": "使用中的連線 (TCP / UDP)",
+      "historyTitleDiskUsage": "磁碟空間使用率",
+      "historyTabBandwidth": "頻寬",
+      "historyTabPackets": "封包",
+      "historyTabDisk": "磁碟 I/O",
+      "historyTabOnline": "線上",
+      "historyTabLoad": "負載",
+      "historyTabConnections": "連線數",
+      "historyTabDiskUsage": "磁碟使用量",
       "charts": "圖表",
       "xrayMetricsTitle": "Xray 指標",
+      "xrayTitleHeap": "已配置的堆積記憶體",
+      "xrayTitleSys": "向作業系統保留的記憶體",
+      "xrayTitleObjects": "存活的堆積物件",
+      "xrayTitleGcCount": "已完成的 GC 週期",
+      "xrayTitleGcPause": "GC 暫停時間",
+      "xrayTitleObservatory": "出站連線健康狀態",
+      "xrayTabHeap": "堆積",
+      "xrayTabSys": "系統",
+      "xrayTabObjects": "物件",
+      "xrayTabGcCount": "GC 次數",
+      "xrayTabGcPause": "GC 暫停",
+      "xrayTabObservatory": "觀測站",
       "xrayMetricsDisabled": "未設定 Xray 指標端點",
       "xrayMetricsHint": "在 xray 設定中加入頂層 metrics 區塊,tag 為 metrics_out,listen 為 127.0.0.1:11111,然後重啟 xray。",
       "xrayObservatoryEmpty": "尚無 Observatory 資料",
@@ -519,6 +547,7 @@
         "cwndMultiplier": "CWND 倍數",
         "maxSendingWindow": "最大發送視窗",
         "externalProxy": "外部代理",
+        "forceTls": "強制 TLS",
         "sniPlaceholder": "SNI (預設為 host)",
         "fingerprint": "指紋",
         "defaultOption": "預設",

+ 3 - 0
web/web.go

@@ -469,6 +469,9 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	if s.cron != nil {
 		s.cron.Stop()
 	}
+	if err := service.PersistSystemMetrics(); err != nil {
+		logger.Warning("persist system metrics on shutdown failed:", err)
+	}
 	if stopXray {
 		service.StopTrafficWriter()
 	}