1
0

13 کامیت‌ها 7c8889466b ... 0b0b6250d6

نویسنده SHA1 پیام تاریخ
  MHSanaei 0b0b6250d6 feat(clients): orphan cleanup + export/import via CodeMirror modals 11 ساعت پیش
  MHSanaei 0483273839 fix(tls): pin remote cert via native uTLS handshake instead of xray subprocess 14 ساعت پیش
  MHSanaei 03e89683dd fix(tls): ping the inbound's own port for remote cert pinning 15 ساعت پیش
  MHSanaei 39774a6a38 fix(tls): default OCSP stapling to off for new inbound certs 15 ساعت پیش
  MHSanaei 3aa76ea05b fix(deps): bump xray-core past finalmask UDP buffer fix (#5462) 16 ساعت پیش
  MHSanaei 33b029e1ca fix(security): confine GetCertHash to known cert files (CWE-22) 16 ساعت پیش
  qin9125 dfd77caf63 Update zh-CN.json (#5459) 16 ساعت پیش
  Sentiago 891d3a8759 feat(memory): add memory threshold alerts (#5366) 16 ساعت پیش
  shazzreab 648fc69cb1 feat(metrics): extend history bucket options to include 12h, 24h, and 48h intervals (#5467) 17 ساعت پیش
  Nikan Zeyaei 6f05c0a492 fix(node): mark node dirty on Update so sync reconciles before snapshot sweep (#5469) 17 ساعت پیش
  Nikan Zeyaei 5d88e68826 fix(frontend): guard IntlUtil.formatDate against out-of-range timestamps (#5468) 17 ساعت پیش
  MHSanaei d20b549b04 fix(ci): use pull_request_target so claude bot gets secrets on fork PRs 17 ساعت پیش
  MHSanaei 97c02ef69f feat(xray): preview export in a modal and switch rule enable toggle 18 ساعت پیش
51فایلهای تغییر یافته به همراه1260 افزوده شده و 144 حذف شده
  1. 2 2
      .github/workflows/claude-bot.yml
  2. 168 0
      frontend/public/openapi.json
  3. 8 0
      frontend/src/components/ui/notifications/EmailNotifications.tsx
  4. 8 0
      frontend/src/components/ui/notifications/TelegramNotifications.tsx
  5. 4 0
      frontend/src/generated/examples.ts
  6. 28 0
      frontend/src/generated/schemas.ts
  7. 4 0
      frontend/src/generated/types.ts
  8. 4 0
      frontend/src/generated/zod.ts
  9. 28 0
      frontend/src/hooks/useClients.ts
  10. 1 1
      frontend/src/lib/xray/inbound-tls-defaults.ts
  11. 2 0
      frontend/src/models/setting.ts
  12. 19 0
      frontend/src/pages/api-docs/endpoints.ts
  13. 143 1
      frontend/src/pages/clients/ClientsPage.tsx
  14. 1 1
      frontend/src/pages/inbounds/form/security/tls.tsx
  15. 6 1
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  16. 3 0
      frontend/src/pages/index/SystemHistoryModal.tsx
  17. 14 4
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  18. 25 14
      frontend/src/pages/xray/routing/RoutingTab.tsx
  19. 4 8
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  20. 2 2
      frontend/src/schemas/protocols/security/tls.ts
  21. 5 5
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  22. 1 1
      frontend/src/test/__snapshots__/security.test.ts.snap
  23. 4 1
      frontend/src/utils/index.ts
  24. 2 2
      go.mod
  25. 2 2
      go.sum
  26. 2 1
      internal/eventbus/events.go
  27. 56 0
      internal/web/controller/client.go
  28. 3 1
      internal/web/entity/entity.go
  29. 35 0
      internal/web/job/check_memory_usage.go
  30. 220 0
      internal/web/service/client_portable.go
  31. 13 0
      internal/web/service/email/subscriber.go
  32. 4 5
      internal/web/service/metric_history.go
  33. 3 0
      internal/web/service/node.go
  34. 50 0
      internal/web/service/node_dirty_test.go
  35. 124 38
      internal/web/service/server.go
  36. 18 0
      internal/web/service/setting.go
  37. 12 0
      internal/web/service/tgbot/tgbot_event.go
  38. 14 3
      internal/web/translation/ar-EG.json
  39. 15 4
      internal/web/translation/en-US.json
  40. 15 4
      internal/web/translation/es-ES.json
  41. 16 5
      internal/web/translation/fa-IR.json
  42. 14 3
      internal/web/translation/id-ID.json
  43. 15 4
      internal/web/translation/ja-JP.json
  44. 15 4
      internal/web/translation/pt-BR.json
  45. 15 4
      internal/web/translation/ru-RU.json
  46. 14 3
      internal/web/translation/tr-TR.json
  47. 15 4
      internal/web/translation/uk-UA.json
  48. 15 4
      internal/web/translation/vi-VN.json
  49. 18 7
      internal/web/translation/zh-CN.json
  50. 15 4
      internal/web/translation/zh-TW.json
  51. 36 1
      internal/web/web.go

+ 2 - 2
.github/workflows/claude-bot.yml

@@ -5,7 +5,7 @@ on:
     types: [opened]
     types: [opened]
   issue_comment:
   issue_comment:
     types: [created]
     types: [created]
-  pull_request:
+  pull_request_target:
     types: [opened]
     types: [opened]
 
 
 permissions:
 permissions:
@@ -265,7 +265,7 @@ jobs:
               code, run builds/tests, commit, or open a PR.
               code, run builds/tests, commit, or open a PR.
 
 
   handle-pr:
   handle-pr:
-    if: github.event_name == 'pull_request'
+    if: github.event_name == 'pull_request_target'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     permissions:
     permissions:
       contents: read
       contents: read

+ 168 - 0
frontend/public/openapi.json

@@ -160,6 +160,12 @@
             "description": "SMTP server host",
             "description": "SMTP server host",
             "type": "string"
             "type": "string"
           },
           },
+          "smtpMemory": {
+            "description": "Memory threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "smtpPassword": {
           "smtpPassword": {
             "description": "SMTP password",
             "description": "SMTP password",
             "type": "string"
             "type": "string"
@@ -335,6 +341,12 @@
             "description": "Telegram bot language",
             "description": "Telegram bot language",
             "type": "string"
             "type": "string"
           },
           },
+          "tgMemory": {
+            "description": "Memory usage threshold for alerts (percent)",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "tgRunTime": {
           "tgRunTime": {
             "description": "Cron schedule for Telegram notifications",
             "description": "Cron schedule for Telegram notifications",
             "type": "string"
             "type": "string"
@@ -428,6 +440,7 @@
           "smtpEnabledEvents",
           "smtpEnabledEvents",
           "smtpEncryptionType",
           "smtpEncryptionType",
           "smtpHost",
           "smtpHost",
+          "smtpMemory",
           "smtpPassword",
           "smtpPassword",
           "smtpPort",
           "smtpPort",
           "smtpTo",
           "smtpTo",
@@ -470,6 +483,7 @@
           "tgCpu",
           "tgCpu",
           "tgEnabledEvents",
           "tgEnabledEvents",
           "tgLang",
           "tgLang",
+          "tgMemory",
           "tgRunTime",
           "tgRunTime",
           "timeLocation",
           "timeLocation",
           "trafficDiff",
           "trafficDiff",
@@ -641,6 +655,12 @@
             "description": "SMTP server host",
             "description": "SMTP server host",
             "type": "string"
             "type": "string"
           },
           },
+          "smtpMemory": {
+            "description": "Memory threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "smtpPassword": {
           "smtpPassword": {
             "description": "SMTP password",
             "description": "SMTP password",
             "type": "string"
             "type": "string"
@@ -816,6 +836,12 @@
             "description": "Telegram bot language",
             "description": "Telegram bot language",
             "type": "string"
             "type": "string"
           },
           },
+          "tgMemory": {
+            "description": "Memory usage threshold for alerts (percent)",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "tgRunTime": {
           "tgRunTime": {
             "description": "Cron schedule for Telegram notifications",
             "description": "Cron schedule for Telegram notifications",
             "type": "string"
             "type": "string"
@@ -916,6 +942,7 @@
           "smtpEnabledEvents",
           "smtpEnabledEvents",
           "smtpEncryptionType",
           "smtpEncryptionType",
           "smtpHost",
           "smtpHost",
+          "smtpMemory",
           "smtpPassword",
           "smtpPassword",
           "smtpPort",
           "smtpPort",
           "smtpTo",
           "smtpTo",
@@ -958,6 +985,7 @@
           "tgCpu",
           "tgCpu",
           "tgEnabledEvents",
           "tgEnabledEvents",
           "tgLang",
           "tgLang",
+          "tgMemory",
           "tgRunTime",
           "tgRunTime",
           "timeLocation",
           "timeLocation",
           "trafficDiff",
           "trafficDiff",
@@ -5234,6 +5262,146 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/clients/delOrphans": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
+        "operationId": "post_panel_api_clients_delOrphans",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "deleted": 0
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/export": {
+      "get": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "operationId": "get_panel_api_clients_export",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "client": {
+                        "email": "[email protected]",
+                        "id": "...",
+                        "totalGB": 53687091200,
+                        "expiryTime": 0,
+                        "enable": true,
+                        "subId": "..."
+                      },
+                      "inboundIds": [
+                        7,
+                        9
+                      ]
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/import": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "operationId": "post_panel_api_clients_import",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "data": "[{\"client\":{\"email\":\"[email protected]\",\"enable\":true},\"inboundIds\":[7]}]"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "created": 2,
+                    "skipped": [
+                      {
+                        "email": "[email protected]",
+                        "reason": "email already in use: [email protected]"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkAdjust": {
     "/panel/api/clients/bulkAdjust": {
       "post": {
       "post": {
         "tags": [
         "tags": [

+ 8 - 0
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -41,6 +41,14 @@ const GROUPS: NotificationGroupConfig[] = [
           <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
           <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
         ),
         ),
       },
       },
+      {
+        key: 'memory.high',
+        label: 'eventMemoryHigh',
+        settingKey: 'smtpMemory',
+        extra: ({ value, onChange }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        ),
+      },
     ],
     ],
   },
   },
   {
   {

+ 8 - 0
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -41,6 +41,14 @@ const GROUPS: NotificationGroupConfig[] = [
           <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
           <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
         ),
         ),
       },
       },
+      {
+        key: 'memory.high',
+        label: 'eventMemoryHigh',
+        settingKey: 'tgMemory',
+        extra: ({ value, onChange }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        ),
+      },
     ],
     ],
   },
   },
   {
   {

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

@@ -35,6 +35,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpEnabledEvents": "",
     "smtpEnabledEvents": "",
     "smtpEncryptionType": "",
     "smtpEncryptionType": "",
     "smtpHost": "",
     "smtpHost": "",
+    "smtpMemory": 0,
     "smtpPassword": "",
     "smtpPassword": "",
     "smtpPort": 1,
     "smtpPort": 1,
     "smtpTo": "",
     "smtpTo": "",
@@ -77,6 +78,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgCpu": 0,
     "tgCpu": 0,
     "tgEnabledEvents": "",
     "tgEnabledEvents": "",
     "tgLang": "",
     "tgLang": "",
+    "tgMemory": 0,
     "tgRunTime": "",
     "tgRunTime": "",
     "timeLocation": "",
     "timeLocation": "",
     "trafficDiff": 0,
     "trafficDiff": 0,
@@ -133,6 +135,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpEnabledEvents": "",
     "smtpEnabledEvents": "",
     "smtpEncryptionType": "",
     "smtpEncryptionType": "",
     "smtpHost": "",
     "smtpHost": "",
+    "smtpMemory": 0,
     "smtpPassword": "",
     "smtpPassword": "",
     "smtpPort": 1,
     "smtpPort": 1,
     "smtpTo": "",
     "smtpTo": "",
@@ -175,6 +178,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgCpu": 0,
     "tgCpu": 0,
     "tgEnabledEvents": "",
     "tgEnabledEvents": "",
     "tgLang": "",
     "tgLang": "",
+    "tgMemory": 0,
     "tgRunTime": "",
     "tgRunTime": "",
     "timeLocation": "",
     "timeLocation": "",
     "trafficDiff": 0,
     "trafficDiff": 0,

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

@@ -134,6 +134,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "SMTP server host",
         "description": "SMTP server host",
         "type": "string"
         "type": "string"
       },
       },
+      "smtpMemory": {
+        "description": "Memory threshold for email notifications",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
       "smtpPassword": {
       "smtpPassword": {
         "description": "SMTP password",
         "description": "SMTP password",
         "type": "string"
         "type": "string"
@@ -309,6 +315,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Telegram bot language",
         "description": "Telegram bot language",
         "type": "string"
         "type": "string"
       },
       },
+      "tgMemory": {
+        "description": "Memory usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
       "tgRunTime": {
       "tgRunTime": {
         "description": "Cron schedule for Telegram notifications",
         "description": "Cron schedule for Telegram notifications",
         "type": "string"
         "type": "string"
@@ -402,6 +414,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEnabledEvents",
       "smtpEnabledEvents",
       "smtpEncryptionType",
       "smtpEncryptionType",
       "smtpHost",
       "smtpHost",
+      "smtpMemory",
       "smtpPassword",
       "smtpPassword",
       "smtpPort",
       "smtpPort",
       "smtpTo",
       "smtpTo",
@@ -444,6 +457,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "tgCpu",
       "tgCpu",
       "tgEnabledEvents",
       "tgEnabledEvents",
       "tgLang",
       "tgLang",
+      "tgMemory",
       "tgRunTime",
       "tgRunTime",
       "timeLocation",
       "timeLocation",
       "trafficDiff",
       "trafficDiff",
@@ -615,6 +629,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "SMTP server host",
         "description": "SMTP server host",
         "type": "string"
         "type": "string"
       },
       },
+      "smtpMemory": {
+        "description": "Memory threshold for email notifications",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
       "smtpPassword": {
       "smtpPassword": {
         "description": "SMTP password",
         "description": "SMTP password",
         "type": "string"
         "type": "string"
@@ -790,6 +810,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Telegram bot language",
         "description": "Telegram bot language",
         "type": "string"
         "type": "string"
       },
       },
+      "tgMemory": {
+        "description": "Memory usage threshold for alerts (percent)",
+        "maximum": 100,
+        "minimum": 0,
+        "type": "integer"
+      },
       "tgRunTime": {
       "tgRunTime": {
         "description": "Cron schedule for Telegram notifications",
         "description": "Cron schedule for Telegram notifications",
         "type": "string"
         "type": "string"
@@ -890,6 +916,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpEnabledEvents",
       "smtpEnabledEvents",
       "smtpEncryptionType",
       "smtpEncryptionType",
       "smtpHost",
       "smtpHost",
+      "smtpMemory",
       "smtpPassword",
       "smtpPassword",
       "smtpPort",
       "smtpPort",
       "smtpTo",
       "smtpTo",
@@ -932,6 +959,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "tgCpu",
       "tgCpu",
       "tgEnabledEvents",
       "tgEnabledEvents",
       "tgLang",
       "tgLang",
+      "tgMemory",
       "tgRunTime",
       "tgRunTime",
       "timeLocation",
       "timeLocation",
       "trafficDiff",
       "trafficDiff",

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

@@ -41,6 +41,7 @@ export interface AllSetting {
   smtpEnabledEvents: string;
   smtpEnabledEvents: string;
   smtpEncryptionType: string;
   smtpEncryptionType: string;
   smtpHost: string;
   smtpHost: string;
+  smtpMemory: number;
   smtpPassword: string;
   smtpPassword: string;
   smtpPort: number;
   smtpPort: number;
   smtpTo: string;
   smtpTo: string;
@@ -83,6 +84,7 @@ export interface AllSetting {
   tgCpu: number;
   tgCpu: number;
   tgEnabledEvents: string;
   tgEnabledEvents: string;
   tgLang: string;
   tgLang: string;
+  tgMemory: number;
   tgRunTime: string;
   tgRunTime: string;
   timeLocation: string;
   timeLocation: string;
   trafficDiff: number;
   trafficDiff: number;
@@ -140,6 +142,7 @@ export interface AllSettingView {
   smtpEnabledEvents: string;
   smtpEnabledEvents: string;
   smtpEncryptionType: string;
   smtpEncryptionType: string;
   smtpHost: string;
   smtpHost: string;
+  smtpMemory: number;
   smtpPassword: string;
   smtpPassword: string;
   smtpPort: number;
   smtpPort: number;
   smtpTo: string;
   smtpTo: string;
@@ -182,6 +185,7 @@ export interface AllSettingView {
   tgCpu: number;
   tgCpu: number;
   tgEnabledEvents: string;
   tgEnabledEvents: string;
   tgLang: string;
   tgLang: string;
+  tgMemory: number;
   tgRunTime: string;
   tgRunTime: string;
   timeLocation: string;
   timeLocation: string;
   trafficDiff: number;
   trafficDiff: number;

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

@@ -53,6 +53,7 @@ export const AllSettingSchema = z.object({
   smtpEnabledEvents: z.string(),
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
   smtpEncryptionType: z.string(),
   smtpHost: z.string(),
   smtpHost: z.string(),
+  smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
   smtpPassword: z.string(),
   smtpPort: z.number().int().min(1).max(65535),
   smtpPort: z.number().int().min(1).max(65535),
   smtpTo: z.string(),
   smtpTo: z.string(),
@@ -95,6 +96,7 @@ export const AllSettingSchema = z.object({
   tgCpu: z.number().int().min(0).max(100),
   tgCpu: z.number().int().min(0).max(100),
   tgEnabledEvents: z.string(),
   tgEnabledEvents: z.string(),
   tgLang: z.string(),
   tgLang: z.string(),
+  tgMemory: z.number().int().min(0).max(100),
   tgRunTime: z.string(),
   tgRunTime: z.string(),
   timeLocation: z.string(),
   timeLocation: z.string(),
   trafficDiff: z.number().int().min(0).max(100),
   trafficDiff: z.number().int().min(0).max(100),
@@ -153,6 +155,7 @@ export const AllSettingViewSchema = z.object({
   smtpEnabledEvents: z.string(),
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
   smtpEncryptionType: z.string(),
   smtpHost: z.string(),
   smtpHost: z.string(),
+  smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
   smtpPassword: z.string(),
   smtpPort: z.number().int().min(1).max(65535),
   smtpPort: z.number().int().min(1).max(65535),
   smtpTo: z.string(),
   smtpTo: z.string(),
@@ -195,6 +198,7 @@ export const AllSettingViewSchema = z.object({
   tgCpu: z.number().int().min(0).max(100),
   tgCpu: z.number().int().min(0).max(100),
   tgEnabledEvents: z.string(),
   tgEnabledEvents: z.string(),
   tgLang: z.string(),
   tgLang: z.string(),
+  tgMemory: z.number().int().min(0).max(100),
   tgRunTime: z.string(),
   tgRunTime: z.string(),
   timeLocation: z.string(),
   timeLocation: z.string(),
   trafficDiff: z.number().int().min(0).max(100),
   trafficDiff: z.number().int().min(0).max(100),

+ 28 - 0
frontend/src/hooks/useClients.ts

@@ -402,6 +402,22 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
+  const delOrphansMut = useMutation({
+    mutationFn: async () => {
+      const raw = await HttpUtil.post('/panel/api/clients/delOrphans');
+      return parseMsg(raw, DelDepletedResultSchema, 'clients/delOrphans');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
+  const importClientsMut = useMutation({
+    mutationFn: async (data: string): Promise<Msg<BulkCreateResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/import', { data }, JSON_HEADERS);
+      return parseMsg(raw, BulkCreateResultSchema, 'clients/import');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
   const update = useCallback((email: string, client: unknown) => {
   const update = useCallback((email: string, client: unknown) => {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -459,6 +475,15 @@ export function useClients() {
   }, [resetTrafficMut]);
   }, [resetTrafficMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
   const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
   const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
+  const delOrphans = useCallback(() => delOrphansMut.mutateAsync(), [delOrphansMut]);
+  const importClients = useCallback((data: string) => importClientsMut.mutateAsync(data), [importClientsMut]);
+  // Fetch the exported clients so the page can show them in a CodeMirror viewer
+  // (Copy / Download), rather than triggering an immediate browser download.
+  const exportClients = useCallback(async (): Promise<unknown[] | null> => {
+    const msg = await HttpUtil.get('/panel/api/clients/export');
+    if (!msg?.success) return null;
+    return Array.isArray(msg.obj) ? msg.obj : [];
+  }, []);
 
 
   const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
   const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
     if (!client?.email) return null;
     if (!client?.email) return null;
@@ -575,6 +600,9 @@ export function useClients() {
     resetTraffic,
     resetTraffic,
     resetAllTraffics,
     resetAllTraffics,
     delDepleted,
     delDepleted,
+    delOrphans,
+    exportClients,
+    importClients,
     setEnable,
     setEnable,
     applyTrafficEvent,
     applyTrafficEvent,
     applyClientStatsEvent,
     applyClientStatsEvent,

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

@@ -7,7 +7,7 @@ function defaultCertificate(): Record<string, unknown> {
     keyFile: '',
     keyFile: '',
     certificate: [],
     certificate: [],
     key: [],
     key: [],
-    ocspStapling: 3600,
+    ocspStapling: 0,
     oneTimeLoading: false,
     oneTimeLoading: false,
     usage: 'encipherment',
     usage: 'encipherment',
     buildChain: false,
     buildChain: false,

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

@@ -22,6 +22,7 @@ export class AllSetting {
   tgRunTime = '@daily';
   tgRunTime = '@daily';
   tgBotBackup = false;
   tgBotBackup = false;
   tgCpu = 80;
   tgCpu = 80;
+  tgMemory = 80;
   tgLang = 'en-US';
   tgLang = 'en-US';
   twoFactorEnable = false;
   twoFactorEnable = false;
   twoFactorToken = '';
   twoFactorToken = '';
@@ -91,6 +92,7 @@ export class AllSetting {
   smtpEncryptionType = 'starttls';
   smtpEncryptionType = 'starttls';
   smtpEnabledEvents = '';
   smtpEnabledEvents = '';
   smtpCpu = 80;
   smtpCpu = 80;
+  smtpMemory = 80;
   hasTgBotToken = false;
   hasTgBotToken = false;
   hasTwoFactorToken = false;
   hasTwoFactorToken = false;
   hasLdapPassword = false;
   hasLdapPassword = false;

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

@@ -607,6 +607,25 @@ export const sections: readonly Section[] = [
         summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
         summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
       },
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/delOrphans',
+        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/export',
+        summary: 'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/import',
+        summary: 'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.',
+        body: '{\n  "data": "[{\\"client\\":{\\"email\\":\\"[email protected]\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use: [email protected]" }\n    ]\n  }\n}',
+      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
         path: '/panel/api/clients/bulkAdjust',

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

@@ -29,6 +29,8 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
 import {
 import {
   ClockCircleOutlined,
   ClockCircleOutlined,
   DeleteOutlined,
   DeleteOutlined,
+  DisconnectOutlined,
+  DownloadOutlined,
   EditOutlined,
   EditOutlined,
   FilterOutlined,
   FilterOutlined,
   InfoCircleOutlined,
   InfoCircleOutlined,
@@ -42,6 +44,7 @@ import {
   SortAscendingOutlined,
   SortAscendingOutlined,
   TagsOutlined,
   TagsOutlined,
   TeamOutlined,
   TeamOutlined,
+  UploadOutlined,
   UsergroupAddOutlined,
   UsergroupAddOutlined,
   UsergroupDeleteOutlined,
   UsergroupDeleteOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
@@ -69,6 +72,8 @@ const SubLinksModal = lazy(() => import('./SubLinksModal'));
 const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
 const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
 const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
+const TextModal = lazy(() => import('@/components/feedback/TextModal'));
+const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
 import { emptyFilters, activeFilterCount } from './filters';
 import { emptyFilters, activeFilterCount } from './filters';
 import type { ClientFilters } from './filters';
 import type { ClientFilters } from './filters';
 import './ClientsPage.css';
 import './ClientsPage.css';
@@ -200,7 +205,7 @@ export default function ClientsPage() {
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
-    resetTraffic, resetAllTraffics, delDepleted, setEnable,
+    resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
     refresh,
     hydrate,
     hydrate,
@@ -233,6 +238,17 @@ export default function ClientsPage() {
   const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
   const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
 
 
+  const [textOpen, setTextOpen] = useState(false);
+  const [textTitle, setTextTitle] = useState('');
+  const [textContent, setTextContent] = useState('');
+  const [textFileName, setTextFileName] = useState('');
+  const [promptOpen, setPromptOpen] = useState(false);
+  const [promptTitle, setPromptTitle] = useState('');
+  const [promptOkText, setPromptOkText] = useState('');
+  const [promptInitial, setPromptInitial] = useState('');
+  const [promptLoading, setPromptLoading] = useState(false);
+  const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
+
   const initial = readFilterState();
   const initial = readFilterState();
   const [searchKey, setSearchKey] = useState(initial.searchKey);
   const [searchKey, setSearchKey] = useState(initial.searchKey);
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
@@ -490,6 +506,40 @@ export default function ClientsPage() {
     setQrOpen(true);
     setQrOpen(true);
   }
   }
 
 
+  const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
+    setTextTitle(opts.title);
+    setTextContent(opts.content);
+    setTextFileName(opts.fileName || '');
+    setTextOpen(true);
+  }, []);
+
+  const openPrompt = useCallback((opts: {
+    title: string;
+    okText?: string;
+    value?: string;
+    confirm: (value: string) => Promise<boolean | void> | boolean | void;
+  }) => {
+    setPromptTitle(opts.title);
+    setPromptOkText(opts.okText || t('confirm'));
+    setPromptInitial(opts.value || '');
+    setPromptHandler(() => opts.confirm);
+    setPromptOpen(true);
+  }, [t]);
+
+  const onPromptConfirm = useCallback(async (value: string) => {
+    if (!promptHandler) {
+      setPromptOpen(false);
+      return;
+    }
+    setPromptLoading(true);
+    try {
+      const ok = await promptHandler(value);
+      if (ok !== false) setPromptOpen(false);
+    } finally {
+      setPromptLoading(false);
+    }
+  }, [promptHandler]);
+
   function onResetAllTraffics() {
   function onResetAllTraffics() {
     modal.confirm({
     modal.confirm({
       title: t('pages.clients.resetAllTrafficsTitle'),
       title: t('pages.clients.resetAllTrafficsTitle'),
@@ -521,6 +571,56 @@ export default function ClientsPage() {
     });
     });
   }
   }
 
 
+  function onDeleteOrphans() {
+    modal.confirm({
+      title: t('pages.clients.delOrphansConfirmTitle'),
+      content: t('pages.clients.delOrphansConfirmContent'),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await delOrphans();
+        if (msg?.success) {
+          const deleted = msg.obj?.deleted ?? 0;
+          messageApi.success(t('pages.clients.toasts.delOrphans', { count: deleted }));
+        }
+      },
+    });
+  }
+
+  async function onExportClients() {
+    const items = await exportClients();
+    if (!items) return;
+    openText({
+      title: t('pages.clients.exportClients'),
+      content: JSON.stringify(items, null, 2),
+      fileName: 'clients-export.json',
+    });
+  }
+
+  function onImportClients() {
+    openPrompt({
+      title: t('pages.clients.importClients'),
+      okText: t('pages.clients.import'),
+      value: '',
+      confirm: async (value) => {
+        const msg = await importClients(value);
+        if (!msg?.success) return false;
+        const created = msg.obj?.created ?? 0;
+        const skipped = msg.obj?.skipped ?? [];
+        if (skipped.length === 0) {
+          messageApi.success(t('pages.clients.toasts.imported', { count: created }));
+        } else {
+          const firstError = skipped[0]?.reason ?? '';
+          messageApi.warning(firstError
+            ? `${t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length })} — ${firstError}`
+            : t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length }));
+        }
+        return true;
+      },
+    });
+  }
+
   function onBulkUngroup() {
   function onBulkUngroup() {
     const emails = [...selectedRowKeys];
     const emails = [...selectedRowKeys];
     if (emails.length === 0) return;
     if (emails.length === 0) return;
@@ -959,12 +1059,25 @@ export default function ClientsPage() {
                                     label: t('pages.clients.bulk'),
                                     label: t('pages.clients.bulk'),
                                     onClick: () => setBulkAddOpen(true),
                                     onClick: () => setBulkAddOpen(true),
                                   },
                                   },
+                                  {
+                                    key: 'export',
+                                    icon: <DownloadOutlined />,
+                                    label: t('pages.clients.exportClients'),
+                                    onClick: onExportClients,
+                                  },
+                                  {
+                                    key: 'import',
+                                    icon: <UploadOutlined />,
+                                    label: t('pages.clients.importClients'),
+                                    onClick: onImportClients,
+                                  },
                                   {
                                   {
                                     key: 'resetAll',
                                     key: 'resetAll',
                                     icon: <RetweetOutlined />,
                                     icon: <RetweetOutlined />,
                                     label: t('pages.clients.resetAllTraffics'),
                                     label: t('pages.clients.resetAllTraffics'),
                                     onClick: onResetAllTraffics,
                                     onClick: onResetAllTraffics,
                                   },
                                   },
+                                  { type: 'divider' as const },
                                   {
                                   {
                                     key: 'delDepleted',
                                     key: 'delDepleted',
                                     icon: <RestOutlined />,
                                     icon: <RestOutlined />,
@@ -972,6 +1085,13 @@ export default function ClientsPage() {
                                     danger: true,
                                     danger: true,
                                     onClick: onDelDepleted,
                                     onClick: onDelDepleted,
                                   },
                                   },
+                                  {
+                                    key: 'delOrphans',
+                                    icon: <DisconnectOutlined />,
+                                    label: t('pages.clients.delOrphans'),
+                                    danger: true,
+                                    onClick: onDeleteOrphans,
+                                  },
                                 ],
                                 ],
                             }}
                             }}
                           >
                           >
@@ -1377,6 +1497,28 @@ export default function ClientsPage() {
             nodes={nodes}
             nodes={nodes}
           />
           />
         </LazyMount>
         </LazyMount>
+        <LazyMount when={textOpen}>
+          <TextModal
+            open={textOpen}
+            onClose={() => setTextOpen(false)}
+            title={textTitle}
+            content={textContent}
+            fileName={textFileName}
+            json
+          />
+        </LazyMount>
+        <LazyMount when={promptOpen}>
+          <PromptModal
+            open={promptOpen}
+            onClose={() => setPromptOpen(false)}
+            title={promptTitle}
+            okText={promptOkText}
+            initialValue={promptInitial}
+            loading={promptLoading}
+            json
+            onConfirm={onPromptConfirm}
+          />
+        </LazyMount>
       </Layout>
       </Layout>
     </ConfigProvider>
     </ConfigProvider>
   );
   );

+ 1 - 1
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -132,7 +132,7 @@ export default function TlsForm({
                   keyFile: '',
                   keyFile: '',
                   certificate: [],
                   certificate: [],
                   key: [],
                   key: [],
-                  ocspStapling: 3600,
+                  ocspStapling: 0,
                   oneTimeLoading: false,
                   oneTimeLoading: false,
                   usage: 'encipherment',
                   usage: 'encipherment',
                   buildChain: false,
                   buildChain: false,

+ 6 - 1
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -143,9 +143,14 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
       messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
       messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
       return;
       return;
     }
     }
+    // `xray tls ping` defaults to :443, but a self-hosted inbound rarely
+    // listens there. Append the inbound's own port (unless the SNI already
+    // carries one) so the ping reaches the actual TLS endpoint.
+    const port = form.getFieldValue('port') as number | undefined;
+    const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
     setSaving(true);
     setSaving(true);
     try {
     try {
-      const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server });
+      const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server: target });
       if (!msg?.success) {
       if (!msg?.success) {
         messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
         messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
         return;
         return;

+ 3 - 0
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -213,6 +213,9 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
               { value: 120, label: '2h' },
               { value: 120, label: '2h' },
               { value: 180, label: '3h' },
               { value: 180, label: '3h' },
               { value: 300, label: '5h' },
               { value: 300, label: '5h' },
+              { value: 720, label: '12h' },
+              { value: 1440, label: '24h' },
+              { value: 2880, label: '48h' },
             ]}
             ]}
           />
           />
         </div>
         </div>

+ 14 - 4
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -37,8 +37,9 @@ import {
   ImportOutlined,
   ImportOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
-import { FileManager, HttpUtil } from '@/utils';
+import { HttpUtil } from '@/utils';
 import PromptModal from '@/components/feedback/PromptModal';
 import PromptModal from '@/components/feedback/PromptModal';
+import TextModal from '@/components/feedback/TextModal';
 
 
 import OutboundFormModal from './OutboundFormModal';
 import OutboundFormModal from './OutboundFormModal';
 import { propagateOutboundTagRename } from '../basics/helpers';
 import { propagateOutboundTagRename } from '../basics/helpers';
@@ -226,11 +227,12 @@ export default function OutboundsTab({
   }
   }
 
 
   const [importOpen, setImportOpen] = useState(false);
   const [importOpen, setImportOpen] = useState(false);
+  const [exportOpen, setExportOpen] = useState(false);
+  const [exportContent, setExportContent] = useState('');
 
 
   function exportOutbounds() {
   function exportOutbounds() {
-    FileManager.downloadTextFile(JSON.stringify(outbounds, null, 2), 'outbounds.json', {
-      type: 'application/json',
-    });
+    setExportContent(JSON.stringify(outbounds, null, 2));
+    setExportOpen(true);
   }
   }
 
 
   function importOutbounds(value: string) {
   function importOutbounds(value: string) {
@@ -531,6 +533,14 @@ export default function OutboundsTab({
           json
           json
           onConfirm={importOutbounds}
           onConfirm={importOutbounds}
         />
         />
+        <TextModal
+          open={exportOpen}
+          onClose={() => setExportOpen(false)}
+          title={t('pages.xray.exportOutbounds')}
+          content={exportContent}
+          fileName="outbounds.json"
+          json
+        />
 
 
         {/* Subscription outbounds (read-only, merged at runtime) */}
         {/* Subscription outbounds (read-only, merged at runtime) */}
         {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
         {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (

+ 25 - 14
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -1,18 +1,19 @@
 import { useCallback, useMemo, useRef, useState } from 'react';
 import { useCallback, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Button, Modal, Space, Table, Tabs, message } from 'antd';
+import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
 import {
 import {
   AimOutlined,
   AimOutlined,
   ControlOutlined,
   ControlOutlined,
   ExportOutlined,
   ExportOutlined,
   ImportOutlined,
   ImportOutlined,
+  MoreOutlined,
   PlusOutlined,
   PlusOutlined,
   UnorderedListOutlined,
   UnorderedListOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
 import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
-import { FileManager } from '@/utils';
 import PromptModal from '@/components/feedback/PromptModal';
 import PromptModal from '@/components/feedback/PromptModal';
+import TextModal from '@/components/feedback/TextModal';
 import RoutingBasic from './RoutingBasic';
 import RoutingBasic from './RoutingBasic';
 import RouteTester from './RouteTester';
 import RouteTester from './RouteTester';
 import RuleFormModal from './RuleFormModal';
 import RuleFormModal from './RuleFormModal';
@@ -144,11 +145,12 @@ export default function RoutingTab({
   }, [templateSettings?.routing?.balancers]);
   }, [templateSettings?.routing?.balancers]);
 
 
   const [importOpen, setImportOpen] = useState(false);
   const [importOpen, setImportOpen] = useState(false);
+  const [exportOpen, setExportOpen] = useState(false);
+  const [exportContent, setExportContent] = useState('');
 
 
   function exportRules() {
   function exportRules() {
-    FileManager.downloadTextFile(JSON.stringify(rules, null, 2), 'routing-rules.json', {
-      type: 'application/json',
-    });
+    setExportContent(JSON.stringify(rules, null, 2));
+    setExportOpen(true);
   }
   }
 
 
   function importRules(value: string) {
   function importRules(value: string) {
@@ -333,16 +335,17 @@ export default function RoutingTab({
                   <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
                   <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
                     {t('pages.xray.Routings')}
                     {t('pages.xray.Routings')}
                   </Button>
                   </Button>
-                  <Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
-                    {t('pages.xray.importRules')}
-                  </Button>
-                  <Button
-                    icon={<ExportOutlined />}
-                    onClick={exportRules}
-                    disabled={rules.length === 0}
+                  <Dropdown
+                    trigger={['click']}
+                    menu={{
+                      items: [
+                        { key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importRules'), onClick: () => setImportOpen(true) },
+                        { key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportRules'), disabled: rules.length === 0, onClick: exportRules },
+                      ],
+                    }}
                   >
                   >
-                    {t('pages.xray.exportRules')}
-                  </Button>
+                    <Button icon={<MoreOutlined />}>{t('more')}</Button>
+                  </Dropdown>
                 </Space>
                 </Space>
 
 
                 {isMobile ? (
                 {isMobile ? (
@@ -405,6 +408,14 @@ export default function RoutingTab({
         json
         json
         onConfirm={importRules}
         onConfirm={importRules}
       />
       />
+      <TextModal
+        open={exportOpen}
+        onClose={() => setExportOpen(false)}
+        title={t('pages.xray.exportRules')}
+        content={exportContent}
+        fileName="routing-rules.json"
+        json
+      />
     </>
     </>
   );
   );
 }
 }

+ 4 - 8
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
+import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { InputAddon } from '@/components/ui';
 import { InputAddon } from '@/components/ui';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
@@ -156,14 +156,10 @@ export default function RuleFormModal({
     >
     >
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
         <Form.Item label={t('enable')}>
         <Form.Item label={t('enable')}>
-          <Select
-            value={form.enabled}
-            onChange={(v) => update('enabled', v)}
+          <Switch
+            checked={form.enabled}
+            onChange={(checked) => update('enabled', checked)}
             disabled={isApiRule(rule ?? {})}
             disabled={isApiRule(rule ?? {})}
-            options={[
-              { value: true, label: t('enable') },
-              { value: false, label: t('disable') },
-            ]}
           />
           />
         </Form.Item>
         </Form.Item>
 
 

+ 2 - 2
frontend/src/schemas/protocols/security/tls.ts

@@ -39,7 +39,7 @@ export type TlsCertUsage = z.infer<typeof TlsCertUsageSchema>;
 export const TlsCertFileSchema = z.object({
 export const TlsCertFileSchema = z.object({
   certificateFile: z.string().min(1),
   certificateFile: z.string().min(1),
   keyFile: z.string().min(1),
   keyFile: z.string().min(1),
-  ocspStapling: z.number().default(3600),
+  ocspStapling: z.number().default(0),
   oneTimeLoading: z.boolean().default(false),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),
   buildChain: z.boolean().default(false),
@@ -47,7 +47,7 @@ export const TlsCertFileSchema = z.object({
 export const TlsCertInlineSchema = z.object({
 export const TlsCertInlineSchema = z.object({
   certificate: z.array(z.string()),
   certificate: z.array(z.string()),
   key: z.array(z.string()),
   key: z.array(z.string()),
-  ocspStapling: z.number().default(3600),
+  ocspStapling: z.number().default(0),
   oneTimeLoading: z.boolean().default(false),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),
   buildChain: z.boolean().default(false),

+ 5 - 5
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -57,7 +57,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
           "buildChain": false,
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/hysteria.crt",
           "certificateFile": "/etc/ssl/certs/hysteria.crt",
           "keyFile": "/etc/ssl/private/hysteria.key",
           "keyFile": "/etc/ssl/private/hysteria.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "oneTimeLoading": false,
           "usage": "encipherment",
           "usage": "encipherment",
         },
         },
@@ -201,7 +201,7 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
           "buildChain": false,
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/trojan.crt",
           "certificateFile": "/etc/ssl/certs/trojan.crt",
           "keyFile": "/etc/ssl/private/trojan.key",
           "keyFile": "/etc/ssl/private/trojan.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "oneTimeLoading": false,
           "usage": "encipherment",
           "usage": "encipherment",
         },
         },
@@ -379,7 +379,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
           "buildChain": false,
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "oneTimeLoading": false,
           "usage": "encipherment",
           "usage": "encipherment",
         },
         },
@@ -471,7 +471,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
           "buildChain": false,
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "oneTimeLoading": false,
           "usage": "encipherment",
           "usage": "encipherment",
         },
         },
@@ -570,7 +570,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
           "buildChain": false,
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/vmess.crt",
           "certificateFile": "/etc/ssl/certs/vmess.crt",
           "keyFile": "/etc/ssl/private/vmess.key",
           "keyFile": "/etc/ssl/private/vmess.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "oneTimeLoading": false,
           "usage": "encipherment",
           "usage": "encipherment",
         },
         },

+ 1 - 1
frontend/src/test/__snapshots__/security.test.ts.snap

@@ -51,7 +51,7 @@ exports[`SecuritySettingsSchema fixtures > parses tls-cert-file byte-stably 1`]
         "buildChain": false,
         "buildChain": false,
         "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
         "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
         "keyFile": "/etc/ssl/private/cdn.example.test.key",
         "keyFile": "/etc/ssl/private/cdn.example.test.key",
-        "ocspStapling": 3600,
+        "ocspStapling": 0,
         "oneTimeLoading": false,
         "oneTimeLoading": false,
         "usage": "encipherment",
         "usage": "encipherment",
       },
       },

+ 4 - 1
frontend/src/utils/index.ts

@@ -920,6 +920,8 @@ export type CalendarKind = 'gregorian' | 'jalalian';
 export class IntlUtil {
 export class IntlUtil {
   static formatDate(date: string | number | Date | null | undefined, calendar: CalendarKind = 'gregorian'): string {
   static formatDate(date: string | number | Date | null | undefined, calendar: CalendarKind = 'gregorian'): string {
     if (date == null) return '';
     if (date == null) return '';
+    const d = new Date(date);
+    if (!isFinite(d.getTime())) return '';
     const language = LanguageManager.getLanguage();
     const language = LanguageManager.getLanguage();
     const locale = calendar === 'jalalian' ? 'fa-IR' : language;
     const locale = calendar === 'jalalian' ? 'fa-IR' : language;
 
 
@@ -934,11 +936,12 @@ export class IntlUtil {
     };
     };
 
 
     const intl = new Intl.DateTimeFormat(locale, intlOptions);
     const intl = new Intl.DateTimeFormat(locale, intlOptions);
-    return intl.format(new Date(date));
+    return intl.format(d);
   }
   }
 
 
   static formatRelativeTime(date: number | null | undefined): string {
   static formatRelativeTime(date: number | null | undefined): string {
     if (date == null) return '';
     if (date == null) return '';
+    if (!isFinite(date)) return '';
     const language = LanguageManager.getLanguage();
     const language = LanguageManager.getLanguage();
     const now = new Date();
     const now = new Date();
     const diff = date < 0
     const diff = date < 0

+ 2 - 2
go.mod

@@ -21,7 +21,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.71.0
 	github.com/valyala/fasthttp v1.71.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
+	github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509
 	go.uber.org/atomic v1.11.0
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.53.0
 	golang.org/x/crypto v0.53.0
 	golang.org/x/sys v0.46.0
 	golang.org/x/sys v0.46.0
@@ -86,7 +86,7 @@ require (
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.60.0 // indirect
 	github.com/quic-go/quic-go v0.60.0 // indirect
-	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
+	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/sagernet/sing v0.8.10 // indirect
 	github.com/sagernet/sing v0.8.10 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect

+ 2 - 2
go.sum

@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1 h1:RAxvdTekSZCn1OO5P9d0ioDrdiiqdOsdqllxLvC+IGQ=
-github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1/go.mod h1:klRI+zA2uG6qrelDRoUaEur3gasszRE9W8e2zTgqXNU=
+github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509 h1:HPhr73dDjzX3OVUO7MrAFTh7OpSeSl6e7IHOiYExbBc=
+github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509/go.mod h1:kxL2uOFeoKMqrsW3tLQiRcfvxtGUmxoTre1B0stjpuM=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 2 - 1
internal/eventbus/events.go

@@ -18,7 +18,8 @@ const (
 	EventNodeUp   EventType = "node.up"
 	EventNodeUp   EventType = "node.up"
 
 
 	// System health
 	// System health
-	EventCPUHigh EventType = "cpu.high"
+	EventCPUHigh    EventType = "cpu.high"
+	EventMemoryHigh EventType = "memory.high"
 
 
 	// Security
 	// Security
 	EventLoginAttempt EventType = "login.attempt"
 	EventLoginAttempt EventType = "login.attempt"

+ 56 - 0
internal/web/controller/client.go

@@ -1,6 +1,7 @@
 package controller
 package controller
 
 
 import (
 import (
+	"encoding/json"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 
 
@@ -57,6 +58,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/detach", a.detach)
 	g.POST("/:email/detach", a.detach)
 	g.POST("/:email/externalLinks", a.setExternalLinks)
 	g.POST("/:email/externalLinks", a.setExternalLinks)
+	g.GET("/export", a.export)
+	g.POST("/import", a.importClients)
+	g.POST("/delOrphans", a.delOrphans)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
 	g.POST("/bulkAdjust", a.bulkAdjust)
@@ -364,6 +368,58 @@ func (a *ClientController) delDepleted(c *gin.Context) {
 	notifyClientsChanged()
 	notifyClientsChanged()
 }
 }
 
 
+// export returns every client as a {client, inboundIds} list in the standard
+// envelope. The frontend renders it in a read-only CodeMirror viewer (Copy /
+// Download), so this hands back data rather than streaming a file attachment.
+func (a *ClientController) export(c *gin.Context) {
+	items, err := a.clientService.ExportAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, items, nil)
+}
+
+type importClientsRequest struct {
+	Data string `json:"data"`
+}
+
+// importClients accepts the pasted export text as a JSON body { "data": "..." },
+// mirroring the inbound import flow. The data string is itself a JSON-encoded
+// []ClientCreatePayload, so it is unmarshalled in a second step.
+func (a *ClientController) importClients(c *gin.Context) {
+	var req importClientsRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	var items []service.ClientCreatePayload
+	if err := json.Unmarshal([]byte(req.Data), &items); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delOrphans(c *gin.Context) {
+	deleted, err := a.clientService.DeleteOrphans()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"deleted": deleted}, nil)
+	notifyClientsChanged()
+}
+
 func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
 func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
 	needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
 	needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)

+ 3 - 1
internal/web/entity/entity.go

@@ -47,6 +47,7 @@ type AllSetting struct {
 	TgRunTime       string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
 	TgRunTime       string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
 	TgBotBackup     bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
 	TgBotBackup     bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
 	TgCpu           int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
 	TgCpu           int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
+	TgMemory        int    `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` // Memory usage threshold for alerts (percent)
 	TgLang          string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
 	TgLang          string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
 	TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`      // Comma-separated event types to send via Telegram
 	TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`      // Comma-separated event types to send via Telegram
 
 
@@ -59,7 +60,8 @@ type AllSetting struct {
 	SmtpTo             string `json:"smtpTo" form:"smtpTo"`                                // Comma-separated recipient emails
 	SmtpTo             string `json:"smtpTo" form:"smtpTo"`                                // Comma-separated recipient emails
 	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`        // SMTP encryption: none, starttls, tls
 	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`        // SMTP encryption: none, starttls, tls
 	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`          // Comma-separated event types to send via email
 	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`          // Comma-separated event types to send via email
-	SmtpCpu            int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`     // CPU threshold for email notifications
+	SmtpCpu           int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`                                          // CPU threshold for email notifications
+	SmtpMemory        int    `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`                                    // Memory threshold for email notifications
 
 
 	// Security settings
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location

+ 35 - 0
internal/web/job/check_memory_usage.go

@@ -0,0 +1,35 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+
+	"github.com/shirou/gopsutil/v4/mem"
+)
+
+// CheckMemJob monitors memory usage and publishes events when threshold is exceeded.
+type CheckMemJob struct {
+	settingService service.SettingService
+}
+
+// NewCheckMemJob creates a new memory monitoring job instance.
+func NewCheckMemJob() *CheckMemJob {
+	return new(CheckMemJob)
+}
+
+// Run checks memory usage and publishes a memory.high event with raw metric data.
+func (j *CheckMemJob) Run() {
+	memInfo, err := mem.VirtualMemory()
+	if err != nil || memInfo == nil {
+		return
+	}
+
+	if EventBus != nil {
+		EventBus.Publish(eventbus.Event{
+			Type: eventbus.EventMemoryHigh,
+			Data: &eventbus.SystemMetricData{
+				Percent: memInfo.UsedPercent,
+			},
+		})
+	}
+}

+ 220 - 0
internal/web/service/client_portable.go

@@ -0,0 +1,220 @@
+package service
+
+import (
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+)
+
+// ExportAll returns every client in the same {client, inboundIds} shape that
+// /add and /bulkCreate accept, so an exported file round-trips straight back
+// through Import. Clients with no inbound attachment are included with an empty
+// inboundIds list so an export taken before DeleteOrphans can restore them.
+func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
+	db := database.GetDB()
+	var rows []model.ClientRecord
+	if err := db.Order("id ASC").Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make([]ClientCreatePayload, 0, len(rows))
+	if len(rows) == 0 {
+		return out, nil
+	}
+
+	ids := make([]int, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].Id)
+	}
+
+	attachments := make(map[int][]int, len(rows))
+	for _, batch := range chunkInts(ids, sqlInChunk) {
+		var links []model.ClientInbound
+		if err := db.Where("client_id IN ?", batch).Order("inbound_id ASC").Find(&links).Error; err != nil {
+			return nil, err
+		}
+		for _, l := range links {
+			attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+		}
+	}
+
+	for i := range rows {
+		client := rows[i].ToClient()
+		// The per-inbound flow_override is the reliable flow for multi-inbound
+		// clients; the canonical column can be left stale by SyncInbound (#4792).
+		if flow, err := s.EffectiveFlow(db, rows[i].Id); err == nil && flow != "" {
+			client.Flow = flow
+		}
+		out = append(out, ClientCreatePayload{
+			Client:     *client,
+			InboundIds: attachments[rows[i].Id],
+		})
+	}
+	return out, nil
+}
+
+// ImportClients recreates clients from an exported list. Items that carry
+// inboundIds go through the normal BulkCreate path (added to every inbound and
+// pushed to xray); items with no inboundIds are restored as bare records so an
+// orphan-inclusive export round-trips. Existing emails are never overwritten —
+// they are reported in Skipped. The boolean reports whether xray needs a restart.
+func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
+	result := BulkCreateResult{}
+	if len(items) == 0 {
+		return result, false, nil
+	}
+
+	attached := make([]ClientCreatePayload, 0, len(items))
+	orphans := make([]ClientCreatePayload, 0)
+	for i := range items {
+		if len(items[i].InboundIds) > 0 {
+			attached = append(attached, items[i])
+		} else {
+			orphans = append(orphans, items[i])
+		}
+	}
+
+	skip := func(email, reason string) {
+		if strings.TrimSpace(email) == "" {
+			email = "(missing email)"
+		}
+		result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
+	}
+
+	needRestart := false
+	if len(attached) > 0 {
+		sub, nr, err := s.BulkCreate(inboundSvc, attached)
+		if err != nil {
+			return result, needRestart, err
+		}
+		needRestart = needRestart || nr
+		result.Created += sub.Created
+		result.Skipped = append(result.Skipped, sub.Skipped...)
+	}
+
+	db := database.GetDB()
+	for i := range orphans {
+		client := orphans[i].Client
+		email := strings.TrimSpace(client.Email)
+		if email == "" {
+			skip("", "client email is required")
+			continue
+		}
+		if verr := validateClientEmail(email); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientSubID(client.SubID); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+
+		// An existing record (in the DB or just created from the attached set
+		// above) always wins — import never clobbers a live client.
+		var taken int64
+		if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&taken).Error; err != nil {
+			return result, needRestart, err
+		}
+		if taken > 0 {
+			skip(email, "email already in use: "+email)
+			continue
+		}
+
+		client.Email = email
+		if client.SubID == "" {
+			client.SubID = uuid.NewString()
+		}
+		if client.SubID != "" {
+			var subTaken int64
+			if err := db.Model(&model.ClientRecord{}).
+				Where("sub_id = ? AND email <> ?", client.SubID, email).
+				Count(&subTaken).Error; err != nil {
+				return result, needRestart, err
+			}
+			if subTaken > 0 {
+				skip(email, "subId already in use: "+client.SubID)
+				continue
+			}
+		}
+		if !client.Enable {
+			client.Enable = true
+		}
+		now := time.Now().UnixMilli()
+		if client.CreatedAt == 0 {
+			client.CreatedAt = now
+		}
+		client.UpdatedAt = now
+
+		if err := db.Create(client.ToRecord()).Error; err != nil {
+			skip(email, err.Error())
+			continue
+		}
+		result.Created++
+	}
+
+	return result, needRestart, nil
+}
+
+// DeleteOrphans removes every client that is not attached to any inbound,
+// together with its traffic rows, IP log, and external links. It mirrors the
+// cleanup the single-client Delete performs, batched into one transaction.
+// Returns the number of clients deleted.
+func (s *ClientService) DeleteOrphans() (int, error) {
+	db := database.GetDB()
+	sub := database.GetDB().Table("client_inbounds").Select("client_id")
+	var rows []model.ClientRecord
+	if err := db.Where("id NOT IN (?)", sub).Order("id ASC").Find(&rows).Error; err != nil {
+		return 0, err
+	}
+	if len(rows) == 0 {
+		return 0, nil
+	}
+
+	ids := make([]int, 0, len(rows))
+	emails := make([]string, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].Id)
+		if rows[i].Email != "" {
+			emails = append(emails, rows[i].Email)
+		}
+	}
+	tombstoneClientEmails(emails)
+
+	if err := runSerializedTx(func(tx *gorm.DB) error {
+		for _, batch := range chunkInts(ids, sqlInChunk) {
+			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
+				return e
+			}
+			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
+				return e
+			}
+		}
+		if len(emails) > 0 {
+			for _, batch := range chunkStrings(emails, sqlInChunk) {
+				if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
+					return e
+				}
+				if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
+					return e
+				}
+			}
+			if e := clearGlobalTraffic(tx, emails...); e != nil {
+				return e
+			}
+		}
+		for _, batch := range chunkInts(ids, sqlInChunk) {
+			if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
+				return e
+			}
+		}
+		return nil
+	}); err != nil {
+		return 0, err
+	}
+	return len(ids), nil
+}

+ 13 - 0
internal/web/service/email/subscriber.go

@@ -148,6 +148,19 @@ func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) {
 			body = wrap(subject, content)
 			body = wrap(subject, content)
 		}
 		}
 
 
+	case eventbus.EventMemoryHigh:
+		if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
+			smtpMemory, err := s.settingService.GetSmtpMemory()
+			if err != nil || smtpMemory <= 0 || data.Percent <= float64(smtpMemory) {
+				return
+			}
+			subject = host + " " + i18n("tgbot.messages.memoryThreshold",
+				"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
+				"Threshold=="+fmt.Sprintf("%d", smtpMemory))
+			content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
+			body = wrap(subject, content)
+		}
+
 	case eventbus.EventLoginAttempt:
 	case eventbus.EventLoginAttempt:
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 			if data.Status == "success" {
 			if data.Status == "success" {

+ 4 - 5
internal/web/service/metric_history.go

@@ -19,11 +19,10 @@ type MetricSample struct {
 	V float64 `json:"v"`
 	V float64 `json:"v"`
 }
 }
 
 
-// metricCapacityDefault caps each ring buffer at ~5h worth of @2s samples
-// or ~25h worth of @10s samples. Plenty for the bucketed aggregation
-// view and small enough that the working set per metric stays under
-// ~150 KiB.
-const metricCapacityDefault = 9000
+// metricCapacityDefault caps each ring buffer at 48h worth of @2s samples.
+// Node metrics arrive less frequently, so they fit the same retention window
+// with room to spare.
+const metricCapacityDefault = 86400
 
 
 // metricHistory is a thread-safe, in-memory ring buffer keyed by
 // metricHistory is a thread-safe, in-memory ring buffer keyed by
 // arbitrary strings. Two singletons live below: one for system-wide
 // arbitrary strings. Two singletons live below: one for system-wide

+ 3 - 0
internal/web/service/node.go

@@ -357,6 +357,9 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
 		return err
 	}
 	}
+	if dErr := s.MarkNodeDirty(id); dErr != nil {
+		logger.Warning("mark node dirty after update failed:", dErr)
+	}
 	if mgr := runtime.GetManager(); mgr != nil {
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 		mgr.InvalidateNode(id)
 	}
 	}

+ 50 - 0
internal/web/service/node_dirty_test.go

@@ -144,3 +144,53 @@ func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
 		t.Fatal("matching-token clear must clear the dirty flag")
 		t.Fatal("matching-token clear must clear the dirty flag")
 	}
 	}
 }
 }
+
+// Editing a node must mark it config-dirty so the next traffic-sync tick
+// reconciles (pushes the panel's inbounds to the remote) before pulling a
+// snapshot. Without the dirty flag, re-pointing a node to a fresh server
+// makes the orphan sweep delete every central inbound absent from the empty
+// snapshot (#5461).
+func TestNodeService_UpdateMarksNodeDirty(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	node := &model.Node{
+		Name:     "n1",
+		Address:  "10.0.0.1",
+		Port:     2096,
+		ApiToken: "tok",
+		Enable:   true,
+		Status:   "online",
+	}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	edited := &model.Node{
+		Name:     node.Name,
+		Address:  "10.0.0.2",
+		Port:     2097,
+		ApiToken: node.ApiToken,
+		Enable:   true,
+	}
+	nodeSvc := NodeService{}
+	if err := nodeSvc.Update(node.Id, edited); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	_, _, dirty, _, err := nodeSvc.NodeSyncState(node.Id)
+	if err != nil {
+		t.Fatalf("NodeSyncState: %v", err)
+	}
+	if !dirty {
+		t.Fatal("Update must mark the node config-dirty so sync reconciles before snapshot sweep (#5461)")
+	}
+
+	var got model.Node
+	if err := db.First(&got, node.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if got.Address != "10.0.0.2" || got.Port != 2097 {
+		t.Fatalf("node row not updated: address=%q port=%d", got.Address, got.Port)
+	}
+}

+ 124 - 38
internal/web/service/server.go

@@ -4,7 +4,6 @@ import (
 	"archive/zip"
 	"archive/zip"
 	"bufio"
 	"bufio"
 	"bytes"
 	"bytes"
-	"context"
 	"crypto/sha256"
 	"crypto/sha256"
 	"crypto/x509"
 	"crypto/x509"
 	"encoding/hex"
 	"encoding/hex"
@@ -13,6 +12,7 @@ import (
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"mime/multipart"
 	"mime/multipart"
+	stdnet "net"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
@@ -34,6 +34,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
+	utls "github.com/refraction-networking/utls"
 	"github.com/shirou/gopsutil/v4/cpu"
 	"github.com/shirou/gopsutil/v4/cpu"
 	"github.com/shirou/gopsutil/v4/disk"
 	"github.com/shirou/gopsutil/v4/disk"
 	"github.com/shirou/gopsutil/v4/host"
 	"github.com/shirou/gopsutil/v4/host"
@@ -158,12 +159,15 @@ const xrayVersionsCacheTTL = 15 * time.Minute
 // callers from triggering arbitrary aggregation work and keeps the
 // callers from triggering arbitrary aggregation work and keeps the
 // frontend's bucket selector self-documenting.
 // frontend's bucket selector self-documenting.
 var allowedHistoryBuckets = map[int]bool{
 var allowedHistoryBuckets = map[int]bool{
-	2:   true, // Real-time view
-	30:  true, // 30s intervals
-	60:  true, // 1m intervals
-	120: true, // 2m intervals
-	180: true, // 3m intervals
-	300: true, // 5m intervals
+	2:    true, // Real-time view
+	30:   true, // 30s intervals
+	60:   true, // 1m intervals
+	120:  true, // 2m intervals
+	180:  true, // 3m intervals
+	300:  true, // 5m intervals
+	720:  true, // 12m intervals
+	1440: true, // 24m intervals
+	2880: true, // 48m intervals
 }
 }
 
 
 // IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
 // IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
@@ -1732,7 +1736,16 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
 func (s *ServerService) GetCertHash(certFile string, certContent string) ([]string, error) {
 func (s *ServerService) GetCertHash(certFile string, certContent string) ([]string, error) {
 	var certBytes []byte
 	var certBytes []byte
 	if path := strings.TrimSpace(certFile); path != "" {
 	if path := strings.TrimSpace(certFile); path != "" {
-		b, err := os.ReadFile(path)
+		// Guard against path traversal: only hash certificate files the panel
+		// already references in its own configuration (an inbound's TLS
+		// certificateFile or the panel's own web cert). The path handed to
+		// os.ReadFile comes from that allow-list, never directly from the
+		// caller-supplied value.
+		known, ok := s.resolveKnownCertFile(path)
+		if !ok {
+			return nil, common.NewError("certificate file is not referenced by any inbound or panel setting")
+		}
+		b, err := os.ReadFile(known)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -1778,46 +1791,119 @@ func (s *ServerService) GetCertHash(certFile string, certContent string) ([]stri
 	return hashes, nil
 	return hashes, nil
 }
 }
 
 
-// GetRemoteCertHash runs `xray tls ping <server>` to fetch the live certificate
-// SHA-256 of a remote endpoint — the value to put in pinnedPeerCertSha256 (pcs)
-// when pinning a server whose certificate file you don't hold (a CDN front, a
-// REALITY dest, an external proxy). Returns the unique leaf-certificate hashes.
+// resolveKnownCertFile checks the caller-supplied certificate path against the
+// set of certificate files the panel already references (inbound TLS configs
+// plus the panel's own web cert) and, on a match, returns the path taken from
+// that configuration — not the caller's value. This both confines reads to
+// known certificates and breaks the user-input-to-filesystem taint flow.
+func (s *ServerService) resolveKnownCertFile(certFile string) (string, bool) {
+	want := filepath.Clean(certFile)
+	for _, known := range s.knownCertFiles() {
+		if filepath.Clean(known) == want {
+			return known, true
+		}
+	}
+	return "", false
+}
+
+// knownCertFiles collects every certificate file path the panel legitimately
+// references: the certificateFile of each inbound's TLS settings and the
+// panel's own web TLS certificate.
+func (s *ServerService) knownCertFiles() []string {
+	var files []string
+	if cert, err := s.settingService.GetCertFile(); err == nil {
+		if cert = strings.TrimSpace(cert); cert != "" {
+			files = append(files, cert)
+		}
+	}
+	if inbounds, err := s.inboundService.GetAllInbounds(); err == nil {
+		for _, inbound := range inbounds {
+			files = collectCertFiles(inbound.StreamSettings, files)
+		}
+	}
+	return files
+}
+
+// collectCertFiles walks a stream-settings JSON document and appends the value
+// of every "certificateFile" field it finds (TLS settings may nest them under
+// several keys depending on the security type).
+func collectCertFiles(streamSettings string, out []string) []string {
+	streamSettings = strings.TrimSpace(streamSettings)
+	if streamSettings == "" {
+		return out
+	}
+	var parsed any
+	if err := json.Unmarshal([]byte(streamSettings), &parsed); err != nil {
+		return out
+	}
+	return walkCertFiles(parsed, out)
+}
+
+func walkCertFiles(node any, out []string) []string {
+	switch v := node.(type) {
+	case map[string]any:
+		for key, val := range v {
+			if key == "certificateFile" {
+				if path, ok := val.(string); ok {
+					if path = strings.TrimSpace(path); path != "" {
+						out = append(out, path)
+					}
+				}
+			}
+			out = walkCertFiles(val, out)
+		}
+	case []any:
+		for _, item := range v {
+			out = walkCertFiles(item, out)
+		}
+	}
+	return out
+}
+
+// GetRemoteCertHash opens a uTLS (Chrome fingerprint) handshake to a remote
+// endpoint and returns the hex-encoded SHA-256 of its leaf certificate — the
+// value to put in pinnedPeerCertSha256 (pcs) when pinning a server whose
+// certificate file you don't hold (a CDN front, a REALITY dest, an external
+// proxy). A native handshake replaces the old `xray tls ping` subprocess so the
+// real dial/handshake failure (connection refused, timeout, …) surfaces
+// verbatim. `server` may be host or host:port; the port defaults to 443.
 func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
 func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
 	server = strings.TrimSpace(server)
 	server = strings.TrimSpace(server)
 	if server == "" {
 	if server == "" {
 		return nil, common.NewError("no server provided")
 		return nil, common.NewError("no server provided")
 	}
 	}
 
 
-	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
-	defer cancel()
-	cmd := exec.CommandContext(ctx, xray.GetBinaryPath(), "tls", "ping", server)
-	var out bytes.Buffer
-	cmd.Stdout = &out
-	cmd.Stderr = &out
-	if err := cmd.Run(); err != nil && out.Len() == 0 {
-		return nil, err
+	host, port := server, "443"
+	if h, p, err := stdnet.SplitHostPort(server); err == nil {
+		host, port = h, p
 	}
 	}
 
 
-	hexRe := regexp.MustCompile(`[0-9a-fA-F]{64}`)
-	seen := make(map[string]struct{})
-	var leaves []string
-	for _, line := range strings.Split(out.String(), "\n") {
-		if !strings.Contains(line, "leaf SHA256") {
-			continue
-		}
-		hash := strings.ToLower(hexRe.FindString(line))
-		if hash == "" {
-			continue
-		}
-		if _, ok := seen[hash]; !ok {
-			seen[hash] = struct{}{}
-			leaves = append(leaves, hash)
-		}
+	dialer := stdnet.Dialer{Timeout: 10 * time.Second}
+	tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
+	if err != nil {
+		return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
 	}
 	}
-	if len(leaves) == 0 {
-		return nil, common.NewError("no certificate hash found for ", server)
+	defer tcpConn.Close()
+	_ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))
+
+	tlsConn := utls.UClient(tcpConn, &utls.Config{
+		ServerName:         host,
+		InsecureSkipVerify: true,
+		NextProtos:         []string{"h2", "http/1.1"},
+	}, utls.HelloChrome_Auto)
+	defer tlsConn.Close()
+	if err := tlsConn.Handshake(); err != nil {
+		return nil, common.NewErrorf("tls handshake with %s failed: %s", host, err)
+	}
+
+	certs := tlsConn.ConnectionState().PeerCertificates
+	if len(certs) == 0 {
+		return nil, common.NewError("no certificate returned by ", host)
 	}
 	}
-	return leaves, nil
+	// PeerCertificates[0] is always the leaf the connection verifies against —
+	// robust for IP-only self-signed certs that carry no DNS SANs.
+	sum := sha256.Sum256(certs[0].Raw)
+	return []string{hex.EncodeToString(sum[:])}, nil
 }
 }
 
 
 func (s *ServerService) GetNewEchCert(sni string) (any, error) {
 func (s *ServerService) GetNewEchCert(sni string) (any, error) {

+ 18 - 0
internal/web/service/setting.go

@@ -63,6 +63,7 @@ var defaultValueMap = map[string]string{
 	"tgRunTime":                   "@daily",
 	"tgRunTime":                   "@daily",
 	"tgBotBackup":                 "false",
 	"tgBotBackup":                 "false",
 	"tgCpu":                       "80",
 	"tgCpu":                       "80",
+	"tgMemory":                    "80",
 	"tgLang":                      "en-US",
 	"tgLang":                      "en-US",
 	"twoFactorEnable":             "false",
 	"twoFactorEnable":             "false",
 	"twoFactorToken":              "",
 	"twoFactorToken":              "",
@@ -131,6 +132,7 @@ var defaultValueMap = map[string]string{
 	"tgEnabledEvents":   "login.attempt,cpu.high",
 	"tgEnabledEvents":   "login.attempt,cpu.high",
 	"smtpEnabledEvents": "login.attempt,cpu.high",
 	"smtpEnabledEvents": "login.attempt,cpu.high",
 	"smtpCpu":           "80",
 	"smtpCpu":           "80",
+	"smtpMemory":        "80",
 
 
 	// Email (SMTP) notifications
 	// Email (SMTP) notifications
 	"smtpEnable":         "false",
 	"smtpEnable":         "false",
@@ -531,6 +533,14 @@ func (s *SettingService) GetTgCpu() (int, error) {
 	return s.getInt("tgCpu")
 	return s.getInt("tgCpu")
 }
 }
 
 
+func (s *SettingService) GetTgMemory() (int, error) {
+	return s.getInt("tgMemory")
+}
+
+func (s *SettingService) SetTgMemory(value int) error {
+	return s.setInt("tgMemory", value)
+}
+
 func (s *SettingService) GetTgLang() (string, error) {
 func (s *SettingService) GetTgLang() (string, error) {
 	return s.getString("tgLang")
 	return s.getString("tgLang")
 }
 }
@@ -1017,6 +1027,14 @@ func (s *SettingService) SetSmtpCpu(value int) error {
 	return s.setInt("smtpCpu", value)
 	return s.setInt("smtpCpu", value)
 }
 }
 
 
+func (s *SettingService) GetSmtpMemory() (int, error) {
+	return s.getInt("smtpMemory")
+}
+
+func (s *SettingService) SetSmtpMemory(value int) error {
+	return s.setInt("smtpMemory", value)
+}
+
 func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
 func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
 	if err := s.preserveRedactedSecrets(allSetting); err != nil {
 	if err := s.preserveRedactedSecrets(allSetting); err != nil {
 		return err
 		return err

+ 12 - 0
internal/web/service/tgbot/tgbot_event.go

@@ -123,6 +123,18 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
 		}
 		}
 		return ""
 		return ""
 
 
+	case eventbus.EventMemoryHigh:
+		if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
+			tgMemory, err := t.settingService.GetTgMemory()
+			if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) {
+				return ""
+			}
+			return header + "🔴 " + t.I18nBot("tgbot.messages.memoryThreshold",
+				"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
+				"Threshold=="+strconv.Itoa(tgMemory))
+		}
+		return ""
+
 	case eventbus.EventLoginAttempt:
 	case eventbus.EventLoginAttempt:
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 			if data.Status == "success" {
 			if data.Status == "success" {

+ 14 - 3
internal/web/translation/ar-EG.json

@@ -828,6 +828,12 @@
       "delDepleted": "حذف المنتهية",
       "delDepleted": "حذف المنتهية",
       "delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
       "delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
       "delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
       "delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
+      "exportClients": "تصدير العملاء",
+      "importClients": "استيراد العملاء",
+      "import": "استيراد",
+      "delOrphans": "حذف العملاء غير المرتبطين",
+      "delOrphansConfirmTitle": "حذف العملاء بلا اتصال وارد؟",
+      "delOrphansConfirmContent": "يزيل كل عميل غير مرتبط بأي اتصال وارد مع سجل حركة مروره. لا يمكن التراجع.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
         "bulkAdjusted": "تم تعديل {count} عميل",
         "bulkAdjusted": "تم تعديل {count} عميل",
         "bulkAdjustedMixed": "{ok} تم تعديلهم، {skipped} تم تخطيهم",
         "bulkAdjustedMixed": "{ok} تم تعديلهم، {skipped} تم تخطيهم",
-        "delDepleted": "تم حذف {count} عميل منتهٍ"
+        "delDepleted": "تم حذف {count} عميل منتهٍ",
+        "delOrphans": "تم حذف {count} عميل غير مرتبط",
+        "imported": "تم استيراد {count} عميل",
+        "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
       "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
       "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
+      "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه."
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "عرض معرف Telegram الخاص بك"
       "idDesc": "عرض معرف Telegram الخاص بك"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)",
+      "cpuThreshold": "حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)",
       "selectUserFailed": "❌ حصل خطأ في اختيار المستخدم!",
       "selectUserFailed": "❌ حصل خطأ في اختيار المستخدم!",
       "userSaved": "✅ حفظت بيانات مستخدم Telegram.",
       "userSaved": "✅ حفظت بيانات مستخدم Telegram.",
       "loginSuccess": "✅ تسجيل الدخول للبانل تم بنجاح.\r\n",
       "loginSuccess": "✅ تسجيل الدخول للبانل تم بنجاح.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "العقدة {{ .Name }} متصلة",
       "eventNodeUp": "العقدة {{ .Name }} متصلة",
       "eventCPUHigh": "ارتفاع استخدام المعالج",
       "eventCPUHigh": "ارتفاع استخدام المعالج",
       "eventCPUHighDetail": "المعالج: {{ .Detail }}",
       "eventCPUHighDetail": "المعالج: {{ .Detail }}",
-      "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}"
+      "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
+      "memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ اقفل الكيبورد",
       "closeKeyboard": "❌ اقفل الكيبورد",

+ 15 - 4
internal/web/translation/en-US.json

@@ -828,6 +828,12 @@
       "delDepleted": "Delete depleted",
       "delDepleted": "Delete depleted",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
       "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
+      "exportClients": "Export clients",
+      "importClients": "Import clients",
+      "import": "Import",
+      "delOrphans": "Delete unattached clients",
+      "delOrphansConfirmTitle": "Delete clients without an inbound?",
+      "delOrphansConfirmContent": "Removes every client that is not attached to any inbound, along with its traffic record. This cannot be undone.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkAdjusted": "{count} clients adjusted",
         "bulkAdjusted": "{count} clients adjusted",
         "bulkAdjustedMixed": "{ok} adjusted, {skipped} skipped",
         "bulkAdjustedMixed": "{ok} adjusted, {skipped} skipped",
-        "delDepleted": "{count} depleted clients deleted"
+        "delDepleted": "{count} depleted clients deleted",
+        "delOrphans": "{count} unattached clients deleted",
+        "imported": "{count} clients imported",
+        "importedMixed": "{ok} imported, {failed} skipped"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1418,7 +1427,8 @@
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
       "smtpErrorEof": "Connection closed by server",
-      "smtpErrorUnknown": "SMTP error: {{ .Error }}"
+      "smtpErrorUnknown": "SMTP error: {{ .Error }}",
+      "eventMemoryHigh": "Memory high (%)"
     },
     },
     "xray": {
     "xray": {
       "title": "Xray Configs",
       "title": "Xray Configs",
@@ -1865,7 +1875,7 @@
       "idDesc": "Show your Telegram ID"
       "idDesc": "Show your Telegram ID"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
+      "cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
       "selectUserFailed": "❌ Error in user selection!",
       "selectUserFailed": "❌ Error in user selection!",
       "userSaved": "✅ Telegram User saved.",
       "userSaved": "✅ Telegram User saved.",
       "loginSuccess": "✅ Logged in to the panel successfully.\r\n",
       "loginSuccess": "✅ Logged in to the panel successfully.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} is UP",
       "eventNodeUp": "Node {{ .Name }} is UP",
       "eventCPUHigh": "CPU high",
       "eventCPUHigh": "CPU high",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Login failed from {{ .Source }}"
+      "eventLoginFallback": "Login failed from {{ .Source }}",
+      "memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Close Keyboard",
       "closeKeyboard": "❌ Close Keyboard",

+ 15 - 4
internal/web/translation/es-ES.json

@@ -828,6 +828,12 @@
       "delDepleted": "Eliminar agotados",
       "delDepleted": "Eliminar agotados",
       "delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
       "delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
       "delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
       "delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
+      "exportClients": "Exportar clientes",
+      "importClients": "Importar clientes",
+      "import": "Importar",
+      "delOrphans": "Eliminar clientes sin entrante",
+      "delOrphansConfirmTitle": "¿Eliminar clientes sin entrante?",
+      "delOrphansConfirmContent": "Elimina todos los clientes que no están vinculados a ningún entrante, junto con su registro de tráfico. No se puede deshacer.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} omitidos",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} omitidos",
-        "delDepleted": "{count} clientes agotados eliminados"
+        "delDepleted": "{count} clientes agotados eliminados",
+        "delOrphans": "{count} clientes sin entrante eliminados",
+        "imported": "{count} clientes importados",
+        "importedMixed": "{ok} importados, {failed} omitidos"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorEof": "Conexión cerrada por el servidor",
       "smtpErrorEof": "Conexión cerrada por el servidor",
       "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Mostrar tu ID de Telegram"
       "idDesc": "Mostrar tu ID de Telegram"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%",
+      "cpuThreshold": "El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%",
       "selectUserFailed": "❌ ¡Error al seleccionar usuario!",
       "selectUserFailed": "❌ ¡Error al seleccionar usuario!",
       "userSaved": "✅ Usuario de Telegram guardado.",
       "userSaved": "✅ Usuario de Telegram guardado.",
       "loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n",
       "loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
       "eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
       "eventCPUHigh": "CPU alta",
       "eventCPUHigh": "CPU alta",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}"
+      "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
+      "memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Cerrar Teclado",
       "closeKeyboard": "❌ Cerrar Teclado",
@@ -2043,4 +2054,4 @@
     "statusDown": "CAÍDO",
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
     "statusUp": "ACTIVO"
   }
   }
-}
+}

+ 16 - 5
internal/web/translation/fa-IR.json

@@ -828,6 +828,12 @@
       "delDepleted": "حذف اتمام‌یافته‌ها",
       "delDepleted": "حذف اتمام‌یافته‌ها",
       "delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
       "delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
       "delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
       "delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
+      "exportClients": "خروجی گرفتن از کلاینت‌ها",
+      "importClients": "ورود کلاینت‌ها",
+      "import": "ورود",
+      "delOrphans": "حذف کلاینت‌های بدون اینباند",
+      "delOrphansConfirmTitle": "حذف کلاینت‌های بدون اینباند؟",
+      "delOrphansConfirmContent": "هر کلاینتی که به هیچ اینباندی متصل نیست، همراه با رکورد ترافیک‌اش حذف می‌شود. این عمل غیرقابل بازگشت است.",
       "auth": "احراز",
       "auth": "احراز",
       "hysteriaAuth": "احراز Hysteria",
       "hysteriaAuth": "احراز Hysteria",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
         "bulkAdjusted": "{count} کلاینت تنظیم شد",
         "bulkAdjusted": "{count} کلاینت تنظیم شد",
         "bulkAdjustedMixed": "{ok} تنظیم، {skipped} رد شد",
         "bulkAdjustedMixed": "{ok} تنظیم، {skipped} رد شد",
-        "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد"
+        "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد",
+        "delOrphans": "{count} کلاینت بدون اینباند حذف شد",
+        "imported": "{count} کلاینت وارد شد",
+        "importedMixed": "{ok} وارد شد، {failed} رد شد"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1310,7 +1319,8 @@
       "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
       "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
-      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}"
+      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
+      "eventMemoryHigh": "مصرف حافظه بالا (%)"
     },
     },
     "xray": {
     "xray": {
       "title": "پیکربندی ایکس‌ری",
       "title": "پیکربندی ایکس‌ری",
@@ -1865,7 +1875,7 @@
       "idDesc": "نمایش شناسه تلگرام شما"
       "idDesc": "نمایش شناسه تلگرام شما"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 بار ‌پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%",
+      "cpuThreshold": "بار ‌پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%",
       "selectUserFailed": "❌ خطا در انتخاب کاربر!",
       "selectUserFailed": "❌ خطا در انتخاب کاربر!",
       "userSaved": "✅ کاربر تلگرام ذخیره شد.",
       "userSaved": "✅ کاربر تلگرام ذخیره شد.",
       "loginSuccess": "✅ با موفقیت به پنل وارد شدید.\r\n",
       "loginSuccess": "✅ با موفقیت به پنل وارد شدید.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "نود {{ .Name }} وصل است",
       "eventNodeUp": "نود {{ .Name }} وصل است",
       "eventCPUHigh": "بالا بودن CPU",
       "eventCPUHigh": "بالا بودن CPU",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "ورود ناموفق از {{ .Source }}"
+      "eventLoginFallback": "ورود ناموفق از {{ .Source }}",
+      "memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ بستن کیبورد",
       "closeKeyboard": "❌ بستن کیبورد",
@@ -2043,4 +2054,4 @@
     "statusDown": "قطع",
     "statusDown": "قطع",
     "statusUp": "وصل"
     "statusUp": "وصل"
   }
   }
-}
+}

+ 14 - 3
internal/web/translation/id-ID.json

@@ -828,6 +828,12 @@
       "delDepleted": "Hapus yang habis",
       "delDepleted": "Hapus yang habis",
       "delDepletedConfirmTitle": "Hapus klien yang habis?",
       "delDepletedConfirmTitle": "Hapus klien yang habis?",
       "delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.",
       "delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.",
+      "exportClients": "Ekspor klien",
+      "importClients": "Impor klien",
+      "import": "Impor",
+      "delOrphans": "Hapus klien tanpa inbound",
+      "delOrphansConfirmTitle": "Hapus klien tanpa inbound?",
+      "delOrphansConfirmContent": "Menghapus setiap klien yang tidak terhubung ke inbound mana pun, beserta catatan lalu lintasnya. Tidak dapat dibatalkan.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkAdjusted": "{count} klien disesuaikan",
         "bulkAdjusted": "{count} klien disesuaikan",
         "bulkAdjustedMixed": "{ok} disesuaikan, {skipped} dilewati",
         "bulkAdjustedMixed": "{ok} disesuaikan, {skipped} dilewati",
-        "delDepleted": "{count} klien habis dihapus"
+        "delDepleted": "{count} klien habis dihapus",
+        "delOrphans": "{count} klien tanpa inbound dihapus",
+        "imported": "{count} klien diimpor",
+        "importedMixed": "{ok} diimpor, {failed} dilewati"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorEof": "Koneksi ditutup oleh server",
       "smtpErrorEof": "Koneksi ditutup oleh server",
       "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Tampilkan ID Telegram Anda"
       "idDesc": "Tampilkan ID Telegram Anda"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",
+      "cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",
       "selectUserFailed": "❌ Kesalahan dalam pemilihan pengguna!",
       "selectUserFailed": "❌ Kesalahan dalam pemilihan pengguna!",
       "userSaved": "✅ Pengguna Telegram tersimpan.",
       "userSaved": "✅ Pengguna Telegram tersimpan.",
       "loginSuccess": "✅ Berhasil masuk ke panel.\r\n",
       "loginSuccess": "✅ Berhasil masuk ke panel.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} AKTIF",
       "eventNodeUp": "Node {{ .Name }} AKTIF",
       "eventCPUHigh": "CPU tinggi",
       "eventCPUHigh": "CPU tinggi",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Gagal masuk dari {{ .Source }}"
+      "eventLoginFallback": "Gagal masuk dari {{ .Source }}",
+      "memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Tutup Papan Ketik",
       "closeKeyboard": "❌ Tutup Papan Ketik",

+ 15 - 4
internal/web/translation/ja-JP.json

@@ -828,6 +828,12 @@
       "delDepleted": "使い切ったクライアントを削除",
       "delDepleted": "使い切ったクライアントを削除",
       "delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
       "delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
       "delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
       "delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
+      "exportClients": "クライアントをエクスポート",
+      "importClients": "クライアントをインポート",
+      "import": "インポート",
+      "delOrphans": "未アタッチのクライアントを削除",
+      "delOrphansConfirmTitle": "インバウンドのないクライアントを削除しますか?",
+      "delOrphansConfirmContent": "どのインバウンドにもアタッチされていないクライアントを、そのトラフィック記録とともにすべて削除します。元に戻せません。",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
         "bulkAdjusted": "{count} 件のクライアントを調整しました",
         "bulkAdjusted": "{count} 件のクライアントを調整しました",
         "bulkAdjustedMixed": "{ok} 件調整、{skipped} 件スキップ",
         "bulkAdjustedMixed": "{ok} 件調整、{skipped} 件スキップ",
-        "delDepleted": "使い切った {count} 件のクライアントを削除しました"
+        "delDepleted": "使い切った {count} 件のクライアントを削除しました",
+        "delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
+        "imported": "{count} 件のクライアントをインポートしました",
+        "importedMixed": "{ok} 件インポート、{failed} 件スキップ"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
       "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
       "smtpErrorEof": "サーバーによって接続が閉じられました",
       "smtpErrorEof": "サーバーによって接続が閉じられました",
       "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
       "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
+      "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。"
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。"
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Telegram IDを表示"
       "idDesc": "Telegram IDを表示"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました",
+      "cpuThreshold": "CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました",
       "selectUserFailed": "❌ ユーザーの選択に失敗しました!",
       "selectUserFailed": "❌ ユーザーの選択に失敗しました!",
       "userSaved": "✅ Telegramユーザーが保存されました。",
       "userSaved": "✅ Telegramユーザーが保存されました。",
       "loginSuccess": "✅ パネルに正常にログインしました。\r\n",
       "loginSuccess": "✅ パネルに正常にログインしました。\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "ノード {{ .Name }} が復旧しました",
       "eventNodeUp": "ノード {{ .Name }} が復旧しました",
       "eventCPUHigh": "CPU高負荷",
       "eventCPUHigh": "CPU高負荷",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "{{ .Source }} からのログインに失敗しました"
+      "eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
+      "memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ キーボードを閉じる",
       "closeKeyboard": "❌ キーボードを閉じる",
@@ -2043,4 +2054,4 @@
     "statusDown": "ダウン",
     "statusDown": "ダウン",
     "statusUp": "アップ"
     "statusUp": "アップ"
   }
   }
-}
+}

+ 15 - 4
internal/web/translation/pt-BR.json

@@ -828,6 +828,12 @@
       "delDepleted": "Excluir esgotados",
       "delDepleted": "Excluir esgotados",
       "delDepletedConfirmTitle": "Excluir clientes esgotados?",
       "delDepletedConfirmTitle": "Excluir clientes esgotados?",
       "delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.",
       "delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.",
+      "exportClients": "Exportar clientes",
+      "importClients": "Importar clientes",
+      "import": "Importar",
+      "delOrphans": "Excluir clientes sem inbound",
+      "delOrphansConfirmTitle": "Excluir clientes sem inbound?",
+      "delOrphansConfirmContent": "Remove todos os clientes que não estão vinculados a nenhum inbound, junto com seu registro de tráfego. Não é possível desfazer.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} ignorados",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} ignorados",
-        "delDepleted": "{count} clientes esgotados excluídos"
+        "delDepleted": "{count} clientes esgotados excluídos",
+        "delOrphans": "{count} clientes sem inbound excluídos",
+        "imported": "{count} clientes importados",
+        "importedMixed": "{ok} importados, {failed} ignorados"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
       "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Mostrar seu ID do Telegram"
       "idDesc": "Mostrar seu ID do Telegram"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%",
+      "cpuThreshold": "A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%",
       "selectUserFailed": "❌ Erro na seleção do usuário!",
       "selectUserFailed": "❌ Erro na seleção do usuário!",
       "userSaved": "✅ Usuário do Telegram salvo.",
       "userSaved": "✅ Usuário do Telegram salvo.",
       "loginSuccess": "✅ Conectado ao painel com sucesso.\r\n",
       "loginSuccess": "✅ Conectado ao painel com sucesso.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "O nó {{ .Name }} está ATIVO",
       "eventNodeUp": "O nó {{ .Name }} está ATIVO",
       "eventCPUHigh": "CPU alta",
       "eventCPUHigh": "CPU alta",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Falha de login a partir de {{ .Source }}"
+      "eventLoginFallback": "Falha de login a partir de {{ .Source }}",
+      "memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Fechar teclado",
       "closeKeyboard": "❌ Fechar teclado",
@@ -2043,4 +2054,4 @@
     "statusDown": "INATIVO",
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
     "statusUp": "ATIVO"
   }
   }
-}
+}

+ 15 - 4
internal/web/translation/ru-RU.json

@@ -828,6 +828,12 @@
       "delDepleted": "Удалить исчерпанных",
       "delDepleted": "Удалить исчерпанных",
       "delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
       "delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
       "delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
       "delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
+      "exportClients": "Экспортировать клиентов",
+      "importClients": "Импортировать клиентов",
+      "import": "Импорт",
+      "delOrphans": "Удалить клиентов без входящего",
+      "delOrphansConfirmTitle": "Удалить клиентов без входящего?",
+      "delOrphansConfirmContent": "Удаляются все клиенты, не привязанные ни к одному входящему, вместе с их записями трафика. Это действие нельзя отменить.",
       "auth": "Авторизация",
       "auth": "Авторизация",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
         "bulkAdjusted": "Изменено клиентов: {count}",
         "bulkAdjusted": "Изменено клиентов: {count}",
         "bulkAdjustedMixed": "Изменено: {ok}, пропущено: {skipped}",
         "bulkAdjustedMixed": "Изменено: {ok}, пропущено: {skipped}",
-        "delDepleted": "Удалено исчерпанных клиентов: {count}"
+        "delDepleted": "Удалено исчерпанных клиентов: {count}",
+        "delOrphans": "Удалено клиентов без входящего: {count}",
+        "imported": "Импортировано клиентов: {count}",
+        "importedMixed": "Импортировано: {ok}, пропущено: {failed}"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorEof": "Соединение закрыто сервером",
       "smtpErrorEof": "Соединение закрыто сервером",
       "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше."
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Показать ваш Telegram ID"
       "idDesc": "Показать ваш Telegram ID"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%",
+      "cpuThreshold": "Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%",
       "selectUserFailed": "❌ Ошибка при выборе пользователя.",
       "selectUserFailed": "❌ Ошибка при выборе пользователя.",
       "userSaved": "✅ Пользователь Telegram сохранен.",
       "userSaved": "✅ Пользователь Telegram сохранен.",
       "loginSuccess": "✅ Успешный вход в панель.\r\n",
       "loginSuccess": "✅ Успешный вход в панель.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Узел {{ .Name }} В СЕТИ",
       "eventNodeUp": "Узел {{ .Name }} В СЕТИ",
       "eventCPUHigh": "Высокая загрузка CPU",
       "eventCPUHigh": "Высокая загрузка CPU",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Неудачный вход с {{ .Source }}"
+      "eventLoginFallback": "Неудачный вход с {{ .Source }}",
+      "memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Закрыть клавиатуру",
       "closeKeyboard": "❌ Закрыть клавиатуру",
@@ -2043,4 +2054,4 @@
     "statusDown": "НЕДОСТУПЕН",
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
     "statusUp": "РАБОТАЕТ"
   }
   }
-}
+}

+ 14 - 3
internal/web/translation/tr-TR.json

@@ -828,6 +828,12 @@
       "delDepleted": "Süresi/Kotası Bitenleri Sil",
       "delDepleted": "Süresi/Kotası Bitenleri Sil",
       "delDepletedConfirmTitle": "Tükenmiş Kullanıcılar Silinsin Mi?",
       "delDepletedConfirmTitle": "Tükenmiş Kullanıcılar Silinsin Mi?",
       "delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm kullanıcılar silinir. Geri alınamaz.",
       "delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm kullanıcılar silinir. Geri alınamaz.",
+      "exportClients": "Kullanıcıları Dışa Aktar",
+      "importClients": "Kullanıcıları İçe Aktar",
+      "import": "İçe Aktar",
+      "delOrphans": "Bağsız Kullanıcıları Sil",
+      "delOrphansConfirmTitle": "Gelen Bağlantısı Olmayan Kullanıcılar Silinsin Mi?",
+      "delOrphansConfirmContent": "Hiçbir gelen bağlantıya bağlı olmayan her kullanıcı, trafik kaydıyla birlikte silinir. Geri alınamaz.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",
         "bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
         "bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
-        "delDepleted": "{count} tükenmiş kullanıcı silindi"
+        "delDepleted": "{count} tükenmiş kullanıcı silindi",
+        "delOrphans": "{count} bağsız kullanıcı silindi",
+        "imported": "{count} kullanıcı içe aktarıldı",
+        "importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
       "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
       "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
+      "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Telegram Kimliğinizi gösterir"
       "idDesc": "Telegram Kimliğinizi gösterir"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor",
+      "cpuThreshold": "CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor",
       "selectUserFailed": "❌ Kullanıcı seçiminde hata!",
       "selectUserFailed": "❌ Kullanıcı seçiminde hata!",
       "userSaved": "✅ Telegram Kullanıcısı kaydedildi.",
       "userSaved": "✅ Telegram Kullanıcısı kaydedildi.",
       "loginSuccess": "✅ Panele başarıyla giriş yapıldı.\r\n",
       "loginSuccess": "✅ Panele başarıyla giriş yapıldı.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
       "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
       "eventCPUHigh": "Yüksek CPU",
       "eventCPUHigh": "Yüksek CPU",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız"
+      "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
+      "memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Klavyeyi Kapat",
       "closeKeyboard": "❌ Klavyeyi Kapat",

+ 15 - 4
internal/web/translation/uk-UA.json

@@ -828,6 +828,12 @@
       "delDepleted": "Видалити вичерпаних",
       "delDepleted": "Видалити вичерпаних",
       "delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
       "delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
       "delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
       "delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
+      "exportClients": "Експортувати клієнтів",
+      "importClients": "Імпортувати клієнтів",
+      "import": "Імпорт",
+      "delOrphans": "Видалити клієнтів без вхідного",
+      "delOrphansConfirmTitle": "Видалити клієнтів без вхідного?",
+      "delOrphansConfirmContent": "Видаляється кожен клієнт, не прив'язаний до жодного вхідного, разом із його записом трафіку. Цю дію неможливо скасувати.",
       "auth": "Авторизація",
       "auth": "Авторизація",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
         "bulkAdjusted": "Змінено клієнтів: {count}",
         "bulkAdjusted": "Змінено клієнтів: {count}",
         "bulkAdjustedMixed": "Змінено: {ok}, пропущено: {skipped}",
         "bulkAdjustedMixed": "Змінено: {ok}, пропущено: {skipped}",
-        "delDepleted": "Видалено вичерпаних клієнтів: {count}"
+        "delDepleted": "Видалено вичерпаних клієнтів: {count}",
+        "delOrphans": "Видалено клієнтів без вхідного: {count}",
+        "imported": "Імпортовано клієнтів: {count}",
+        "importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorEof": "З'єднання закрито сервером",
       "smtpErrorEof": "З'єднання закрито сервером",
       "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище."
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Показати ваш Telegram ID"
       "idDesc": "Показати ваш Telegram ID"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 Навантаження ЦП  {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%",
+      "cpuThreshold": "Навантаження ЦП  {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%",
       "selectUserFailed": "❌ Помилка під час вибору користувача!",
       "selectUserFailed": "❌ Помилка під час вибору користувача!",
       "userSaved": "✅ Користувача Telegram збережено.",
       "userSaved": "✅ Користувача Telegram збережено.",
       "loginSuccess": "✅ Успішно ввійшли в панель\r\n",
       "loginSuccess": "✅ Успішно ввійшли в панель\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
       "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
       "eventCPUHigh": "Високе навантаження на CPU",
       "eventCPUHigh": "Високе навантаження на CPU",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Невдала спроба входу з {{ .Source }}"
+      "eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
+      "memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Закрити клавіатуру",
       "closeKeyboard": "❌ Закрити клавіатуру",
@@ -2043,4 +2054,4 @@
     "statusDown": "НЕДОСТУПНО",
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
     "statusUp": "ДОСТУПНО"
   }
   }
