1
0

11 Коммиты f90e4a6962 ... 5c725df702

Автор SHA1 Сообщение Дата
  MHSanaei 5c725df702 fix(ci): pin the tag smoke test to the release under test 4 часов назад
  MHSanaei d105b2741c fix(node): stop one rejected inbound from starving a node's traffic sync 5 часов назад
  MHSanaei 05cb70d8a8 feat(frontend): add text search to the inbound list 5 часов назад
  MHSanaei 323cf09d10 feat(sub): show the announcement on the subscription info page 5 часов назад
  MHSanaei 1f04912b6f feat(tgbot): register usage, inbound, restart and clearall in the bot command menu 5 часов назад
  MHSanaei 220dcb1579 feat(tgbot): show inbound remark alongside email in the online clients list 5 часов назад
  MHSanaei a13a79b230 fix(docker): start crond and persist acme.sh state so cert renewal works 5 часов назад
  MHSanaei ff3bd63656 feat(sub): serve the HTML info page for browser requests on JSON and Clash URLs 5 часов назад
  MHSanaei 052dd85ad3 feat(clients): hide disabled inbounds in the client form selector 5 часов назад
  MHSanaei b2ceb854f5 feat(tgbot): include hostname in backup and ban-log messages 5 часов назад
  MHSanaei dd4f55f690 feat(frontend): add text search to node select components 5 часов назад
36 измененных файлов с 381 добавлено и 63 удалено
  1. 35 1
      .github/workflows/smoke.yml
  2. 9 0
      DockerEntrypoint.sh
  3. 20 4
      deploy/test/smoke-noninteractive.sh
  4. 3 0
      docker-compose.yml
  5. 6 0
      frontend/public/openapi.json
  6. 1 0
      frontend/src/env.d.ts
  7. 1 0
      frontend/src/generated/examples.ts
  8. 5 0
      frontend/src/generated/schemas.ts
  9. 1 0
      frontend/src/generated/types.ts
  10. 1 0
      frontend/src/generated/zod.ts
  11. 2 1
      frontend/src/pages/clients/ClientFormModal.tsx
  12. 1 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  13. 24 4
      frontend/src/pages/inbounds/list/InboundList.tsx
  14. 5 0
      frontend/src/pages/sub/SubPage.tsx
  15. 20 0
      internal/database/db.go
  16. 51 27
      internal/sub/controller.go
  17. 9 6
      internal/web/job/node_traffic_sync_job.go
  18. 4 1
      internal/web/service/inbound.go
  19. 8 3
      internal/web/service/inbound_node.go
  20. 82 0
      internal/web/service/inbound_node_reconcile_test.go
  21. 4 0
      internal/web/service/tgbot/tgbot.go
  22. 9 3
      internal/web/service/tgbot/tgbot_report.go
  23. 15 0
      internal/web/service/tgbot/tgbot_router.go
  24. 5 1
      internal/web/translation/ar-EG.json
  25. 5 1
      internal/web/translation/en-US.json
  26. 5 1
      internal/web/translation/es-ES.json
  27. 5 1
      internal/web/translation/fa-IR.json
  28. 5 1
      internal/web/translation/id-ID.json
  29. 5 1
      internal/web/translation/ja-JP.json
  30. 5 1
      internal/web/translation/pt-BR.json
  31. 5 1
      internal/web/translation/ru-RU.json
  32. 5 1
      internal/web/translation/tr-TR.json
  33. 5 1
      internal/web/translation/uk-UA.json
  34. 5 1
      internal/web/translation/vi-VN.json
  35. 5 1
      internal/web/translation/zh-CN.json
  36. 5 1
      internal/web/translation/zh-TW.json

+ 35 - 1
.github/workflows/smoke.yml

@@ -1,10 +1,18 @@
 name: Deploy Smoke Tests
 
 # Container smoke test for the unattended (cloud-init) install path.
-# Runs only when the install/deploy assets change.
+# Runs when the install/deploy assets change on a branch push or PR, and
+# again after a release-tag build finishes uploading its assets — pinned to
+# that tag, so the green result verifies the release actually being shipped.
+# Tag pushes must NOT trigger the unpinned job directly: at that moment
+# releases/latest still points at the previous release (#5756), and a `paths`
+# filter alone cannot exclude them because a brand-new tag ref has no diff
+# base, so it runs on every tag push.
 
 on:
   push:
+    branches:
+      - "**"
     paths:
       - "install.sh"
       - "deploy/**"
@@ -14,12 +22,16 @@ on:
       - "install.sh"
       - "deploy/**"
       - ".github/workflows/smoke.yml"
+  workflow_run:
+    workflows: ["Release 3X-UI"]
+    types: [completed]
 
 permissions:
   contents: read
 
 jobs:
   noninteractive-install:
