1
0

13 Коммиты 7c8889466b ... 0b0b6250d6

Автор SHA1 Сообщение Дата
  MHSanaei 0b0b6250d6 feat(clients): orphan cleanup + export/import via CodeMirror modals 12 часов назад
  MHSanaei 0483273839 fix(tls): pin remote cert via native uTLS handshake instead of xray subprocess 15 часов назад
  MHSanaei 03e89683dd fix(tls): ping the inbound's own port for remote cert pinning 16 часов назад
  MHSanaei 39774a6a38 fix(tls): default OCSP stapling to off for new inbound certs 16 часов назад
  MHSanaei 3aa76ea05b fix(deps): bump xray-core past finalmask UDP buffer fix (#5462) 17 часов назад
  MHSanaei 33b029e1ca fix(security): confine GetCertHash to known cert files (CWE-22) 17 часов назад
  qin9125 dfd77caf63 Update zh-CN.json (#5459) 17 часов назад
  Sentiago 891d3a8759 feat(memory): add memory threshold alerts (#5366) 17 часов назад
  shazzreab 648fc69cb1 feat(metrics): extend history bucket options to include 12h, 24h, and 48h intervals (#5467) 18 часов назад
  Nikan Zeyaei 6f05c0a492 fix(node): mark node dirty on Update so sync reconciles before snapshot sweep (#5469) 18 часов назад
  Nikan Zeyaei 5d88e68826 fix(frontend): guard IntlUtil.formatDate against out-of-range timestamps (#5468) 18 часов назад
  MHSanaei d20b549b04 fix(ci): use pull_request_target so claude bot gets secrets on fork PRs 18 часов назад
  MHSanaei 97c02ef69f feat(xray): preview export in a modal and switch rule enable toggle 19 часов назад
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]
   issue_comment:
     types: [created]
-  pull_request:
+  pull_request_target:
     types: [opened]
 
 permissions:
@@ -265,7 +265,7 @@ jobs:
               code, run builds/tests, commit, or open a PR.
 
   handle-pr:
-    if: github.event_name == 'pull_request'
+    if: github.event_name == 'pull_request_target'
     runs-on: ubuntu-latest
     permissions:
       contents: read

+ 168 - 0
frontend/public/openapi.json

@@ -160,6 +160,12 @@
             "description": "SMTP server host",
             "type": "string"
           },
+          "smtpMemory": {
+            "description": "Memory threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "smtpPassword": {
             "description": "SMTP password",
             "type": "string"
@@ -335,6 +341,12 @@
             "description": "Telegram bot language",
             "type": "string"
           },
+          "tgMemory": {
+            "description": "Memory usage threshold for alerts (percent)",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "tgRunTime": {
             "description": "Cron schedule for Telegram notifications",
             "type": "string"
@@ -428,6 +440,7 @@
           "smtpEnabledEvents",
           "smtpEncryptionType",
           "smtpHost",
+          "smtpMemory",
           "smtpPassword",
           "smtpPort",
           "smtpTo",
@@ -470,6 +483,7 @@
           "tgCpu",
           "tgEnabledEvents",
           "tgLang",
+          "tgMemory",
           "tgRunTime",
           "timeLocation",
           "trafficDiff",
@@ -641,6 +655,12 @@
             "description": "SMTP server host",
             "type": "string"
           },
+          "smtpMemory": {
+            "description": "Memory threshold for email notifications",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "smtpPassword": {
             "description": "SMTP password",
             "type": "string"
@@ -816,6 +836,12 @@
             "description": "Telegram bot language",
             "type": "string"
           },
+          "tgMemory": {
+            "description": "Memory usage threshold for alerts (percent)",
+            "maximum": 100,
+            "minimum": 0,
+            "type": "integer"
+          },
           "tgRunTime": {
             "description": "Cron schedule for Telegram notifications",
             "type": "string"
@@ -916,6 +942,7 @@
           "smtpEnabledEvents",
           "smtpEncryptionType",
           "smtpHost",
+          "smtpMemory",
           "smtpPassword",
           "smtpPort",
           "smtpTo",
@@ -958,6 +985,7 @@
           "tgCpu",
           "tgEnabledEvents",
           "tgLang",
+          "tgMemory",
           "tgRunTime",
           "timeLocation",
           "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": {
       "post": {
         "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 }} />
         ),
       },
+      {
+        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 }} />
         ),
       },
+      {
+        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": "",
     "smtpEncryptionType": "",
     "smtpHost": "",
+    "smtpMemory": 0,
     "smtpPassword": "",
     "smtpPort": 1,
     "smtpTo": "",
@@ -77,6 +78,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgCpu": 0,
     "tgEnabledEvents": "",
     "tgLang": "",
+    "tgMemory": 0,
     "tgRunTime": "",
     "timeLocation": "",
     "trafficDiff": 0,
@@ -133,6 +135,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpEnabledEvents": "",
     "smtpEncryptionType": "",
     "smtpHost": "",
+    "smtpMemory": 0,
     "smtpPassword": "",
     "smtpPort": 1,
     "smtpTo": "",
@@ -175,6 +178,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tgCpu": 0,
     "tgEnabledEvents": "",
     "tgLang": "",
+    "tgMemory": 0,
     "tgRunTime": "",
     "timeLocation": "",
     "trafficDiff": 0,

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

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

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

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

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

@@ -53,6 +53,7 @@ export const AllSettingSchema = z.object({
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
   smtpHost: z.string(),
+  smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
   smtpPort: z.number().int().min(1).max(65535),
   smtpTo: z.string(),
@@ -95,6 +96,7 @@ export const AllSettingSchema = z.object({
   tgCpu: z.number().int().min(0).max(100),
   tgEnabledEvents: z.string(),
   tgLang: z.string(),
+  tgMemory: z.number().int().min(0).max(100),
   tgRunTime: z.string(),
   timeLocation: z.string(),
   trafficDiff: z.number().int().min(0).max(100),
@@ -153,6 +155,7 @@ export const AllSettingViewSchema = z.object({
   smtpEnabledEvents: z.string(),
   smtpEncryptionType: z.string(),
   smtpHost: z.string(),
+  smtpMemory: z.number().int().min(0).max(100),
   smtpPassword: z.string(),
   smtpPort: z.number().int().min(1).max(65535),
   smtpTo: z.string(),
@@ -195,6 +198,7 @@ export const AllSettingViewSchema = z.object({
   tgCpu: z.number().int().min(0).max(100),
   tgEnabledEvents: z.string(),
   tgLang: z.string(),
+  tgMemory: z.number().int().min(0).max(100),
   tgRunTime: z.string(),
   timeLocation: z.string(),
   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(); },
   });
 
+  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 update = useCallback((email: string, client: unknown) => {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -459,6 +475,15 @@ export function useClients() {
   }, [resetTrafficMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
   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) => {
     if (!client?.email) return null;
@@ -575,6 +600,9 @@ export function useClients() {
     resetTraffic,
     resetAllTraffics,
     delDepleted,
+    delOrphans,
+    exportClients,
+    importClients,
     setEnable,
     applyTrafficEvent,
     applyClientStatsEvent,

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

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

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

@@ -22,6 +22,7 @@ export class AllSetting {
   tgRunTime = '@daily';
   tgBotBackup = false;
   tgCpu = 80;
+  tgMemory = 80;
   tgLang = 'en-US';
   twoFactorEnable = false;
   twoFactorToken = '';
@@ -91,6 +92,7 @@ export class AllSetting {
   smtpEncryptionType = 'starttls';
   smtpEnabledEvents = '';
   smtpCpu = 80;
+  smtpMemory = 80;
   hasTgBotToken = false;
   hasTwoFactorToken = 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.',
         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',
         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 {
   ClockCircleOutlined,
   DeleteOutlined,
+  DisconnectOutlined,
+  DownloadOutlined,
   EditOutlined,
   FilterOutlined,
   InfoCircleOutlined,
@@ -42,6 +44,7 @@ import {
   SortAscendingOutlined,
   TagsOutlined,
   TeamOutlined,
+  UploadOutlined,
   UsergroupAddOutlined,
   UsergroupDeleteOutlined,
 } from '@ant-design/icons';
@@ -69,6 +72,8 @@ const SubLinksModal = lazy(() => import('./SubLinksModal'));
 const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
 const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 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 type { ClientFilters } from './filters';
 import './ClientsPage.css';
@@ -200,7 +205,7 @@ export default function ClientsPage() {
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     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,
     refresh,
     hydrate,
@@ -233,6 +238,17 @@ export default function ClientsPage() {
   const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
   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 [searchKey, setSearchKey] = useState(initial.searchKey);
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
@@ -490,6 +506,40 @@ export default function ClientsPage() {
     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() {
     modal.confirm({
       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() {
     const emails = [...selectedRowKeys];
     if (emails.length === 0) return;
@@ -959,12 +1059,25 @@ export default function ClientsPage() {
                                     label: t('pages.clients.bulk'),
                                     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',
                                     icon: <RetweetOutlined />,
                                     label: t('pages.clients.resetAllTraffics'),
                                     onClick: onResetAllTraffics,
                                   },
+                                  { type: 'divider' as const },
                                   {
                                     key: 'delDepleted',
                                     icon: <RestOutlined />,
@@ -972,6 +1085,13 @@ export default function ClientsPage() {
                                     danger: true,
                                     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}
           />
         </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>
     </ConfigProvider>
   );

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

@@ -132,7 +132,7 @@ export default function TlsForm({
                   keyFile: '',
                   certificate: [],
                   key: [],
-                  ocspStapling: 3600,
+                  ocspStapling: 0,
                   oneTimeLoading: false,
                   usage: 'encipherment',
                   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'));
       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);
     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) {
         messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
         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: 180, label: '3h' },
               { value: 300, label: '5h' },
+              { value: 720, label: '12h' },
+              { value: 1440, label: '24h' },
+              { value: 2880, label: '48h' },
             ]}
           />
         </div>

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

@@ -37,8 +37,9 @@ import {
   ImportOutlined,
 } from '@ant-design/icons';
 
-import { FileManager, HttpUtil } from '@/utils';
+import { HttpUtil } from '@/utils';
 import PromptModal from '@/components/feedback/PromptModal';
+import TextModal from '@/components/feedback/TextModal';
 
 import OutboundFormModal from './OutboundFormModal';
 import { propagateOutboundTagRename } from '../basics/helpers';
@@ -226,11 +227,12 @@ export default function OutboundsTab({
   }
 
   const [importOpen, setImportOpen] = useState(false);
+  const [exportOpen, setExportOpen] = useState(false);
+  const [exportContent, setExportContent] = useState('');
 
   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) {
@@ -531,6 +533,14 @@ export default function OutboundsTab({
           json
           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) */}
         {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 { useTranslation } from 'react-i18next';
-import { Button, Modal, Space, Table, Tabs, message } from 'antd';
+import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
 import {
   AimOutlined,
   ControlOutlined,
   ExportOutlined,
   ImportOutlined,
+  MoreOutlined,
   PlusOutlined,
   UnorderedListOutlined,
 } from '@ant-design/icons';
 
 import { catTabLabel } from '@/pages/settings/catTabLabel';
-import { FileManager } from '@/utils';
 import PromptModal from '@/components/feedback/PromptModal';
+import TextModal from '@/components/feedback/TextModal';
 import RoutingBasic from './RoutingBasic';
 import RouteTester from './RouteTester';
 import RuleFormModal from './RuleFormModal';
@@ -144,11 +145,12 @@ export default function RoutingTab({
   }, [templateSettings?.routing?.balancers]);
 
   const [importOpen, setImportOpen] = useState(false);
+  const [exportOpen, setExportOpen] = useState(false);
+  const [exportContent, setExportContent] = useState('');
 
   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) {
@@ -333,16 +335,17 @@ export default function RoutingTab({
                   <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
                     {t('pages.xray.Routings')}
                   </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>
 
                 {isMobile ? (
@@ -405,6 +408,14 @@ export default function RoutingTab({
         json
         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 { 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 { InputAddon } from '@/components/ui';
 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.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 ?? {})}
-            options={[
-              { value: true, label: t('enable') },
-              { value: false, label: t('disable') },
-            ]}
           />
         </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({
   certificateFile: z.string().min(1),
   keyFile: z.string().min(1),
-  ocspStapling: z.number().default(3600),
+  ocspStapling: z.number().default(0),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),
@@ -47,7 +47,7 @@ export const TlsCertFileSchema = z.object({
 export const TlsCertInlineSchema = z.object({
   certificate: z.array(z.string()),
   key: z.array(z.string()),
-  ocspStapling: z.number().default(3600),
+  ocspStapling: z.number().default(0),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   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,
           "certificateFile": "/etc/ssl/certs/hysteria.crt",
           "keyFile": "/etc/ssl/private/hysteria.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -201,7 +201,7 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/trojan.crt",
           "keyFile": "/etc/ssl/private/trojan.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -379,7 +379,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -471,7 +471,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -570,7 +570,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/vmess.crt",
           "keyFile": "/etc/ssl/private/vmess.key",
-          "ocspStapling": 3600,
+          "ocspStapling": 0,
           "oneTimeLoading": false,
           "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,
         "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
         "keyFile": "/etc/ssl/private/cdn.example.test.key",
-        "ocspStapling": 3600,
+        "ocspStapling": 0,
         "oneTimeLoading": false,
         "usage": "encipherment",
       },

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

@@ -920,6 +920,8 @@ export type CalendarKind = 'gregorian' | 'jalalian';
 export class IntlUtil {
   static formatDate(date: string | number | Date | null | undefined, calendar: CalendarKind = 'gregorian'): string {
     if (date == null) return '';
+    const d = new Date(date);
+    if (!isFinite(d.getTime())) return '';
     const language = LanguageManager.getLanguage();
     const locale = calendar === 'jalalian' ? 'fa-IR' : language;
 
@@ -934,11 +936,12 @@ export class IntlUtil {
     };
 
     const intl = new Intl.DateTimeFormat(locale, intlOptions);
-    return intl.format(new Date(date));
+    return intl.format(d);
   }
 
   static formatRelativeTime(date: number | null | undefined): string {
     if (date == null) return '';
+    if (!isFinite(date)) return '';
     const language = LanguageManager.getLanguage();
     const now = new Date();
     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/valyala/fasthttp v1.71.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
 	golang.org/x/crypto v0.53.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/quic-go/qpack v0.6.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/sagernet/sing v0.8.10 // 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/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/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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 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"
 
 	// System health
-	EventCPUHigh EventType = "cpu.high"
+	EventCPUHigh    EventType = "cpu.high"
+	EventMemoryHigh EventType = "memory.high"
 
 	// Security
 	EventLoginAttempt EventType = "login.attempt"

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

@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"encoding/json"
 	"strconv"
 	"strings"
 
@@ -57,6 +58,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/detach", a.detach)
 	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("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
@@ -364,6 +368,58 @@ func (a *ClientController) delDepleted(c *gin.Context) {
 	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) {
 	email := c.Param("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
 	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)
+	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
 	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
 	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
-	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
 	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)
 		}
 
+	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:
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 			if data.Status == "success" {

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

@@ -19,11 +19,10 @@ type MetricSample struct {
 	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
 // 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 {
 		return err
 	}
+	if dErr := s.MarkNodeDirty(id); dErr != nil {
+		logger.Warning("mark node dirty after update failed:", dErr)
+	}
 	if mgr := runtime.GetManager(); mgr != nil {
 		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")
 	}
 }
+
+// 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"
 	"bufio"
 	"bytes"
-	"context"
 	"crypto/sha256"
 	"crypto/x509"
 	"encoding/hex"
@@ -13,6 +12,7 @@ import (
 	"fmt"
 	"io"
 	"mime/multipart"
+	stdnet "net"
 	"net/http"
 	"net/url"
 	"os"
@@ -34,6 +34,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/google/uuid"
+	utls "github.com/refraction-networking/utls"
 	"github.com/shirou/gopsutil/v4/cpu"
 	"github.com/shirou/gopsutil/v4/disk"
 	"github.com/shirou/gopsutil/v4/host"
@@ -158,12 +159,15 @@ const xrayVersionsCacheTTL = 15 * time.Minute
 // callers from triggering arbitrary aggregation work and keeps the
 // frontend's bucket selector self-documenting.
 var allowedHistoryBuckets = map[int]bool{
-	2:   true, // Real-time view
-	30:  true, // 30s intervals
-	60:  true, // 1m intervals
-	120: true, // 2m intervals
-	180: true, // 3m intervals
-	300: true, // 5m intervals
+	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
@@ -1732,7 +1736,16 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
 func (s *ServerService) GetCertHash(certFile string, certContent string) ([]string, error) {
 	var certBytes []byte
 	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 {
 			return nil, err
 		}
@@ -1778,46 +1791,119 @@ func (s *ServerService) GetCertHash(certFile string, certContent string) ([]stri
 	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) {
 	server = strings.TrimSpace(server)
 	if server == "" {
 		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) {

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

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

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

@@ -123,6 +123,18 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
 		}
 		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:
 		if data, ok := e.Data.(*eventbus.LoginEventData); ok {
 			if data.Status == "success" {

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

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

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

@@ -828,6 +828,12 @@
       "delDepleted": "Delete depleted",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkAdjusted": "{count} clients adjusted",
         "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": {
@@ -1418,7 +1427,8 @@
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
-      "smtpErrorUnknown": "SMTP error: {{ .Error }}"
+      "smtpErrorUnknown": "SMTP error: {{ .Error }}",
+      "eventMemoryHigh": "Memory high (%)"
     },
     "xray": {
       "title": "Xray Configs",
@@ -1865,7 +1875,7 @@
       "idDesc": "Show your Telegram ID"
     },
     "messages": {
-      "cpuThreshold": "🔴 CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
+      "cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
       "selectUserFailed": "❌ Error in user selection!",
       "userSaved": "✅ Telegram User saved.",
       "loginSuccess": "✅ Logged in to the panel successfully.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} is UP",
       "eventCPUHigh": "CPU high",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Login failed from {{ .Source }}"
+      "eventLoginFallback": "Login failed from {{ .Source }}",
+      "memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%"
     },
     "buttons": {
       "closeKeyboard": "❌ Close Keyboard",

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

@@ -828,6 +828,12 @@
       "delDepleted": "Eliminar agotados",
       "delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
       "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkAdjusted": "{count} clientes ajustados",
         "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": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorEof": "Conexión cerrada por el servidor",
       "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Mostrar tu ID de Telegram"
     },
     "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!",
       "userSaved": "✅ Usuario de Telegram guardado.",
       "loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
       "eventCPUHigh": "CPU alta",
       "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": {
       "closeKeyboard": "❌ Cerrar Teclado",
@@ -2043,4 +2054,4 @@
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
   }
-}
+}

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

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

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

@@ -828,6 +828,12 @@
       "delDepleted": "Hapus 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.",
+      "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkAdjusted": "{count} klien disesuaikan",
         "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": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorEof": "Koneksi ditutup oleh server",
       "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Tampilkan ID Telegram Anda"
     },
     "messages": {
-      "cpuThreshold": "🔴 Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",
+      "cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",
       "selectUserFailed": "❌ Kesalahan dalam pemilihan pengguna!",
       "userSaved": "✅ Pengguna Telegram tersimpan.",
       "loginSuccess": "✅ Berhasil masuk ke panel.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} AKTIF",
       "eventCPUHigh": "CPU tinggi",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Gagal masuk dari {{ .Source }}"
+      "eventLoginFallback": "Gagal masuk dari {{ .Source }}",
+      "memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%"
     },
     "buttons": {
       "closeKeyboard": "❌ Tutup Papan Ketik",

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

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

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

@@ -828,6 +828,12 @@
       "delDepleted": "Excluir 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.",
+      "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkAdjusted": "{count} clientes ajustados",
         "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": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
       "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Mostrar seu ID do Telegram"
     },
     "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!",
       "userSaved": "✅ Usuário do Telegram salvo.",
       "loginSuccess": "✅ Conectado ao painel com sucesso.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "O nó {{ .Name }} está ATIVO",
       "eventCPUHigh": "CPU alta",
       "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": {
       "closeKeyboard": "❌ Fechar teclado",
@@ -2043,4 +2054,4 @@
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
   }
-}
+}

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

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

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

@@ -828,6 +828,12 @@
       "delDepleted": "Süresi/Kotası Bitenleri Sil",
       "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.",
+      "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",
         "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": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
       "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
+      "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Telegram Kimliğinizi gösterir"
     },
     "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!",
       "userSaved": "✅ Telegram Kullanıcısı kaydedildi.",
       "loginSuccess": "✅ Panele başarıyla giriş yapıldı.\r\n",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
       "eventCPUHigh": "Yüksek CPU",
       "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": {
       "closeKeyboard": "❌ Klavyeyi Kapat",

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

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

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

@@ -828,6 +828,12 @@
       "delDepleted": "Xóa 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.",
+      "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",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
         "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": {
@@ -1309,6 +1318,7 @@
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
       "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
+      "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
     },
@@ -1865,7 +1875,7 @@
       "idDesc": "Hiển thị ID Telegram của bạn"
     },
     "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!",
       "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",
@@ -1940,7 +1950,8 @@
       "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
       "eventCPUHigh": "CPU cao",
       "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": {
       "closeKeyboard": "❌ Đóng Bàn Phím",
@@ -2043,4 +2054,4 @@
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
   }
-}
+}

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

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

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

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

+ 36 - 1
internal/web/web.go

@@ -290,7 +290,8 @@ const (
 	cadenceCheckHash     = "@every 2m"
 	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
 	// 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
@@ -385,6 +386,10 @@ func (s *Server) startTask(restartXray bool) {
 	if s.cpuAlarmWanted() {
 		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
@@ -418,6 +423,36 @@ func (s *Server) cpuAlarmWanted() bool {
 	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.
 func (s *Server) Start() (err error) {
 	return s.start(true, true)