-}
+}

+ 15 - 4
internal/web/translation/vi-VN.json

@@ -828,6 +828,12 @@
       "delDepleted": "Xóa hết hạn mức",
       "delDepleted": "Xóa hết hạn mức",
       "delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?",
       "delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?",
       "delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.",
       "delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.",
+      "exportClients": "Xuất khách hàng",
+      "importClients": "Nhập khách hàng",
+      "import": "Nhập",
+      "delOrphans": "Xóa khách hàng không gắn inbound",
+      "delOrphansConfirmTitle": "Xóa khách hàng không thuộc inbound nào?",
+      "delOrphansConfirmContent": "Gỡ tất cả khách hàng không được gắn vào bất kỳ inbound nào, cùng với bản ghi lưu lượng của họ. Không thể hoàn tác.",
       "auth": "Auth",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
         "bulkAdjustedMixed": "Đã điều chỉnh {ok}, bỏ qua {skipped}",
         "bulkAdjustedMixed": "Đã điều chỉnh {ok}, bỏ qua {skipped}",
-        "delDepleted": "Đã xóa {count} khách hàng hết hạn mức"
+        "delDepleted": "Đã xóa {count} khách hàng hết hạn mức",
+        "delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
+        "imported": "Đã nhập {count} khách hàng",
+        "importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
       "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
       "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Hiển thị ID Telegram của bạn"
       "idDesc": "Hiển thị ID Telegram của bạn"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%",