+    if: github.event_name != 'workflow_run'
     strategy:
       fail-fast: false
       matrix:
@@ -30,3 +42,25 @@ jobs:
       - uses: actions/checkout@v7
       - name: Non-interactive install smoke test
         run: bash deploy/test/smoke-noninteractive.sh
+
+  release-tag-install:
+    if: >-
+      github.event_name == 'workflow_run' &&
+      github.event.workflow_run.conclusion == 'success' &&
+      github.event.workflow_run.event == 'push' &&
+      startsWith(github.event.workflow_run.head_branch, 'v') &&
+      contains(github.event.workflow_run.head_branch, '.')
+    strategy:
+      fail-fast: false
+      matrix:
+        runner: [ubuntu-latest, ubuntu-24.04-arm]
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 15
+    steps:
+      - uses: actions/checkout@v7
+        with:
+          ref: ${{ github.event.workflow_run.head_sha }}
+      - name: Pinned release install smoke test
+        env:
+          XUI_SMOKE_VERSION: ${{ github.event.workflow_run.head_branch }}
+        run: bash deploy/test/smoke-noninteractive.sh "$XUI_SMOKE_VERSION"

+ 9 - 0
DockerEntrypoint.sh

@@ -69,5 +69,14 @@ EOF
     fail2ban-client -x start
 fi
 
+# Certificate auto-renewal: acme.sh (installed by the panel's SSL menu) relies
+# on a root crontab entry, but the crontab is lost when the container is
+# recreated and crond was never started. Re-register the job and run crond so
+# renewals actually fire; mount /root/.acme.sh as a volume to keep acme state.
+if [ -f /root/.acme.sh/acme.sh ]; then
+    /root/.acme.sh/acme.sh --install-cronjob >/dev/null 2>&1
+    crond
+fi
+
 # Run x-ui
 exec /app/x-ui

+ 20 - 4
deploy/test/smoke-noninteractive.sh

@@ -7,35 +7,51 @@
 #   * /etc/x-ui/install-result.env exists (mode 600) with random, non-default creds
 #   * the panel reports hasDefaultCredential: false (no admin/admin remains)
 #   * the panel HTTP server actually serves on the generated port/base path
+#   * with a [version] argument: the installed binary reports exactly that version
 #
 # Requires Docker and network access (install.sh downloads the released binary).
-# Usage: bash deploy/test/smoke-noninteractive.sh
+# Usage: bash deploy/test/smoke-noninteractive.sh [version]
+#   With no argument install.sh resolves releases/latest. Pass an explicit tag
+#   (e.g. v3.4.2) to verify that exact release — the tag-triggered CI run does
+#   this so it cannot silently validate the previous release (#5756).
 set -euo pipefail
 
 REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
 IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
+XUI_SMOKE_VERSION="${1:-}"
 
 if ! command -v docker > /dev/null 2>&1; then
     echo "ERROR: docker is required for this smoke test." >&2
     exit 1
 fi
 
-echo "== non-interactive install smoke test (image: $IMAGE) =="
+echo "== non-interactive install smoke test (image: $IMAGE, version: ${XUI_SMOKE_VERSION:-latest}) =="
 
 docker run --rm \
     -v "${REPO_ROOT}/install.sh:/root/install.sh:ro" \
     -e XUI_NONINTERACTIVE=1 \
     -e XUI_SSL_MODE=none \
+    -e XUI_SMOKE_VERSION="$XUI_SMOKE_VERSION" \
     -e DEBIAN_FRONTEND=noninteractive \
     "$IMAGE" bash -euo pipefail -c '
         apt-get update -qq
         apt-get install -y -qq curl tar openssl ca-certificates > /dev/null
 
-        echo "--- running install.sh piped (no TTY) ---"
+        echo "--- running install.sh piped (no TTY), version: ${XUI_SMOKE_VERSION:-latest} ---"
         # Piping guarantees stdin is not a TTY, exercising the auto non-interactive path.
-        cat /root/install.sh | bash
+        if [ -n "${XUI_SMOKE_VERSION:-}" ]; then
+            cat /root/install.sh | bash -s -- "$XUI_SMOKE_VERSION"
+        else
+            cat /root/install.sh | bash
+        fi
 
         echo "--- assertions ---"
+        if [ -n "${XUI_SMOKE_VERSION:-}" ]; then
+            installed=$(/usr/local/x-ui/x-ui -v)
+            [ "$installed" = "${XUI_SMOKE_VERSION#v}" ] \
+                || { echo "FAIL: installed version $installed, want ${XUI_SMOKE_VERSION#v}"; exit 1; }
+        fi
+
         RESULT=/etc/x-ui/install-result.env
         test -f "$RESULT" || { echo "FAIL: $RESULT missing"; exit 1; }
 