+      "cpuThreshold": "Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%",
       "selectUserFailed": "❌ Lỗi khi chọn người dùng!",
       "selectUserFailed": "❌ Lỗi khi chọn người dùng!",
       "userSaved": "✅ Người dùng Telegram đã được lưu.",
       "userSaved": "✅ Người dùng Telegram đã được lưu.",
       "loginSuccess": "✅ Đăng nhập thành công vào bảng điều khiển.\r\n",
       "loginSuccess": "✅ Đăng nhập thành công vào bảng điều khiển.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
       "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
       "eventCPUHigh": "CPU cao",
       "eventCPUHigh": "CPU cao",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}"
+      "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
+      "memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ Đóng Bàn Phím",
       "closeKeyboard": "❌ Đóng Bàn Phím",
@@ -2043,4 +2054,4 @@
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
     "statusUp": "HOẠT ĐỘNG"
   }
   }
-}
+}

+ 18 - 7
internal/web/translation/zh-CN.json

@@ -828,6 +828,12 @@
       "delDepleted": "删除已耗尽",
       "delDepleted": "删除已耗尽",
       "delDepletedConfirmTitle": "删除已耗尽的客户端?",
       "delDepletedConfirmTitle": "删除已耗尽的客户端?",
       "delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
       "delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
+      "exportClients": "导出客户端",
+      "importClients": "导入客户端",
+      "import": "导入",
+      "delOrphans": "删除未关联的客户端",
+      "delOrphansConfirmTitle": "删除没有入站的客户端?",
+      "delOrphansConfirmContent": "删除所有未关联到任何入站的客户端及其流量记录。该操作不可撤销。",
       "auth": "认证",
       "auth": "认证",
       "hysteriaAuth": "Hysteria 认证",
       "hysteriaAuth": "Hysteria 认证",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
         "bulkAdjusted": "已调整 {count} 个客户端",
         "bulkAdjusted": "已调整 {count} 个客户端",
         "bulkAdjustedMixed": "已调整 {ok} 个,跳过 {skipped} 个",
         "bulkAdjustedMixed": "已调整 {ok} 个,跳过 {skipped} 个",
-        "delDepleted": "已删除 {count} 个已耗尽的客户端"
+        "delDepleted": "已删除 {count} 个已耗尽的客户端",
+        "delOrphans": "已删除 {count} 个未关联的客户端",
+        "imported": "已导入 {count} 个客户端",
+        "importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1107,11 +1116,11 @@
       "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
       "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
       "subThemeDirDocs": "模板指南 ↗",
       "subThemeDirDocs": "模板指南 ↗",
       "subEnableRouting": "启用路由",
       "subEnableRouting": "启用路由",
-      "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(限 Happ)",
-      "subRoutingRules": "路由規則",
-      "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(限 Happ)",
+      "subRoutingRules": "路由规则",
+      "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
       "subHideSettings": "隐藏服务器设置",
       "subHideSettings": "隐藏服务器设置",
-      "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(限 Happ)",
+      "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(限 Happ)",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashRoutingRules": "全局路由规则",
       "subClashRoutingRules": "全局路由规则",
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "服务器拒绝从此地址发送",
       "smtpErrorRelay": "服务器拒绝从此地址发送",
       "smtpErrorEof": "连接被服务器关闭",
       "smtpErrorEof": "连接被服务器关闭",
       "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
       "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
+      "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
       "remarkTemplate": "备注模板",
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。"
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。"
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "显示您的 Telegram ID"
       "idDesc": "显示您的 Telegram ID"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%",