+ 3 - 0
docker-compose.yml

@@ -18,6 +18,9 @@ services:
     volumes:
       - $PWD/db/:/etc/x-ui/
       - $PWD/cert/:/root/cert/
+      # Persists acme.sh state so certificate auto-renewal survives container
+      # recreation (the entrypoint re-registers the renewal cron job from it).
+      - $PWD/acme/:/root/.acme.sh/
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"

+ 6 - 0
frontend/public/openapi.json

@@ -1824,6 +1824,10 @@
       },
       "InboundOption": {
         "properties": {
+          "enable": {
+            "example": true,
+            "type": "boolean"
+          },
           "id": {
             "example": 1,
             "type": "integer"
@@ -1880,6 +1884,7 @@
           }
         },
         "required": [
+          "enable",
           "id",
           "port",
           "protocol",
@@ -2795,6 +2800,7 @@
                   "success": true,
                   "obj": [
                     {
+                      "enable": true,
                       "id": 1,
                       "listen": "",
                       "nodeAddress": "",

+ 1 - 0
frontend/src/env.d.ts

@@ -18,6 +18,7 @@ interface SubPageData {
   links?: string[];
   emails?: string[];
   datepicker?: 'gregorian' | 'jalalian';
+  announce?: string;
   downloadByte?: string | number;
   uploadByte?: string | number;
   usedByte?: string | number;

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

@@ -399,6 +399,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "xver": 0
   },
   "InboundOption": {
+    "enable": true,
     "id": 1,
     "listen": "",
     "nodeAddress": "",

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

@@ -1798,6 +1798,10 @@ export const SCHEMAS: Record<string, unknown> = {
   },
   "InboundOption": {
     "properties": {
+      "enable": {
+        "example": true,
+        "type": "boolean"
+      },
       "id": {
         "example": 1,
         "type": "integer"
@@ -1854,6 +1858,7 @@ export const SCHEMAS: Record<string, unknown> = {
       }
     },
     "required": [
+      "enable",
       "id",
       "port",
       "protocol",

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

@@ -393,6 +393,7 @@ export interface InboundFallback {
 }
 
 export interface InboundOption {
+  enable: boolean;
   id: number;
   listen?: string;
   nodeAddress?: string;

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

@@ -420,6 +420,7 @@ export const InboundFallbackSchema = z.object({
 export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 
 export const InboundOptionSchema = z.object({
+  enable: z.boolean(),
   id: z.number().int(),
   listen: z.string().optional(),
   nodeAddress: z.string().optional(),

+ 2 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -376,12 +376,13 @@ export default function ClientFormModal({
   const inboundOptions = useMemo(
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
+      .filter((ib) => ib.enable || (form.inboundIds || []).includes(ib.id))
       .map((ib) => ({
         label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
         title: formatInboundLabel(ib.tag, ib.remark),
       })),
-    [inbounds],
+    [inbounds, form.inboundIds],
   );
 
   const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);

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

@@ -528,6 +528,7 @@ export default function InboundFormModal({
       {selectableNodes.length > 0 && isNodeEligible && (
         <Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
           <Select
+            showSearch
             disabled={mode === 'edit'}
             placeholder={t('pages.inbounds.localPanel')}
             allowClear

+ 24 - 4
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -5,6 +5,7 @@ import {
   Card,
   Checkbox,
   Dropdown,
+  Input,
   Select,
   Space,
   Switch,
@@ -22,6 +23,7 @@ import {
   ReloadOutlined,
   InfoCircleOutlined,
   DeleteOutlined,
+  SearchOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
@@ -56,6 +58,7 @@ export default function InboundList({
   // Node filter (#4997): 'all' shows everything, 0 is the local-panel
   // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
   const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
+  const [searchKey, setSearchKey] = useState('');
 
   const showNodeFilter = useMemo(
     () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
@@ -72,10 +75,17 @@ export default function InboundList({
   );
 
   const visibleInbounds = useMemo(() => {
-    if (nodeFilter === 'all') return dbInbounds;
-    if (nodeFilter === 0) return dbInbounds.filter((ib) => ib.nodeId == null);
-    return dbInbounds.filter((ib) => ib.nodeId === nodeFilter);
-  }, [dbInbounds, nodeFilter]);
+    let list = dbInbounds;
+    if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null);
+    else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
+    const q = searchKey.trim().toLowerCase();
+    if (!q) return list;
+    return list.filter((ib) => (
+      (ib.remark || '').toLowerCase().includes(q)
+      || String(ib.port).includes(q)
+      || (ib.protocol || '').toLowerCase().includes(q)
+    ));
+  }, [dbInbounds, nodeFilter, searchKey]);
 
   const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
     const previous = dbInbound.enable;
@@ -174,11 +184,21 @@ export default function InboundList({
               value={nodeFilter}
               onChange={(v) => setNodeFilter(v)}
               options={nodeFilterOptions}
+              showSearch
               popupMatchSelectWidth={false}
               style={{ minWidth: isMobile ? 90 : 140 }}
               aria-label={t('pages.clients.filters.nodes')}
             />
           )}
+          <Input
+            value={searchKey}
+            onChange={(e) => setSearchKey(e.target.value)}
+            placeholder={t('search')}
+            allowClear
+            prefix={<SearchOutlined />}
+            style={{ maxWidth: isMobile ? 110 : 200 }}
+            aria-label={t('search')}
+          />
           {selectedRowKeys.length > 0 && (
             <>
               <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>

+ 5 - 0
frontend/src/pages/sub/SubPage.tsx

@@ -1,6 +1,7 @@
 import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  Alert,
   Button,
   Card,
   Col,
@@ -61,6 +62,7 @@ const links: string[] = Array.isArray(subData.links) ? subData.links : [];
 const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
 const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
 const datepicker = subData.datepicker || 'gregorian';
+const announce = subData.announce || '';
 
 const isUnlimited = totalByte <= 0 && expireMs === 0;
 const isActive = (() => {
@@ -283,6 +285,9 @@ export default function SubPage() {
           <Row justify="center">
             <Col xs={24} sm={22} md={18} lg={14} xl={12}>
               <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
+                {announce && (
+                  <Alert type="info" showIcon message={announce} style={{ marginBottom: 16 }} />
+                )}
                 <Descriptions
                   bordered
                   column={1}

+ 20 - 0
internal/database/db.go

@@ -113,6 +113,9 @@ func initModels() error {
 	if err := normalizeInboundSubSortIndex(); err != nil {
 		return err
 	}
+	if err := migrateLegacySocksInboundsToMixed(); err != nil {
+		return err
+	}
 	if IsPostgres() {
 		if err := resyncPostgresSequences(db, models); err != nil {
 			log.Printf("Error resyncing postgres sequences: %v", err)
@@ -483,6 +486,23 @@ func pruneOrphanedClientInbounds() error {
 	return nil
 }
 
+// migrateLegacySocksInboundsToMixed renames legacy socks inbounds to mixed.
+// The protocol enum dropped socks in favor of mixed (identical settings shape,
+// same behavior plus HTTP on the shared port), so rows predating the rename
+// fail model validation — most visibly when pushed to a node, where one legacy
+// inbound stalled the entire node's config and traffic sync (#5685).
+func migrateLegacySocksInboundsToMixed() error {
+	res := db.Exec("UPDATE inbounds SET protocol = 'mixed' WHERE protocol = 'socks'")
+	if res.Error != nil {
+		log.Printf("Error migrating legacy socks inbounds to mixed: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Migrated %d legacy socks inbound(s) to mixed", res.RowsAffected)
+	}
+	return nil
+}
+
 // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
 // minimum (rows written by builds that defaulted the column to 0, or by nodes
 // predating the field) so they cannot sort ahead of explicitly ranked inbounds.

+ 51 - 27
internal/sub/controller.go

@@ -146,18 +146,54 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 	}
 }
 
+// maybeServeSubPage renders the HTML info page when the request comes from a
+// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
+// It reports whether the request was handled. The remark template's per-client
+// info is for the content a client app imports — the raw subscription body. A
+// browser viewing the HTML info page gets clean, name-only remarks (usage is
+// shown in the page summary).
+func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
+	accept := c.GetHeader("Accept")
+	wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
+	if !wantsHTML {
+		return false
+	}
+	subId := c.Param("subid")
+	_, host, _, hostHeader := a.subService.ResolveRequest(c)
+	subReq := a.subService.ForRequest(host)
+	subReq.subscriptionBody = false
+	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
+	if err != nil || len(subs) == 0 {
+		writeSubError(c, err)
+		return true
+	}
+	subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
+	if !a.jsonEnabled {
+		subJsonURL = ""
+	}
+	if !a.clashEnabled {
+		subClashURL = ""
+	}
+	basePath, exists := c.Get("base_path")
+	if !exists {
+		basePath = "/"
+	}
+	basePathStr := basePath.(string)
+	page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
+	a.serveSubPage(c, basePathStr, page)
+	return true
+}
+
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
 func (a *SUBController) subs(c *gin.Context) {
+	if a.maybeServeSubPage(c) {
+		return
+	}
 	subId := c.Param("subid")
-	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
+	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	subReq := a.subService.ForRequest(host)
-	// The remark template's per-client info is for the content a client app
-	// imports — the raw subscription body. A browser viewing the HTML info page
-	// gets clean, name-only remarks (usage is shown in the page summary).
-	accept := c.GetHeader("Accept")
-	wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
-	subReq.subscriptionBody = !wantsHTML
-	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
+	subReq.subscriptionBody = true
+	subs, _, _, traffic, err := subReq.getSubs(subId)
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
 	} else {
@@ -167,25 +203,6 @@ func (a *SUBController) subs(c *gin.Context) {
 			result.WriteString("\n")
 		}
 
-		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
-		if wantsHTML {
-			subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
-			if !a.jsonEnabled {
-				subJsonURL = ""
-			}
-			if !a.clashEnabled {
-				subClashURL = ""
-			}
-			basePath, exists := c.Get("base_path")
-			if !exists {
-				basePath = "/"
-			}
-			basePathStr := basePath.(string)
-			page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
-			a.serveSubPage(c, basePathStr, page)
-			return
-		}
-
 		// Add headers
 		header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 		profileUrl := a.subProfileUrl
@@ -264,6 +281,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 		"links":         page.Result,
 		"emails":        page.Emails,
 		"datepicker":    datepicker,
+		"announce":      a.subAnnounce,
 	}
 
 	// When an admin has configured a custom subscription theme, render it
@@ -366,6 +384,9 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
 
 // subJsons handles HTTP requests for JSON subscription configurations.
 func (a *SUBController) subJsons(c *gin.Context) {
+	if a.maybeServeSubPage(c) {
+		return
+	}
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
@@ -383,6 +404,9 @@ func (a *SUBController) subJsons(c *gin.Context) {
 }
 
 func (a *SUBController) subClashs(c *gin.Context) {
+	if a.maybeServeSubPage(c) {
+		return
+	}
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)

+ 9 - 6
internal/web/job/node_traffic_sync_job.go

@@ -368,13 +368,16 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
 		reconcileCancel()
 		if reconcileErr != nil {
-			logger.Warningf("node traffic sync: reconcile for %s failed: %v", n.Name, reconcileErr)
-			return nil
-		}
-		if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
-			logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
+			// The dirty flag stays set so reconcile retries next tick, but traffic
+			// accounting must keep flowing: one rejected inbound used to starve the
+			// whole node's traffic/online sync forever (#5685).
+			logger.Warningf("node traffic sync: reconcile for %s failed, continuing with traffic pull: %v", n.Name, reconcileErr)
+		} else {
+			if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
+				logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
+			}
+			j.structural.set()
 		}
-		j.structural.set()
 	}
 
 	ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)

+ 4 - 1
internal/web/service/inbound.go

@@ -297,6 +297,7 @@ type InboundOption struct {
 	Tag            string `json:"tag" example:"in-443-tcp"`
 	Protocol       string `json:"protocol" example:"vless"`
 	Port           int    `json:"port" example:"443"`
+	Enable         bool   `json:"enable" example:"true"`
 	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
 	SsMethod       string `json:"ssMethod"`
 	WgPublicKey    string `json:"wgPublicKey,omitempty"`
@@ -325,6 +326,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		Tag               string `gorm:"column:tag"`
 		Protocol          string `gorm:"column:protocol"`
 		Port              int    `gorm:"column:port"`
+		Enable            bool   `gorm:"column:enable"`
 		StreamSettings    string `gorm:"column:stream_settings"`
 		Settings          string `gorm:"column:settings"`
 		Listen            string `gorm:"column:listen"`
@@ -334,7 +336,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		NodeAddress       string `gorm:"column:node_address"`
 	}
 	err := db.Table("inbounds").
-		Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address").
+		Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.enable, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address").
 		Joins("LEFT JOIN nodes ON nodes.id = inbounds.node_id").
 		Where("inbounds.user_id = ?", userId).
 		Order("inbounds.id ASC").
@@ -355,6 +357,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Tag:               r.Tag,
 			Protocol:          r.Protocol,
 			Port:              r.Port,
+			Enable:            r.Enable,
 			TlsFlowCapable:    inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
 			WgPublicKey:       wgPublicKey,

+ 8 - 3
internal/web/service/inbound_node.go

@@ -82,6 +82,10 @@ func (s *InboundService) AnyNodePending(inboundIds []int) bool {
 	return false
 }
 
+// ReconcileNode pushes every inbound and sweeps undesired remote tags even when
+// individual operations fail, returning the failures joined: one inbound the
+// node rejects (e.g. a legacy protocol failing validation, #5685) must not
+// stall the rest of the node's config — or, via syncOne, its traffic sync.
 func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, n *model.Node) error {
 	if rt == nil || n == nil || n.Id <= 0 {
 		return nil
@@ -102,6 +106,7 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 	}
 	prefix := nodeTagPrefix(&nodeID)
 	desiredTags := make(map[string]struct{}, len(inbounds)*2)
+	var errs []error
 	for _, ib := range inbounds {
 		desiredTags[ib.Tag] = struct{}{}
 		// existsOnNode: does the node already report this inbound under any of the
@@ -121,7 +126,7 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			}
 		}
 		if _, err := rt.ReconcileInbound(ctx, ib, existsOnNode); err != nil {
-			return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
+			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 	}
 	// In "selected" sync mode the panel only manages the selected tags: the
@@ -145,10 +150,10 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			}
 		}
 		if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
-			return fmt.Errorf("reconcile delete %q: %w", tag, err)
+			errs = append(errs, fmt.Errorf("reconcile delete %q: %w", tag, err))
 		}
 	}
-	return nil
+	return errors.Join(errs...)
 }
 
 const resetGracePeriodMs int64 = 30000

+ 82 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -143,6 +143,88 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
 	}
 }
 
+// One inbound the node rejects (e.g. a legacy protocol failing the node's
+// request validation, #5685) must not abort the reconcile: the healthy inbound
+// is still pushed, the delete sweep still runs, and the returned error names
+// the failed tag so the caller keeps the dirty flag set for retry.
+func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
+	setupConflictDB(t)
+
+	var mu sync.Mutex
+	updated := map[int]int{}
+	var deleted []int
+	tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3}
+	writeOK := func(w http.ResponseWriter, obj any) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
+		type row struct {
+			Id  int    `json:"id"`
+			Tag string `json:"tag"`
+		}
+		rows := make([]row, 0, len(tagToID))
+		for tag, id := range tagToID {
+			rows = append(rows, row{Id: id, Tag: tag})
+		}
+		writeOK(w, rows)
+	})
+	mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
+		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
+		if err != nil {
+			http.Error(w, "bad id", http.StatusBadRequest)
+			return
+		}
+		if id == tagToID["legacy"] {
+			http.Error(w, "request body failed validation", http.StatusBadRequest)
+			return
+		}
+		mu.Lock()
+		updated[id]++
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
+		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
+		if err != nil {
+			http.Error(w, "bad id", http.StatusBadRequest)
+			return
+		}
+		mu.Lock()
+		deleted = append(deleted, id)
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+
+	node := reconcileTestNode(t, ts, "half-broken-node", "all", nil)
+	seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id)
+	seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+	svc := InboundService{}
+	err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
+	if err == nil {
+		t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil")
+	}
+	if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) {
+		t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err)
+	}
+
+	mu.Lock()
+	healthyPushes := updated[tagToID["healthy"]]
+	gotDeleted := append([]int(nil), deleted...)
+	mu.Unlock()
+	if healthyPushes != 1 {
+		t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes)
+	}
+	sort.Ints(gotDeleted)
+	if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] {
+		t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"])
+	}
+}
+
 func TestEnsureInboundTagAllowed(t *testing.T) {
 	setupConflictDB(t)
 	db := database.GetDB()

+ 4 - 0
internal/web/service/tgbot/tgbot.go

@@ -345,6 +345,10 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
 			{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
 			{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
 			{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
+			{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
+			{Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")},
+			{Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")},
+			{Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")},
 		},
 	})
 	if err != nil {

+ 9 - 3
internal/web/service/tgbot/tgbot_report.go

@@ -374,7 +374,11 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
 	if onlinesCount > 0 {
 		var buttons []telego.InlineKeyboardButton
 		for _, online := range onlines {
-			buttons = append(buttons, tu.InlineKeyboardButton(online).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
+			label := online
+			if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
+				label = online + " - " + inbound.Remark
+			}
+			buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
 		}
 		cols := 0
 		if onlinesCount < 21 {
@@ -396,7 +400,8 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
 
 // sendBackup sends a backup of the database and configuration files.
 func (t *Tgbot) sendBackup(chatId int64) {
-	output := t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
+	output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
+	output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
 	t.SendMsgToTgbot(chatId, output)
 
 	// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
@@ -442,7 +447,8 @@ func (t *Tgbot) sendBackup(chatId int64) {
 // sendBanLogs sends the ban logs to the specified chat.
 func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
 	if dt {
-		output := t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
+		output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
+		output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
 		t.SendMsgToTgbot(chatId, output)
 	}
 

+ 15 - 0
internal/web/service/tgbot/tgbot_router.go

@@ -237,6 +237,21 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
 		} else {
 			handleUnknownCommand()
 		}
+	case "clearall":
+		onlyMessage = true
+		if isAdmin {
+			inlineKeyboard := tu.InlineKeyboard(
+				tu.InlineKeyboardRow(
+					tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("reset_all_traffics_cancel")),
+				),
+				tu.InlineKeyboardRow(
+					tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_all_traffics_c")),
+				),
+			)
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.AreYouSure"), inlineKeyboard)
+		} else {
+			handleUnknownCommand()
+		}
 	default:
 		handleUnknownCommand()
 	}

+ 5 - 1
internal/web/translation/ar-EG.json

@@ -2007,7 +2007,11 @@
       "startDesc": "عرض القائمة الرئيسية",
       "helpDesc": "مساعدة البوت",
       "statusDesc": "التحقق من حالة البوت",
-      "idDesc": "عرض معرف Telegram الخاص بك"
+      "idDesc": "عرض معرف Telegram الخاص بك",
+      "usageDesc": "عرض استهلاك العميل: /usage البريد",
+      "inboundDesc": "البحث في الواردات: /inbound الاسم (مشرف)",
+      "restartDesc": "إعادة تشغيل نواة Xray (مشرف)",
+      "clearallDesc": "تصفير استهلاك جميع العملاء (مشرف)"
     },
     "messages": {
       "cpuThreshold": "حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)",

+ 5 - 1
internal/web/translation/en-US.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Show the main menu",
       "helpDesc": "Bot help",
       "statusDesc": "Check bot status",
-      "idDesc": "Show your Telegram ID"
+      "idDesc": "Show your Telegram ID",
+      "usageDesc": "Show client usage: /usage email",
+      "inboundDesc": "Search inbounds: /inbound remark (admin)",
+      "restartDesc": "Restart Xray core (admin)",
+      "clearallDesc": "Reset all clients' traffic (admin)"
     },
     "messages": {
       "cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/es-ES.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Mostrar el menú principal",
       "helpDesc": "Ayuda del bot",
       "statusDesc": "Comprobar el estado del bot",
-      "idDesc": "Mostrar tu ID de Telegram"
+      "idDesc": "Mostrar tu ID de Telegram",
+      "usageDesc": "Ver el uso del cliente: /usage correo",
+      "inboundDesc": "Buscar entradas: /inbound nombre (admin)",
+      "restartDesc": "Reiniciar el núcleo de Xray (admin)",
+      "clearallDesc": "Restablecer el tráfico de todos los clientes (admin)"
     },
     "messages": {
       "cpuThreshold": "El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%",

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

@@ -2007,7 +2007,11 @@
       "startDesc": "نمایش منوی اصلی",
       "helpDesc": "راهنمای ربات",
       "statusDesc": "بررسی وضعیت ربات",
-      "idDesc": "نمایش شناسه تلگرام شما"
+      "idDesc": "نمایش شناسه تلگرام شما",
+      "usageDesc": "مشاهده مصرف کاربر: /usage ایمیل",
+      "inboundDesc": "جستجوی ورودی‌ها: /inbound نام (مدیر)",
+      "restartDesc": "راه‌اندازی مجدد هسته Xray (مدیر)",
+      "clearallDesc": "صفر کردن ترافیک همه کاربران (مدیر)"
     },
     "messages": {
       "cpuThreshold": "بار ‌پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/id-ID.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Tampilkan menu utama",
       "helpDesc": "Bantuan bot",
       "statusDesc": "Periksa status bot",
-      "idDesc": "Tampilkan ID Telegram Anda"
+      "idDesc": "Tampilkan ID Telegram Anda",
+      "usageDesc": "Lihat pemakaian klien: /usage email",
+      "inboundDesc": "Cari inbound: /inbound nama (admin)",
+      "restartDesc": "Mulai ulang inti Xray (admin)",
+      "clearallDesc": "Reset trafik semua klien (admin)"
     },
     "messages": {
       "cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/ja-JP.json

@@ -2007,7 +2007,11 @@
       "startDesc": "メインメニューを表示",
       "helpDesc": "ボットのヘルプ",
       "statusDesc": "ボットの状態を確認",
-      "idDesc": "Telegram IDを表示"
+      "idDesc": "Telegram IDを表示",
+      "usageDesc": "クライアント使用量を表示: /usage メール",
+      "inboundDesc": "インバウンド検索: /inbound 備考(管理者)",
+      "restartDesc": "Xray コアを再起動(管理者)",
+      "clearallDesc": "全クライアントのトラフィックをリセット(管理者)"
     },
     "messages": {
       "cpuThreshold": "CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました",

+ 5 - 1
internal/web/translation/pt-BR.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Mostrar menu principal",
       "helpDesc": "Ajuda do bot",
       "statusDesc": "Verificar status do bot",
-      "idDesc": "Mostrar seu ID do Telegram"
+      "idDesc": "Mostrar seu ID do Telegram",
+      "usageDesc": "Ver o uso do cliente: /usage email",
+      "inboundDesc": "Buscar entradas: /inbound nome (admin)",
+      "restartDesc": "Reiniciar o núcleo Xray (admin)",
+      "clearallDesc": "Zerar o tráfego de todos os clientes (admin)"
     },
     "messages": {
       "cpuThreshold": "A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/ru-RU.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Показать главное меню",
       "helpDesc": "Справка по боту",
       "statusDesc": "Проверить статус бота",
-      "idDesc": "Показать ваш Telegram ID"
+      "idDesc": "Показать ваш Telegram ID",
+      "usageDesc": "Показать трафик клиента: /usage email",
+      "inboundDesc": "Поиск входящих: /inbound имя (админ)",
+      "restartDesc": "Перезапустить ядро Xray (админ)",
+      "clearallDesc": "Сбросить трафик всех клиентов (админ)"
     },
     "messages": {
       "cpuThreshold": "Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/tr-TR.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Ana menüyü göster",
       "helpDesc": "Bot yardımı",
       "statusDesc": "Bot durumunu kontrol et",
-      "idDesc": "Telegram Kimliğinizi gösterir"
+      "idDesc": "Telegram Kimliğinizi gösterir",
+      "usageDesc": "İstemci kullanımını göster: /usage e-posta",
+      "inboundDesc": "Gelenleri ara: /inbound ad (yönetici)",
+      "restartDesc": "Xray çekirdeğini yeniden başlat (yönetici)",
+      "clearallDesc": "Tüm istemcilerin trafiğini sıfırla (yönetici)"
     },
     "messages": {
       "cpuThreshold": "CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor",

+ 5 - 1
internal/web/translation/uk-UA.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Показати головне меню",
       "helpDesc": "Довідка по боту",
       "statusDesc": "Перевірити статус бота",
-      "idDesc": "Показати ваш Telegram ID"
+      "idDesc": "Показати ваш Telegram ID",
+      "usageDesc": "Показати трафік клієнта: /usage email",
+      "inboundDesc": "Пошук вхідних: /inbound назва (адмін)",
+      "restartDesc": "Перезапустити ядро Xray (адмін)",
+      "clearallDesc": "Скинути трафік усіх клієнтів (адмін)"
     },
     "messages": {
       "cpuThreshold": "Навантаження ЦП  {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/vi-VN.json

@@ -2007,7 +2007,11 @@
       "startDesc": "Hiển thị menu chính",
       "helpDesc": "Trợ giúp bot",
       "statusDesc": "Kiểm tra trạng thái bot",
-      "idDesc": "Hiển thị ID Telegram của bạn"
+      "idDesc": "Hiển thị ID Telegram của bạn",
+      "usageDesc": "Xem mức dùng của khách: /usage email",
+      "inboundDesc": "Tìm inbound: /inbound tên (quản trị)",
+      "restartDesc": "Khởi động lại lõi Xray (quản trị)",
+      "clearallDesc": "Đặt lại lưu lượng mọi khách hàng (quản trị)"
     },
     "messages": {
       "cpuThreshold": "Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/zh-CN.json

@@ -2007,7 +2007,11 @@
       "startDesc": "显示主菜单",
       "helpDesc": "机器人帮助",
       "statusDesc": "检查机器人状态",
-      "idDesc": "显示您的 Telegram ID"
+      "idDesc": "显示您的 Telegram ID",
+      "usageDesc": "查看客户端用量:/usage 邮箱",
+      "inboundDesc": "搜索入站:/inbound 备注(管理员)",
+      "restartDesc": "重启 Xray 内核(管理员)",
+      "clearallDesc": "重置所有客户端流量(管理员)"
     },
     "messages": {
       "cpuThreshold": "CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%",

+ 5 - 1
internal/web/translation/zh-TW.json

@@ -2007,7 +2007,11 @@
       "startDesc": "顯示主選單",
       "helpDesc": "機器人幫助",
       "statusDesc": "檢查機器人狀態",
-      "idDesc": "顯示您的 Telegram ID"
+      "idDesc": "顯示您的 Telegram ID",
+      "usageDesc": "查看客戶端用量:/usage 郵箱",
+      "inboundDesc": "搜尋入站:/inbound 備註(管理員)",
+      "restartDesc": "重啟 Xray 核心(管理員)",
+      "clearallDesc": "重置所有客戶端流量(管理員)"
     },
     "messages": {
       "cpuThreshold": "CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%",