+      "cpuThreshold": "CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%",
       "selectUserFailed": "❌ 用户选择错误!",
       "selectUserFailed": "❌ 用户选择错误!",
       "userSaved": "✅ 电报用户已保存。",
       "userSaved": "✅ 电报用户已保存。",
       "loginSuccess": "✅ 成功登录到面板。\r\n",
       "loginSuccess": "✅ 成功登录到面板。\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "节点 {{ .Name }} 已上线",
       "eventNodeUp": "节点 {{ .Name }} 已上线",
       "eventCPUHigh": "CPU 占用过高",
       "eventCPUHigh": "CPU 占用过高",
       "eventCPUHighDetail": "CPU:{{ .Detail }}",
       "eventCPUHighDetail": "CPU:{{ .Detail }}",
-      "eventLoginFallback": "来自 {{ .Source }} 的登录失败"
+      "eventLoginFallback": "来自 {{ .Source }} 的登录失败",
+      "memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ 关闭键盘",
       "closeKeyboard": "❌ 关闭键盘",

+ 15 - 4
internal/web/translation/zh-TW.json

@@ -828,6 +828,12 @@
       "delDepleted": "刪除已耗盡",
       "delDepleted": "刪除已耗盡",
       "delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
       "delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
       "delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
       "delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
+      "exportClients": "匯出客戶端",
+      "importClients": "匯入客戶端",
+      "import": "匯入",
+      "delOrphans": "刪除未關聯的客戶端",
+      "delOrphansConfirmTitle": "刪除沒有入站的客戶端?",
+      "delOrphansConfirmContent": "移除所有未關聯任何入站的客戶端,連同其流量紀錄一併刪除。此操作無法復原。",
       "auth": "認證",
       "auth": "認證",
       "hysteriaAuth": "Hysteria 認證",
       "hysteriaAuth": "Hysteria 認證",
       "uuid": "UUID",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
         "bulkAdjusted": "已調整 {count} 個客戶端",
         "bulkAdjusted": "已調整 {count} 個客戶端",
         "bulkAdjustedMixed": "已調整 {ok} 個,跳過 {skipped} 個",
         "bulkAdjustedMixed": "已調整 {ok} 個,跳過 {skipped} 個",
-        "delDepleted": "已刪除 {count} 個已耗盡的客戶端"
+        "delDepleted": "已刪除 {count} 個已耗盡的客戶端",
+        "delOrphans": "已刪除 {count} 個未關聯的客戶端",
+        "imported": "已匯入 {count} 個客戶端",
+        "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
       }
       }
     },
     },
     "groups": {
     "groups": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "伺服器拒絕從此地址傳送",
       "smtpErrorRelay": "伺服器拒絕從此地址傳送",
       "smtpErrorEof": "連線已被伺服器關閉",
       "smtpErrorEof": "連線已被伺服器關閉",
       "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
       "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
+      "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
       "remarkTemplate": "備註範本",
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。"
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。"
     },
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "顯示您的 Telegram ID"
       "idDesc": "顯示您的 Telegram ID"
     },
     },
     "messages": {
     "messages": {
-      "cpuThreshold": "🔴 CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%",
+      "cpuThreshold": "CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%",
       "selectUserFailed": "❌ 使用者選擇錯誤!",
       "selectUserFailed": "❌ 使用者選擇錯誤!",
       "userSaved": "✅ 電報使用者已儲存。",
       "userSaved": "✅ 電報使用者已儲存。",
       "loginSuccess": "✅ 成功登入到面板。\r\n",
       "loginSuccess": "✅ 成功登入到面板。\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "節點 {{ .Name }} 已上線",
       "eventNodeUp": "節點 {{ .Name }} 已上線",
       "eventCPUHigh": "CPU 偏高",
       "eventCPUHigh": "CPU 偏高",
       "eventCPUHighDetail": "CPU:{{ .Detail }}",
       "eventCPUHighDetail": "CPU:{{ .Detail }}",
-      "eventLoginFallback": "來自 {{ .Source }} 的登入失敗"
+      "eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
+      "memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%"
     },
     },
     "buttons": {
     "buttons": {
       "closeKeyboard": "❌ 關閉鍵盤",
       "closeKeyboard": "❌ 關閉鍵盤",
@@ -2043,4 +2054,4 @@
     "statusDown": "中斷",
     "statusDown": "中斷",
     "statusUp": "恢復"
     "statusUp": "恢復"
   }
   }
-}
+}

+ 36 - 1
internal/web/web.go

@@ -290,7 +290,8 @@ const (
 	cadenceCheckHash     = "@every 2m"
 	cadenceCheckHash     = "@every 2m"
 	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
 	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
 	// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
 	// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
-	cadenceCPUAlarm = "@every 1m"
+	cadenceCPUAlarm    = "@every 1m"
+	cadenceMemoryAlarm = "@every 1m"
 )
 )
 
 
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
@@ -385,6 +386,10 @@ func (s *Server) startTask(restartXray bool) {
 	if s.cpuAlarmWanted() {
 	if s.cpuAlarmWanted() {
 		s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
 		s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
 	}
 	}
+	// Memory monitor publishes memory.high events; register it whenever any notifier wants them.
+	if s.memoryAlarmWanted() {
+		s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
+	}
 }
 }
 
 
 // cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
 // cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
@@ -418,6 +423,36 @@ func (s *Server) cpuAlarmWanted() bool {
 	return false
 	return false
 }
 }
 
 
+// memoryAlarmWanted reports whether any notifier is configured to receive memory.high alerts.
+func (s *Server) memoryAlarmWanted() bool {
+	wants := func(events string, threshold int) bool {
+		if threshold <= 0 {
+			return false
+		}
+		for _, e := range strings.Split(events, ",") {
+			if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
+				return true
+			}
+		}
+		return false
+	}
+	if on, _ := s.settingService.GetTgbotEnabled(); on {
+		events, _ := s.settingService.GetTgEnabledEvents()
+		mem, _ := s.settingService.GetTgMemory()
+		if wants(events, mem) {
+			return true
+		}
+	}
+	if on, _ := s.settingService.GetSmtpEnable(); on {
+		events, _ := s.settingService.GetSmtpEnabledEvents()
+		mem, _ := s.settingService.GetSmtpMemory()
+		if wants(events, mem) {
+			return true
+		}
+	}
+	return false
+}
+
 // Start initializes and starts the web server with configured settings, routes, and background jobs.
 // Start initializes and starts the web server with configured settings, routes, and background jobs.
 func (s *Server) Start() (err error) {
 func (s *Server) Start() (err error) {
 	return s.start(true, true)
 	return s.start(true, true)